Retrieval ingênuo recupera 50 docs e despeja na janela. RAG bom recupera 100, rerankeia para 5, e mostra citação. Aqui você aprende a fechar o ciclo: top-k, reranker, contextual retrieval, e como forçar o modelo a citar a fonte.
🎯 Pipeline de retrieval em 5 passos
Cada passo tem decisão clara e métrica de saúde. Sem instrumentação, você não sabe onde o pipeline degrada.
- •1. Query rewrite (opcional): expansão / HyDE.
- •2. Hybrid retrieval: BM25 + denso → top-50 via RRF.
- •3. Rerank: cross-encoder → top-5.
- •4. Format: monta prompt com IDs visíveis.
- •5. Generate + cite: modelo responde com citação obrigatória.
📊 Anthropic Contextual Retrieval (2024)
- -49% em failure rate (recall@20) com contextual retrieval + reranker.
- -35% só com contextual retrieval (sem reranker).
- Custo: uma chamada extra ao LLM por chunk no index time (mitigado por prompt caching).
🎯 Top-k retrieval: quanto recuperar
Trade-off recall vs. ruído
k é o tamanho do top recuperado que vai pra geração. Trade-off: k baixo (3-5) tem alta precisão mas perde recall; k alto (20-50) capta tudo mas enche a janela e dispara lost-in-middle. Default: k=5 para gerar, k=50 para reranker. Otimize empiricamente no seu golden set.
| k para gerar | Recall@k | Groundedness | Custo input |
|---|---|---|---|
| 3 | 0.71 | 0.93 | 1× |
| 5 | 0.84 | 0.91 | 1.6× |
| 10 | 0.92 | 0.85 | 3× |
| 20 | 0.96 | 0.78 | 6× |
| 50 | 0.99 | 0.62 | 15× |
📑 Resumo navegável
🏆 Reranker cross-encoder: precisão alta
BGE-reranker, Cohere Rerank
Retriever inicial (dual encoder) é rápido mas grosseiro. Reranker cross-encoder recebe (query, chunk) JUNTOS e produz score com mais precisão. Padrão: top-50 do retriever → reranker → top-5 final. Custo: 50-200ms a mais. Ganho: groundedness +5-15 pp consistente.
from sentence_transformers import CrossEncoder
rer = CrossEncoder('BAAI/bge-reranker-large')
candidatos = retriever.search(query, top_k=50)
pairs = [(query, c.text) for c in candidatos]
scores = rer.predict(pairs)
top5 = sorted(zip(candidatos, scores), key=lambda x: -x[1])[:5]
📑 Resumo navegável
🪄 Contextual retrieval (Anthropic 2024)
Embed com contexto do documento
Contextual retrieval (Anthropic, 2024): antes de embedar cada chunk, o LLM gera uma frase curta de contexto sobre o documento e prefixa no chunk. Resultado: chunks isolados deixam de perder referência. Anthropic reporta -49% em failure rate (recall@20) com contextual retrieval + reranker. Custo: 1 chamada LLM por chunk no index time (mitigado por prompt caching).
for doc in corpus:
chunks = chunk_by_paragraph(doc.text)
for chunk in chunks:
contexto = llm_describe(
f'<doc>{doc.text}</doc>\n<chunk>{chunk}</chunk>\n'
'Em 1 frase: como este chunk se situa no doc?'
)
chunk_aumentado = f'{contexto}\n\n{chunk}'
index.add(embed(chunk_aumentado), metadata={...})
📑 Resumo navegável
📌 Citações obrigatórias na geração
Rastreabilidade da resposta
Sem citação explícita, você não sabe se o modelo grounded a resposta ou alucinou. Padrão: incluir IDs visíveis no contexto (<chunk id=42>...</chunk>) e instruir 'cite o id após cada afirmação'. Eval de groundedness depende disso para ser objetivo.
system = '''Responda APENAS com base no contexto fornecido.
Cite o id de cada chunk usado, no formato [chunk:42].
Se a resposta não está no contexto, diga: 'Não tenho informação suficiente'.
Não invente fatos.'''
ctx = '\n'.join(f'<chunk id={c.id}>{c.text}</chunk>' for c in top5)
📑 Resumo navegável
🚫 Saber dizer 'não sei'
Quando não há contexto suficiente
Modelos preferem responder algo a admitir ignorância — viés conhecido. Combate: instrução explícita 'se a resposta não está no contexto, diga não sei' + few-shot mostrando casos de abstenção. Sem isso, alucinação em RAG é a regra, não a exceção.
<exemplos>
<ex>
<ctx><chunk id=1>O céu é azul.</chunk></ctx>
<q>Qual a cor do mar?</q>
<a>Não tenho informação suficiente no contexto.</a>
</ex>
<ex>
<ctx><chunk id=2>O mar é azul.</chunk></ctx>
<q>Qual a cor do mar?</q>
<a>O mar é azul [chunk:2].</a>
</ex>
</exemplos>
📑 Resumo navegável
♻️ Re-rankeio adaptativo: query rewriting
Query expansion e HyDE
Query do usuário é tipicamente curta e ambígua. Query rewriting expande sinônimos ou decompõe em sub-perguntas; HyDE (Gao et al. 2022) gera resposta hipotética com LLM e usa essa resposta como query — surpreendentemente eficaz para queries factuais. Custo: 1 chamada LLM extra; ganho: +5-10% em recall.
def hyde_search(query: str, retriever) -> list:
# 1. gera resposta hipotética (sem contexto)
hipotetica = llm.generate(
f'Responda em 2 frases (mesmo que invente): {query}'
)
# 2. usa a resposta como query (semanticamente mais rica)
return retriever.search(hipotetica, top_k=10)
📑 Resumo navegável
📑 Resumo navegável dos tópicos
1 🎯 Top-k retrieval: quanto recuperar — Trade-off recall vs. ruído
2 🏆 Reranker cross-encoder: precisão alta — BGE-reranker, Cohere Rerank
3 🪄 Contextual retrieval (Anthropic 2024) — Embed com contexto do documento
4 📌 Citações obrigatórias na geração — Rastreabilidade da resposta
5 🚫 Saber dizer 'não sei' — Quando não há contexto suficiente
6 ♻️ Re-rankeio adaptativo: query rewriting — Query expansion e HyDE
✓ O que FAZER
- ✓Reranker cross-encoder após top-50 inicial
- ✓Citação obrigatória + ID visível no prompt
- ✓Instrução 'diga não sei' + few-shot de abstenção
- ✓Eval de groundedness no harness
✗ O que NÃO fazer
- ✗Passar top-50 direto à geração
- ✗Esperar que o modelo 'use' o contexto sem citar
- ✗Sempre forçar uma resposta
- ✗Confiar que 'a resposta parece boa'
🚫 Quando NÃO usar
- •Query muito específica e bem-formada: query rewrite pode atrapalhar.
- •Latência crítica: cross-encoder rerank adiciona 100-500ms.
- •Top-k já é alto e modelo aguenta: rerank dispensável se atenção der conta.
💻 Exemplo de código
from fec_sdk import Message, MessageRole
from fec_sdk.adapters import get_adapter
from fec_sdk.retrieval import HybridRetriever, CrossEncoderReranker # pseudo
retriever = HybridRetriever(vector_store, bm25)
reranker = CrossEncoderReranker(model="bge-reranker-large")
def responder(query: str) -> dict:
candidatos = retriever.search(query, k=50) # top-50
top5 = reranker.rerank(query, candidatos, k=5) # top-5
contexto = "\n\n".join(
f"<chunk id={c.id}>{c.text}</chunk>" for c in top5
)
system = (
"Responda APENAS com base no contexto. "
"Cite o id de cada chunk usado, ex.: [chunk:42]. "
"Se a resposta não está no contexto, diga 'não sei'."
)
client = get_adapter("mock")
resp = client.chat([
Message(role=MessageRole.SYSTEM, content=system),
Message(role=MessageRole.USER, content=f"{contexto}\n\n{query}"),
])
return {"answer": resp.content, "citations": [c.id for c in top5]}
🏋️ Exercício hands-on
Use o índice de 3.1 + reranker para responder 30 perguntas do golden FEC-GS-RAG-v1 com groundedness ≥0.85. Implementação em exercicios/modulo-3-2/.
📚 Bibliografia
- Anthropic (2024) — Introducing Contextual Retrieval
- Gao et al. (2022) — Precise Zero-Shot Dense Retrieval (HyDE)
- Khattab & Zaharia (2020) — ColBERT
- Liu et al. (2023) — Lost in the Middle
🎯 Resumo do Módulo
- ✓Top-k: 3-10 é o range; meça empiricamente.
- ✓Reranker cross-encoder após top-50 dá ganho consistente.
- ✓Contextual retrieval (Anthropic 2024): -35-49% em failure rate.
- ✓Citação obrigatória é pré-requisito de eval de groundedness.
- ✓Saber dizer 'não sei' é parte do design, não exceção.
Próximo Módulo:
RAG agêntico e self-RAG (beta)