
O Arachne ganhou olhos — VLM, fila de jobs e cache de visão
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.dbpara migrações
O Arachne não só raspa — agora ele enxerga. E com fila, cache e worker dedicado, enxerga em escala.