10. Recuperação Mais Inteligente: Filtros, Limiares e MMR
No Capítulo 9, construímos um sistema RAG simples. Era um pipeline que dividia documentos em pedaços (chunks), os incorporava no ChromaDB e, quando uma pergunta do usuário chegava, recuperava os chunks relevantes e os incluía no prompt. Isso permitia que o LLM respondesse a perguntas sobre informações para as quais nunca foi treinado, como documentos internos da empresa e manuais de produtos. Tudo parecia funcionar perfeitamente.
Mas tente fazer uma variedade maior de perguntas e as fraquezas surgem rapidamente. Você pergunta sobre a política de reembolso para consumidores e o conteúdo da loja de funcionários acaba se misturando, ou você pergunta sobre algo que não está em nenhum documento e o LLM inventa uma resposta plausível, ou você aumenta o número de resultados da busca e a qualidade da resposta na verdade cai.
Neste capítulo, abordaremos esses três problemas um por um. Usaremos filtragem de metadados para restringir quais documentos são pesquisados, limiares de pontuação de similaridade para excluir resultados não relacionados à pergunta, e ajuste de K e MMR para melhorar a quantidade e a diversidade dos chunks fornecidos ao LLM. Nenhuma ferramenta nova é necessária. Estamos refinando a configuração e o uso de similarity_search() e do vector store Chroma que você já conhece.
10.1) O Que Há de Errado com Nosso RAG?
No Capítulo 9, colocamos apenas dois documentos no vector store — uma política de reembolso e uma política de envio — e cada documento abordava um tópico distinto. Também testamos apenas perguntas cujas respostas estavam claramente presentes ou ausentes nos documentos. Desta vez, criaremos um cenário mais realista. Adicionaremos um guia da loja de funcionários ao vector store. Este documento também contém conteúdo relacionado a reembolsos, mas é destinado a funcionários, não a consumidores em geral. Em seguida, faremos várias perguntas e veremos quais problemas surgem.
Preparação de Dados: Adicionando o Guia da Loja de Funcionários
Aqui estão os dois documentos do Capítulo 9 para referência.
data/docs/refund_policy.md:
# Refund Policy
**Effective Date**: January 1, 2026
## Standard Returns
All physical products may be returned within 30 days of purchase for a full refund.
The original receipt or order confirmation email is required. Items must be in their
original packaging and unused condition.
After 30 days, returns are accepted for store credit only. Store credit does not expire.
## Digital Products
Digital products (software licenses, e-books, online courses) are non-refundable
once the download or access link has been activated. If you experience technical
issues preventing access, contact support within 7 days for a replacement or refund.
## Defective Items
Defective items may be returned at any time for a full refund or replacement.
Please include a description of the defect. Shipping costs for defective returns
are covered by the company.
## Subscription Services
Monthly subscriptions may be cancelled at any time. Refunds are prorated based on
the remaining days in the billing cycle. Annual subscriptions may be refunded in full
within the first 14 days. After 14 days, no refund is available but access continues until the end of the billing period.data/docs/shipping_info.md:
# Shipping Information
## Domestic Shipping
Standard shipping (5-7 business days): Free on orders over $50, otherwise $5.99.
Express shipping (2-3 business days): $12.99.
Overnight shipping (next business day): $24.99.
## International Shipping
International orders are shipped via tracked airmail. Delivery times vary by
destination, typically 10-21 business days. International shipping costs are
calculated at checkout based on weight and destination.
Customs duties and import taxes are the responsibility of the buyer and are not included in the shipping cost.
## Order Tracking
All orders include a tracking number sent via email within 24 hours of shipment.
Track your order through the tracking link in your email or through the carrier's website.
## Lost or Damaged Packages
If your package is lost or arrives damaged, contact support within 48 hours.
We will ship a replacement at no additional cost. For damaged items, please
provide photos of the damage and packaging.Adicione o guia da loja de funcionários aqui.
Crie data/docs/employee_store.md:
# Employee Store Guide
## Eligibility and Benefits
Employees can purchase company products at a 30% discount through the internal employee store.
The monthly purchase limit is $500, and payment can be made via payroll deduction or benefit points.
## Ordering and Shipping
Employee store orders are placed through the internal portal, and delivery is only available to the company address.
Orders are delivered within 3-5 business days, and shipping is free.
## Refund Policy
Refunds are available within 7 days of purchase for unopened items only.
Cash refunds are not available; refunds are credited as benefit points.
After opening, only exchanges are allowed, limited to one exchange per identical product.
## Contact
For employee store inquiries, please contact HR at hr@acme.com.O diretório data/docs/ agora contém três arquivos: refund_policy.md, shipping_info.md, employee_store.md. Execute novamente o script de ingestão do Capítulo 9 para reconstruir o vector store.
# ingest.py — mesmo pipeline de ingestão do Capítulo 9
from langchain_community.document_loaders import DirectoryLoader, TextLoader
from langchain_text_splitters import RecursiveCharacterTextSplitter
from langchain_openai import OpenAIEmbeddings
from langchain_chroma import Chroma
loader = DirectoryLoader(
"data/docs/", glob="**/*.md",
loader_cls=TextLoader, loader_kwargs={"encoding": "utf-8"},
)
documents = loader.load()
print(f"Loaded {len(documents)} documents")
text_splitter = RecursiveCharacterTextSplitter(
chunk_size=200,
chunk_overlap=80,
separators=["\n## ", "\n\n", "\n", " ", ""],
)
chunks = text_splitter.split_documents(documents)
print(f"Created {len(chunks)} chunks")
embedding_model = OpenAIEmbeddings(model="text-embedding-3-small")
vector_store = Chroma.from_documents(
documents=chunks,
embedding=embedding_model,
persist_directory="data/chroma_db",
collection_name="company_docs",
)
print(f"Stored {len(chunks)} chunks in ChromaDB")Saída:
Loaded 3 documents
Created 12 chunks
Stored 12 chunks in ChromaDBProblema 1: Documentos Irrelevantes Misturados aos Resultados da Busca
Vamos pesquisar as condições de reembolso.
from langchain_openai import OpenAIEmbeddings
from langchain_chroma import Chroma
embedding_model = OpenAIEmbeddings(model="text-embedding-3-small")
vector_store = Chroma(
persist_directory="data/chroma_db",
collection_name="company_docs",
embedding_function=embedding_model,
)
results = vector_store.similarity_search("What are the refund conditions?", k=3)
for i, doc in enumerate(results):
source = doc.metadata["source"]
print(f"Result {i+1} [{source}] {doc.page_content[:80]}...")Saída:
Result 1 [data/docs/refund_policy.md] ## Standard Returns
All physical products may be returned within 30 days of pur...
Result 2 [data/docs/employee_store.md] ## Refund Policy
Refunds are available within 7 days of purchase for unopened i...
Result 3 [data/docs/refund_policy.md] ## Subscription Services
Monthly subscriptions may be cancelled at any time. Re...Observe o Resultado 2. Um cliente perguntou sobre as condições de reembolso, e a política de reembolso da loja de funcionários está incluída nos resultados. A política de reembolso para consumidores permite reembolsos integrais em até 30 dias, mas a loja de funcionários só permite reembolsos em até 7 dias para itens não abertos, com os reembolsos creditados como pontos de benefício. Se ambas as políticas forem passadas ao LLM juntas, o cliente pode receber condições de reembolso exclusivas para funcionários.
Problema 2: Resultados Retornados Mesmo Quando Não Existe Conteúdo Relevante
Agora vamos perguntar sobre algo que não existe em lugar nenhum dos nossos documentos. Usaremos similarity_search_with_score(), que aprendemos no Capítulo 9, para também ver os valores de distância. No ChromaDB, valores de distância mais baixos significam maior similaridade.
results = vector_store.similarity_search_with_score(
"What is the hiring process at this company?", k=3
)
for doc, score in results:
source = doc.metadata["source"]
print(f"[dist={score:.4f}] [{source}] {doc.page_content[:80]}...")Saída:
[dist=1.4648] [data/docs/employee_store.md] ## Refund Policy
Refunds are available within 7 days of purchase for unopened i...
[dist=1.4699] [data/docs/employee_store.md] ## Eligibility and Benefits
Employees can purchase company products at a 30% di...
[dist=1.4743] [data/docs/refund_policy.md] ## Subscription Services
Monthly subscriptions may be cancelled at any time. Re...Não há nenhuma informação sobre o processo de contratação em lugar nenhum. Os valores de distância estão todos acima de 1.4, mostrando similaridade muito baixa, e mesmo assim similarity_search_with_score() ainda retornou três chunks. Vamos ver qual resposta a cadeia RAG produz quando esses chunks são passados a ela.
from rag_chain import build_rag_chain # cadeia RAG do Capítulo 9
chain = build_rag_chain()
answer = chain.invoke("What is the hiring process at this company?")
print(answer)Saída:
I don't have enough information to answer that question.O LLM respondeu que não tinha informações suficientes para responder. Isso ocorre porque incluímos a instrução "diga que você não tem informações suficientes" no prompt de sistema de build_rag_chain(). No entanto, o LLM nem sempre consegue fazer esse julgamento. Se os chunks recuperados contiverem frases que pareçam relacionadas à pergunta, o LLM pode gerar uma resposta incorreta baseada nesse conteúdo.
Problema 3: Aumentar os Resultados da Busca Sempre Ajuda?
Você pode pensar "não seria melhor ter mais contexto?". Aumentar k de 3 para 10 de fato eleva a chance de que os chunks necessários sejam incluídos. Mas, ao mesmo tempo, mais chunks irrelevantes também entram. Como o LLM recebe todos esses chunks como contexto e gera respostas a partir deles, informações desnecessárias ou incorretas podem acabar na resposta. Mais contexto não significa necessariamente respostas melhores.
Além disso, todos os chunks recuperados são passados ao LLM como tokens. À medida que k cresce, os custos das chamadas de API aumentam e os tempos de resposta ficam mais lentos.
Identificamos três problemas. Agora vamos resolvê-los um por um.
10.2) Filtragem de Metadados: Reduzindo o Espaço de Busca
Quando pesquisamos as condições de reembolso no Problema 1, tanto a política de reembolso para clientes quanto a política de reembolso da loja de funcionários apareceram juntas. Isso aconteceu porque não dissemos a similarity_search() em quais documentos pesquisar.
A filtragem de metadados anexa atributos como categoria, fonte e ano de publicação a cada chunk e, em seguida, filtra os chunks com base nesses atributos antes de executar a busca por similaridade. Apenas os chunks que correspondem às condições passam pelo cálculo de similaridade. Ela desempenha um papel semelhante ao da cláusula WHERE do SQL.
10.2.1) Conteúdo do Documento vs. Metadados do Documento
O objeto Document que aprendemos no Capítulo 9 contém duas coisas:
page_content: O texto em si. Ele é incorporado em um vetor e é sobre o que a busca por similaridade opera.metadata: Um dicionário que contém atributos como fonte e categoria. Esses valores não são incorporados.
A busca por similaridade opera sobre page_content, enquanto a filtragem de metadados opera sobre as informações de metadata.
10.2.2) Reconstruindo o Vector Store: Adicionando Metadados
Vamos adicionar um atributo category para filtragem e reconstruir o vector store. Só precisamos adicionar o código de atribuição de metadados ao ingest.py da seção 10.1.
# ingest_with_metadata.py
from langchain_community.document_loaders import DirectoryLoader, TextLoader
from langchain_text_splitters import RecursiveCharacterTextSplitter
from langchain_openai import OpenAIEmbeddings
from langchain_chroma import Chroma
# Passo 1: Carrega os documentos (igual à seção 10.1)
loader = DirectoryLoader(
"data/docs/", glob="**/*.md",
loader_cls=TextLoader, loader_kwargs={"encoding": "utf-8"},
)
documents = loader.load()
print(f"Loaded {len(documents)} documents")
# Passo 2: [NOVO] Atribui metadados de categoria com base no nome do arquivo
CATEGORY_MAP = {
"refund_policy.md": "customer",
"shipping_info.md": "customer",
"employee_store.md": "employee",
}
for doc in documents:
filename = doc.metadata["source"].split("/")[-1]
doc.metadata["category"] = CATEGORY_MAP.get(filename, "unknown")
# Passo 3: Divide em chunks (igual à seção 10.1)
text_splitter = RecursiveCharacterTextSplitter(
chunk_size=200,
chunk_overlap=80,
separators=["\n## ", "\n\n", "\n", " ", ""],
)
chunks = text_splitter.split_documents(documents)
print(f"Created {len(chunks)} chunks")
# Passo 4: Constrói o vector store (igual à seção 10.1)
embedding_model = OpenAIEmbeddings(model="text-embedding-3-small")
vector_store = Chroma.from_documents(
documents=chunks,
embedding=embedding_model,
persist_directory="data/chroma_db",
collection_name="company_docs",
)
print(f"Stored {len(chunks)} chunks in ChromaDB")Saída:
Loaded 3 documents
Created 12 chunks
Stored 12 chunks in ChromaDBA única diferença em relação ao ingest.py original é a adição de metadados category a cada documento.
10.2.3) Aplicando Filtros à Busca
Agora podemos usar o parâmetro filter de similarity_search() para restringir quais documentos são pesquisados. Pesquisaremos com a mesma consulta do Problema 1, mas adicionaremos um filtro para pesquisar apenas documentos voltados para clientes.
results = vector_store.similarity_search(
"What are the refund conditions?",
k=3,
filter={"category": "customer"},
)
for i, doc in enumerate(results):
source = doc.metadata["source"]
category = doc.metadata["category"]
print(f"Result {i+1} [{category}] [{source}] {doc.page_content[:80]}...")Saída:
Result 1 [customer] [data/docs/refund_policy.md] ## Standard Returns
All physical products may be returned within 30 days of pur...
Result 2 [customer] [data/docs/refund_policy.md] ## Subscription Services
Monthly subscriptions may be cancelled at any time. Re...
Result 3 [customer] [data/docs/refund_policy.md] ## Digital Products
Digital products (software licenses, e-books, online course...A política de reembolso da loja de funcionários que se misturou durante o Problema 1 não está mais incluída. Isso ocorre porque especificamos que apenas os chunks com category igual a "customer" deveriam ser pesquisados.
Combinando Múltiplas Condições
O exemplo acima usou uma única condição (category igual a "customer"). Quando várias condições precisam ser aplicadas simultaneamente, você pode combiná-las com operadores lógicos como $and e $or.
# $and: apenas chunks que satisfazem TODAS as condições
filter={
"$and": [
{"category": "customer"},
{"source": "data/docs/refund_policy.md"},
]
}
# $or: chunks que satisfazem QUALQUER condição
filter={
"$or": [
{"source": "data/docs/refund_policy.md"},
{"source": "data/docs/shipping_info.md"},
]
}Operadores adicionais incluem $ne (diferente), $gt (maior que) e $lt (menor que). Consulte a documentação do ChromaDB para a lista completa de operadores.
10.3) Limiares de Similaridade: Excluindo Resultados de Baixa Relevância
No Problema 2, quando perguntamos sobre o processo de contratação, chunks com valores de distância acima de 1.4 foram retornados. No ChromaDB, valores de distância tão altos indicam quase nenhuma relevância. Ainda assim, eles foram recuperados. Isso ocorre porque similarity_search() sempre retorna k resultados.
Esse problema pode ser resolvido definindo um limiar de distância. Resultados mais distantes que o limiar não são incluídos no contexto passado ao LLM.
Então, que limiar devemos definir? Vamos primeiro comparar os valores de distância entre perguntas que têm conteúdo relevante nos documentos e perguntas que não têm.
10.3.1) Comparando Distâncias: Perguntas Com e Sem Conteúdo Relevante
queries = [
"Can I cancel a subscription service?", # existe conteúdo relevante
"What is the hiring process at this company?", # sem conteúdo relevante
]
for query in queries:
print(f"\nQuery: {query}")
results = vector_store.similarity_search_with_score(query, k=1)
for doc, score in results:
print(f" [dist={score:.4f}] {doc.page_content[:80]}...")Saída:
Query: Can I cancel a subscription service?
[dist=0.7511] ## Subscription Services
Monthly subscriptions may be cancelled at any time. Re...
Query: What is the hiring process at this company?
[dist=1.4648] ## Refund Policy
Refunds are available within 7 days of purchase for unopened i...Perguntas com conteúdo relevante têm valores de distância em torno de 0.75, enquanto perguntas sem conteúdo relevante têm valores acima de 1.4. Teste várias perguntas como essas e escolha um limiar que separe claramente os dois casos. O limiar correto pode variar dependendo do modelo de embedding, da natureza dos seus documentos e do tamanho do chunk, então é melhor determiná-lo testando diretamente com seus próprios dados.
10.3.2) Filtrando os Resultados da Busca por Limiar de Distância
Uma vez definido um limiar, vamos construir uma função que filtra os resultados que excedem o limiar. Apenas os chunks que sobrevivem ao filtro são incluídos no contexto passado ao LLM. Se nenhum chunk permanecer abaixo do limiar, pulamos a chamada ao LLM inteiramente e respondemos com "Não tenho informações suficientes para responder a essa pergunta".
from langchain_openai import ChatOpenAI
llm = ChatOpenAI(model="gpt-5-mini")
def retrieve_or_abstain(query: str, max_distance: float = 1.0, k: int = 3):
"""Retorna apenas os chunks abaixo do limiar de distância. Retorna None se nenhum passar."""
scored = vector_store.similarity_search_with_score(query, k=k)
good = [doc for doc, dist in scored if dist <= max_distance]
return good or None
def safe_answer(query: str) -> str:
docs = retrieve_or_abstain(query)
if docs is None:
return "I don't have enough information to answer that question."
context = "\n\n".join(d.page_content for d in docs)
prompt = (
"Answer the question using ONLY the context below.\n\n"
f"Context:\n{context}\n\nQuestion: {query}"
)
return llm.invoke(prompt).contentVamos testar com as mesmas perguntas de antes.
# Pergunta com conteúdo relevante
print(safe_answer("Can I cancel a subscription service?"))
print("---")
# Pergunta sem conteúdo relevante
print(safe_answer("What is the hiring process at this company?"))Saída:
Yes. Monthly subscriptions may be cancelled at any time, and refunds are prorated
based on the remaining days in the billing cycle. Annual subscriptions may be refunded
in full within the first 14 days...
---
I don't have enough information to answer that question.10.4) K e MMR: Controlando a Quantidade e a Diversidade dos Resultados da Busca
Nesta seção, compararemos diretamente como os resultados da busca mudam com diferentes valores de k e aprenderemos sobre a busca MMR, que melhora a diversidade dos resultados.
10.4.1) O Que Acontece Quando Você Aumenta K?
No Problema 3, dissemos que aumentar k também traz mais chunks irrelevantes. Vamos verificar isso. Pesquisaremos com k=10 e examinaremos os valores de distância de cada chunk.
results = vector_store.similarity_search_with_score(
"What are the refund conditions?",
k=10,
filter={"category": "customer"},
)
for i, (doc, dist) in enumerate(results, 1):
source = doc.metadata["source"].split("/")[-1]
print(f"{i:>2}. [dist={dist:.4f}] [{source}] {doc.page_content[:80]}...")Saída:
1. [dist=0.7798] [refund_policy.md] ## Standard Returns
**Effective Date**: January 1, 2026
All physical products ma...
2. [dist=0.8203] [refund_policy.md] ## Defective Items
Defective items may be returned at any time for a full refun...
3. [dist=0.9353] [refund_policy.md] ## Subscription Services
Monthly subscriptions may be cancelled at any time. Re...
4. [dist=1.4249] [shipping_info.md] ## Lost or Damaged Packages
If your package is lost or arrives damaged, contact...
5. [dist=1.5516] [shipping_info.md] ## Domestic Shipping
Standard shipping (5-7 business days): Free on orders over...
...Os 3 principais resultados têm distâncias abaixo de 1.0 e estão todos relacionados a reembolsos. A partir do 4º resultado, as distâncias saltam acima de 1.4, e chunks não relacionados às condições de reembolso — como informações de envio — começam a aparecer. Com k=10, todos esses chunks são passados ao LLM.
Os custos de aumentar k são os seguintes:
- Ruído: Chunks de classificação mais baixa podem ser completamente não relacionados à pergunta. Quando esses chunks são incluídos no prompt, o LLM pode incluir informações desnecessárias ou incorretas em sua resposta.
- Maior custo: Mais chunks significam mais tokens enviados ao LLM, aumentando os custos das chamadas de API.
- Respostas mais lentas: Mais tokens para processar significam tempos de resposta mais longos.
10.4.2) MMR: Alcançando Tanto Relevância Quanto Diversidade
Em ambientes do mundo real, à medida que a coleção de documentos cresce, é comum que surjam vários chunks com conteúdo similar. A configuração chunk_overlap do Capítulo 9, que fazia chunks adjacentes compartilharem parte do conteúdo, também é uma fonte de duplicação. Nesses casos, mesmo pesquisando com k=3, poderiam ser retornados três chunks quase idênticos.
O MMR (Maximum Marginal Relevance) é um método de busca que evita que os resultados sejam enviesados em direção ao mesmo conteúdo. A busca por similaridade padrão retorna os k chunks mais próximos da consulta, o que pode fazer com que chunks similares se agrupem no topo. O MMR prioriza chunks que são tanto relevantes para a consulta quanto diferentes dos resultados já selecionados.
Veja como funciona:
- Assim como na busca por similaridade padrão, ela primeiro recupera
fetch_kchunks candidatos mais próximos da consulta. - Seleciona o chunk mais próximo da consulta como o primeiro resultado.
- Dos candidatos restantes, seleciona o próximo chunk que é relevante para a consulta, mas diferente em conteúdo dos chunks já selecionados.
- O passo 3 se repete até que
kchunks sejam selecionados.
O resultado é um conjunto de chunks que mantêm relevância enquanto evitam conteúdo sobreposto.
results_mmr = vector_store.max_marginal_relevance_search(
"What are the refund conditions?",
k=3,
fetch_k=10,
)
for i, doc in enumerate(results_mmr):
print(f"{i+1}. {doc.page_content[:80]}...")Saída:
1. ## Standard Returns
All physical products may be returned within 30 days of pur...
2. ## Defective Items
Defective items may be returned at any time for a full refun...
3. ## Digital Products
Digital products (software licenses, e-books, online course...Com os dados atuais, não há uma grande diferença em relação à busca padrão porque o conjunto de dados é pequeno. No entanto, à medida que os documentos crescem para centenas ou milhares, chunks similares frequentemente se agrupam no topo dos resultados, e é aí que o MMR se torna muito útil. fetch_k é o tamanho do conjunto de candidatos do qual o MMR seleciona — começar com 10–20 é uma abordagem comum.
10.4.3) Quando Parar de Ajustar
Há vários parâmetros para ajustar: k, fetch_k, filtros de metadados, limiares de distância e mais. Seguir estas regras simples ajuda você a ajustar de forma eficiente:
- Prepare várias perguntas — algumas com conteúdo relevante nos documentos e algumas sem.
- Execute as perguntas e examine diretamente os chunks recuperados.
- Se um problema for encontrado, altere apenas um parâmetro de cada vez e teste novamente com as mesmas perguntas.
Se as perguntas com conteúdo relevante produzem respostas corretas e as perguntas sem conteúdo relevante resultam em abstenção, você alcançou a qualidade básica. Acertar as configurações desde o início não é fácil. Responda aos problemas descobertos durante o uso real e melhore de forma incremental.