O Arachne ganhou olhos — VLM, fila de jobs e cache de visão
Arachne·

O Arachne ganhou olhos — VLM, fila de jobs e cache de visão

8 min de leitura← Voltar para timeline

O dia em que o scraper aprendeu a enxergar

Quando lancei o pipeline de visão do Arachne em julho, ele tinha 8 estágios: 7 de OpenCV/Tesseract/NumPy puros e o último opcional com gemma4:12b no Ollama local. Funcionava, mas havia um problema — o VLM rodava inline na requisição, travando o worker por 10-30s. Numa plataforma que serve scraping + RAG + visão, isso não escala.

A solução não foi “otimizar o modelo”. Foi arquitetar para assincronicidade.

A nova arquitetura: fila + worker + cache

1. O modelo: qwen2.5vl:7b no Ollama (WSL)

Troquei o gemma4:12b pelo qwen2.5vl:7b — menor, mais rápido e surpreendentemente melhor em OCR e descrição técnica. Roda no Ollama dentro do WSL (Ubuntu) com passagem de GPU via CUDA.

# Ollama no WSL — GPU visível
ollama pull qwen2.5vl:7b
ollama run qwen2.5vl:7b "Descreva esta imagem técnica"  # ~3-5s na RTX 3080

O truque: WSL2 + drivers NVIDIA do Windows = CUDA funciona nativo. Sem Docker, sem Kubernetes. O Ollama no WSL enxerga a GPU diretamente.

2. Fila de jobs: arachne-vlm-jobs (Redis + BullMQ)

Visão virou um job assíncrono. O cliente faz POST /api/extract/image com a imagem (upload ou URL), recebe job_id na hora e faz polling em GET /api/extract/image-jobs/{id}.

# app/queue/vlm_queue.py
from bullmq import Queue, Worker

vlm_queue = Queue("arachne-vlm-jobs", connection=redis)

async def enqueue_vlm_job(image_data: bytes, options: dict) -> str:
    job = await vlm_queue.add("process-vision", {
        "image_sha256": hashlib.sha256(image_data).hexdigest(),
        "image_data": base64.b64encode(image_data).decode(),
        "options": options,  # stages, detail_level, etc.
    })
    return job.id

Worker dedicado: arachne-vlm-w1 — processo separado consumindo a fila, rodando o pipeline de 8 estágios (incluindo o VLM), salvando o resultado no cache e atualizando o status do job. Um worker, uma GPU, zero disputa com os workers de scraping.

3. Cache de visão: vlm_cache.db (SQLite + sha256)

A mesma imagem não deveria ser processada duas vezes. O cache é simples e brutal:

-- vlm_cache.db
CREATE TABLE vision_cache (
    sha256 TEXT PRIMARY KEY,           -- hash da imagem original
    result_json TEXT NOT NULL,         -- resultado completo do pipeline (JSON)
    model_version TEXT NOT NULL,       -- "qwen2.5vl:7b" — invalida se o modelo mudar
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    hits INTEGER DEFAULT 0
);

CREATE INDEX idx_vision_cache_model ON vision_cache(model_version);

Hit rate atual: ~34% (muitas imagens repetidas em scrapings de documentação técnica). Cada hit economiza 3-5s de GPU + fila.

# app/vision/cache.py
def get_cached_result(sha256: str, model_version: str) -> dict | None:
    row = db.execute(
        "SELECT result_json, hits FROM vision_cache WHERE sha256=? AND model_version=?",
        (sha256, model_version)
    ).fetchone()
    if row:
        db.execute("UPDATE vision_cache SET hits=hits+1 WHERE sha256=?", (sha256,))
        return json.loads(row[0])
    return None

def save_to_cache(sha256: str, model_version: str, result: dict):
    db.execute(
        "INSERT OR REPLACE INTO vision_cache (sha256, model_version, result_json) VALUES (?, ?, ?)",
        (sha256, model_version, json.dumps(result))
    )
    db.commit()

4. API: JWT obrigatório, polling simples

# app/api/vision.py
@router.post("/extract/image")
async def extract_image(
    file: UploadFile = File(...),
    options: VisionOptions = Depends(),
    user: User = Depends(get_current_user)  # JWT obrigatório
):
    image_data = await file.read()
    sha256 = hashlib.sha256(image_data).hexdigest()
    
    # Cache hit?
    cached = get_cached_result(sha256, MODEL_VERSION)
    if cached:
        return {"status": "completed", "result": cached, "cached": True}
    
    # Enfileira
    job_id = await enqueue_vlm_job(image_data, options.dict())
    return {"status": "queued", "job_id": job_id}

@router.get("/extract/image-jobs/{job_id}")
async def get_job_status(job_id: str, user: User = Depends(get_current_user)):
    job = await vlm_queue.get_job(job_id)
    if not job:
        raise HTTPException(404, "Job not found")
    
    if job.finished:
        result = job.returnvalue
        return {"status": "completed", "result": result}
    elif job.failed:
        return {"status": "failed", "error": job.failed_reason}
    else:
        return {"status": "processing", "progress": job.progress}

O cliente faz polling a cada 2s até status: "completed". Simples, funciona, sem WebSocket.

O que mudou na prática

Antes (julho) Agora (agosto)
VLM inline na requisição (bloqueante) Fila assíncrona + worker dedicado
gemma4:12b (~10-30s) qwen2.5vl:7b (~3-5s)
Sem cache Cache SQLite por sha256 + model_version
1 worker fazia tudo Workers de scraping + worker de visão separados
Sem auth na visão JWT obrigatório em todos os endpoints

Os detalhes que mordem

PYTHONPATH e PostgreSQL

O worker arachne-vlm-w1 roda com PYTHONPATH=/opt/arachne/api — o código da API vive separado do core. E PostgreSQL é obrigatório (não SQLite) porque a fila BullMQ usa Redis, mas metadados de job, usuários, billing e ApiUsageLog vivem no Postgres. Tentar rodar com SQLite quebra migrations e transações concorrentes.

# Worker .env
PYTHONPATH=/opt/arachne/api
DATABASE_URL=postgresql://arachne:***@localhost:5432/arachne
REDIS_URL=redis://localhost:6379/1
OLLAMA_HOST=http://localhost:11434
MODEL_VERSION=qwen2.5vl:7b

Invalidação de cache por versão do modelo

Se eu trocar qwen2.5vl:7b por qwen2.5vl:32b ou atualizar o Ollama, model_version muda e o cache se invalida sozinho (chave composta sha256 + model_version). Sem CACHE_BUST=1 manual.

Rate limiting na visão

Endpoints de visão têm limiter dedicado: 20 req/min por usuário (mais rígido que scraping). GPU é recurso escasso.

Métricas atuais

Métrica Valor
Modelo VLM qwen2.5vl:7b (Ollama WSL + CUDA)
Tempo médio VLM 3.2s (P50) / 5.8s (P95)
Hit rate do cache 34%
Média da fila 0.3 jobs (quase sempre vazia)
Worker de visão 1 (arachne-vlm-w1, GPU dedicada)
Tamanho do cache 1.2K entradas, 180 MB
Auth JWT obrigatório (Bearer token)

O que vem a seguir

  • Visão em lote — enviar várias imagens num único job (útil para docs com muitos screenshots)
  • Webhooks de visão — callback quando o job terminar (evita polling)
  • Roteamento multi-modelo — qwen2.5vl:7b para OCR/rápido, qwen2.5vl:32b para descrição detalhada (sob demanda)
  • Exportar cache — dump/import do vlm_cache.db para migrações
~/lifelog — bash
$cat about.txt
╔══════════════════════════════════════╗
║  Samuel Medeiros                    ║
║  Senior Software Engineer           ║
║  Stack: Python · TypeScript · Rust  ║
║  Projetos: Arachne, Dogwalk,        ║
║            Capivara, TatuEngine      ║
╚══════════════════════════════════════╝
      
$

O Arachne não só raspa — agora ele enxerga. E com fila, cache e worker dedicado, enxerga em escala.