RAG bom é resultado de indexação boa. Cada decisão — tamanho do chunk, modelo de embedding, índice sparse vs. denso — tem trade-offs concretos de recall, latência e custo. Aqui você aprende a navegar essas decisões.
🏗️ Pipeline canônico de indexação
Cada documento entra, é processado por uma pipeline determinística e sai como N chunks indexados. Reproducibilidade vem de fixar cada passo.
- •1. Parse: extrai texto (PDF→text, HTML→text), normaliza encoding.
- •2. Chunk: divide com strategy + tamanho + overlap declarados.
- •3. Embed: passa cada chunk pelo modelo de embedding.
- •4. Index: armazena vetor + metadata em vector store + BM25.
- •5. Hash: registra sha256 do corpus para reproducibilidade.
💡 Comece com 500 tokens + 50 overlap
Para a maioria dos corpora técnicos em PT-BR, chunks de ~500 tokens com 10% overlap dão recall razoável. Ajuste depois com base em eval real, não em achismo.
✂️ Chunking: dividir o corpus
Tamanho, overlap, fronteiras semânticas
Chunking é a primeira e mais consequente decisão. Chunk muito grande (>2000 tokens) dilui relevância — embedding fica genérico. Chunk muito pequeno (<100 tokens) perde contexto. Default robusto: 500 tokens com overlap de 10-20%, cortando em fronteira semântica (parágrafo, fim de sentença). Recursive splitter (LangChain) ou SemanticChunker (LlamaIndex) implementam isso bem.
def chunk_by_paragraph(text: str, target=500, overlap=50) -> list[str]:
paras = text.split('\n\n')
chunks, current = [], ''
for p in paras:
if len(current) + len(p) > target:
chunks.append(current)
current = current[-overlap:] + '\n\n' + p # overlap
else:
current += '\n\n' + p
chunks.append(current)
return chunks
📑 Resumo navegável
🧮 Embeddings densos: vetores semânticos
Modelos sentence-transformers
Embeddings densos mapeiam texto em vetor (768-3072 dimensões) onde proximidade vetorial ≈ similaridade semântica. Modelos canônicos: BGE-large (open), OpenAI text-embedding-3-large, Cohere embed-v3. Capturam paráfrase ('automóvel' ≈ 'carro') que BM25 sozinho perde.
from sentence_transformers import SentenceTransformer
model = SentenceTransformer('BAAI/bge-large-en-v1.5')
chunks = ['o carro é vermelho', 'o automóvel é vermelho', 'o gato dorme']
vectors = model.encode(chunks, normalize_embeddings=True)
# vectors[0] @ vectors[1] ~ 0.92 (similar)
# vectors[0] @ vectors[2] ~ 0.15 (não relacionado)
📑 Resumo navegável
🔤 BM25: o sparse clássico que ainda manda
TF-IDF refinado
BM25 é probabilístico, baseado em TF-IDF. Não usa ML, mas é excelente em match exato: números, IDs, nomes próprios, acrônimos — coisas que embeddings densos costumam errar. Em queries factuais ('quem é Liu et al. 2023?'), BM25 frequentemente bate dense retrieval sozinho.
from rank_bm25 import BM25Okapi
tokenized = [doc.lower().split() for doc in corpus]
bm25 = BM25Okapi(tokenized)
query = 'Liu 2023 lost in middle'.lower().split()
scores = bm25.get_scores(query)
top10 = sorted(zip(corpus, scores), key=lambda x: -x[1])[:10]
📑 Resumo navegável
🤝 Híbrido: BM25 + denso, fusão por RRF
O melhor dos dois
Híbrido roda BM25 + dense em paralelo, depois funde via Reciprocal Rank Fusion (RRF). RRF combina rankings sem precisar normalizar scores: score = Σ 1/(k + rank_i) com k=60. Em benchmark BEIR, híbrido bate cada método sozinho consistentemente.
def rrf(rankings: list[list[int]], k: int = 60) -> list[int]:
scores = {}
for ranking in rankings:
for rank, doc_id in enumerate(ranking):
scores[doc_id] = scores.get(doc_id, 0) + 1 / (k + rank)
return [d for d, _ in sorted(scores.items(), key=lambda x: -x[1])]
top_bm25 = bm25_search(query, top_k=50)
top_dense = vector_search(query, top_k=50)
top_hybrid = rrf([top_bm25, top_dense])[:20]
📑 Resumo navegável
🗂️ Vector stores: o que escolher
FAISS, pgvector, Qdrant, Pinecone
Vector stores fazem ANN (approximate nearest neighbor) com algoritmos como HNSW (hierarchical navigable small world) ou IVF. Em 100k documentos, cosine similarity exato é O(n) — inviável em produção. ANN entrega ~99% de recall em <5ms. Para começar: FAISS em memória ou pgvector em Postgres existente. Cloud: Qdrant, Pinecone, Weaviate.
| Store | Hosting | Quando usar |
|---|---|---|
| FAISS | biblioteca local | <1M docs, dev/research |
| pgvector | Postgres existente | Já tem Postgres, simplicidade |
| Qdrant | self-hosted ou cloud | Filtros complexos, hybrid search nativo |
| Pinecone | cloud only | Não quer operar infra |
📑 Resumo navegável
🏷️ Metadata e filtros: além do match semântico
Filtragem antes/depois do retrieval
Retrieval só por similaridade semântica é ingênuo. Em corpus heterogêneo, você quer filtros: 'eventos de 2024' não deve trazer chunks de 2019, mesmo que semanticamente similares. Vector stores modernos suportam metadata filters (pre-filter ou post-filter). Pre-filter (Qdrant, pgvector) é mais eficiente; post-filter (FAISS) força recuperar mais e descartar.
from qdrant_client import QdrantClient
from qdrant_client.models import Filter, FieldCondition, MatchValue
client.search(
collection_name='docs',
query_vector=q,
query_filter=Filter(must=[
FieldCondition(key='year', range={'gte': 2024}),
FieldCondition(key='lang', match=MatchValue(value='pt')),
]),
limit=10,
)
📑 Resumo navegável
📑 Resumo navegável dos tópicos
1 ✂️ Chunking: dividir o corpus — Tamanho, overlap, fronteiras semânticas
2 🧮 Embeddings densos: vetores semânticos — Modelos sentence-transformers
3 🔤 BM25: o sparse clássico que ainda manda — TF-IDF refinado
4 🤝 Híbrido: BM25 + denso, fusão por RRF — O melhor dos dois
5 🗂️ Vector stores: o que escolher — FAISS, pgvector, Qdrant, Pinecone
6 🏷️ Metadata e filtros: além do match semântico — Filtragem antes/depois do retrieval
✓ O que FAZER
- ✓Hash do corpus + versão da pipeline em metadata
- ✓Híbrido (BM25 + denso) com RRF como default
- ✓Filtros de metadata (data, idioma, fonte)
- ✓Chunks com fronteira semântica (parágrafo)
✗ O que NÃO fazer
- ✗Re-indexar e perder reproducibilidade
- ✗Confiar só em embeddings densas
- ✗Match puramente semântico em corpus heterogêneo
- ✗Cortar no meio de sentença ou fórmula
🚫 Quando NÃO usar
- •Quando contexto longo basta: corpus pequeno (<50k tokens) cabe direto na janela.
- •Quando a query exige raciocínio multi-hop: agente (T4) funciona melhor que RAG estático.
- •Quando o corpus muda a cada chamada: indexar não vale a pena; passe contexto direto.
💻 Exemplo de código
from fec_sdk import check_compat
check_compat("modulo-3-1")
# Pseudo-código provider-neutral
from fec_sdk.indexing import Pipeline, Chunker, EmbedderHTTP, BM25
pipeline = Pipeline([
Chunker(size=500, overlap=50, strategy="paragraph"),
EmbedderHTTP(model="bge-large-en-v1.5"),
])
# Indexa todos os docs
for doc in corpus:
chunks = pipeline.run(doc)
vector_store.add(chunks, metadata={"doc_id": doc.id, "date": doc.date})
# BM25 em paralelo
bm25 = BM25(chunks)
# Híbrido: query → top-50 BM25 ∪ top-50 denso → RRF → top-10
🏋️ Exercício hands-on
Indexe um corpus de 100 abstracts ArXiv (em fixtures/arxiv-cs-100/) com pipeline determinística. Eval de recall@10 deve atingir ≥0.7 no golden FEC-GS-RAG-v1. Em exercicios/modulo-3-1/.
📚 Bibliografia
- Robertson & Zaragoza (2009) — BM25 and Beyond
- Reimers & Gurevych (2019) — Sentence-BERT
- Cormack et al. (2009) — Reciprocal Rank Fusion (RRF)
- Thakur et al. (2021) — BEIR benchmark
🎯 Resumo do Módulo
- ✓Chunking: 500 tokens + overlap, fronteira semântica.
- ✓Embeddings densas capturam paráfrase; BM25 pega match exato.
- ✓Híbrido com RRF bate ambos consistentemente.
- ✓Vector stores usam ANN (HNSW, IVF) para escalar.
- ✓Metadata + filtros são tão importantes quanto similaridade.
Próximo Módulo:
Recuperação e reranking