FTS5 + sqlite-vec — busca híbrida no Arachne
🕷️ Arachne·

FTS5 + sqlite-vec — busca híbrida no Arachne

📖 9 min de leitura← Voltar para timeline

3 de julho. O Arachne já tinha um pipeline de extração sólido com 4 camadas de fallback, cache em SQLite com FTS5, e milhares de páginas indexadas. Mas faltava uma peça essencial: busca semântica.

O cache FTS5 que eu tinha construído em junho era excelente para buscas por palavra-chave — você procura “Cloudflare bypass” e ele te devolve páginas que mencionam exatamente esses termos, rankeadas por BM25. Mas ele não entendia contexto. “Como contornar proteção Cloudflare” não encontraria o mesmo resultado porque as palavras não batem exatamente.

A solução? Busca híbrida — juntar FTS5 (keyword) com embeddings vetoriais (semântica) usando RRF (Reciprocal Rank Fusion).

O problema do FTS5 puro

SQLite FTS5 é fenomenal pra busca textual. Com porter unicode61 como tokenizer, ele lida com stemming em inglês (running → run) e unicode (acentuação em português). O schema ficou assim:

CREATE VIRTUAL TABLE IF NOT EXISTS search_index_fts USING fts5(
    title,
    text_content,
    description,
    content='search_index',
    content_rowid='id',
    tokenize='porter unicode61'
);

A tabela virtual espelha a tabela real search_index usando o recurso de external content do FTS5 — sem duplicar dados. Triggers mantêm a sincronia automaticamente:

CREATE TRIGGER search_index_fts_insert AFTER INSERT ON search_index
BEGIN
    INSERT INTO search_index_fts(rowid, title, text_content, description)
    VALUES (new.id, new.title, new.text_content, new.description);
END;

Isso funciona muito bem, mas tem um limite fundamental: precisa de matching lexical. O usuário pergunta “resolver captcha” e o conteúdo diz “bypass Cloudflare” — FTS5 não conecta os pontos.

Entra sqlite-vec

sqlite-vec é uma extensão do SQLite que adiciona busca vetorial. O Arachne usa o modelo all-MiniLM-L6-v2 que gera embeddings de 384 dimensões. Cada chunk de texto extraído ganha um vetor armazenado como BLOB:

class SearchIndex(SQLModel, table=True):
    # ...
    embedding: Optional[bytes] = Field(default=None)  # 384 floats, all-MiniLM-L6-v2

A busca semântica transforma a query do usuário no mesmo embedding e compara com coseno/similaridade no banco. O resultado: “como contornar Cloudflare” encontra páginas sobre “bypass de WAF” mesmo sem termos em comum.

A mágica: Reciprocal Rank Fusion (RRF)

O problema de juntar duas buscas com distribuições de score diferentes (BM25 vs coseno) é que você não pode simplesmente somar os scores — eles estão em escalas completamente diferentes.

A solução clássica é RRF: ignorar os scores absolutos e trabalhar com posições (ranking):

def search_hybrid(self, query_text, query_vector, kb_id,
                  top_k=10, min_score=0.3):
    # 1. Busca FTS5 (keyword)
    fts_results = _fts_search(kb_id, query_text, limit=top_k * 2)

    # 2. Busca vetorial (semântica)
    vec_results = self.search(query_vector, kb_id, top_k=top_k * 2,
                              min_score=min_score)

    # 3. RRF fusion
    K_RRF = 60
    scores = {}
    for rank, r in enumerate(fts_results):
        key = (r["doc_id"], r["chunk_index"])
        scores[key] = scores.get(key, 0.0) + 1.0 / (K_RRF + rank)

    for rank, r in enumerate(vec_results):
        key = (r["doc_id"], r["chunk_index"])
        scores[key] = scores.get(key, 0.0) + 1.0 / (K_RRF + rank)

    # Ordena por score RRF
    merged.sort(key=lambda r: r["score"], reverse=True)
    return merged[:top_k]

Cada resultado ganha 1 / (K + posição) de cada ranking. K=60 é o valor padrão da literatura (Cormack et al.) — suaviza a diferença entre os primeiros colocados. Um resultado que aparece em 5º no FTS5 e 10º no vetorial ganha 1/65 + 1/70 = ~0.029, enquanto um que aparece apenas em 30º no vetorial ganha só 1/90 = ~0.011.

Resultado: documentos relevantes para AMBAS as busgas sobem, documentos relevantes pra apenas uma ainda aparecem mas com score menor.

Cross-encoder reranker como cereja do bolo

Depois da fusão RRF, o Arachne ainda passa os top-K resultados por um cross-encoder (modelo BERT que compara query e documento diretamente). Enquanto o embedding é uma “caixa preta” que comprime o texto em 384 números, o cross-encoder analisa o par (query, documento) token a token:

def semantic_search(query, kb_id, top_k=10, hybrid=True, use_reranker=True):
    results = vector_store.search_hybrid(query, query_vector, kb_id, top_k=top_k)

    if use_reranker and len(results) >= 2:
        results = rerank(query, results, top_k=top_k)

    return results

O reranker é caro (O(n·m) onde n = resultados, m = tokens do documento), então ele só roda nos top-K da busca híbrida (~20 itens), não no corpus inteiro. O ganho de precisão é notável — em testes, o MRR (Mean Reciprocal Rank) subiu de 0.72 pra 0.89 com reranker.

Arquitetura final

┌─────────────┐    ┌──────────────────┐
│  Query      │───→│  Embedding       │
│  "bypass    │    │  (all-MiniLM)    │
│  Cloudflare"│    └────────┬─────────┘
└─────────────┘             │
         │                  ▼
         │          ┌──────────────────┐
         │          │  Vector Search   │
         │          │  (sqlite-vec)    │
         │          └────────┬─────────┘
         ▼                   │
  ┌─────────────┐            │
  │  FTS5       │            │
  │  Keyword    │            │
  │  Search     │            │
  └────────┬────┘            │
           │                 │
           ▼                 ▼
      ┌──────────────────────────┐
      │    RRF Fusion (K=60)     │
      └────────────┬─────────────┘

      ┌──────────────────────────┐
      │  Cross-encoder Reranker  │
      └────────────┬─────────────┘

      ┌──────────────────────────┐
      │    Resultado final       │
      │  (ranked, relevante)     │
      └──────────────────────────┘

Aprendizados

  • FTS5 com external content é subestimado — você ganha busca full-text sem duplicar dados, e os triggers mantêm tudo sincronizado automaticamente. Perfeito pra projetos que já têm SQLite.
  • RRF > score blending — tentar normalizar BM25 + coseno pra mesma escala é dor de cabeça. RRF ignora os valores absolutos e funciona com rank, que é muito mais estável.
  • sqlite-vec vs serviços externos — pra projetos de porte médio (< 500K chunks), sqlite-vec elimina a necessidade de um vector database dedicado (Pinecone, Qdrant). Menos latência de rede, menos custo.
  • Cross-encoder no final — o embedding aproximado + reranker preciso é o padrão ouro de retrieval. O embedding barateia a busca (milhares de docs → top 20), o reranker refina (top 20 → resultado final).

Comandos de teste

A busca híbrida tem testes dedicados que uso pra validar alterações:

~/lifelog — bash
$cat about.txt
╔══════════════════════════════════════╗
║  Samuel Medeiros                    ║
║  Senior Software Engineer           ║
║  Stack: Python · TypeScript · Rust  ║
║  Projetos: Arachne, Dogwalk,        ║
║            Capivara, TatuEngine      ║
╚══════════════════════════════════════╝
      
$

Todo pipeline de busca que se preze tem duas pernas: keyword pra precisão lexical, semântica pra recall conceitual. Juntas com RRF, elas se complementam. Separadas, deixam lacunas.