Python & AI Tutorials Logo
LangChain & LangGraph

10. Recupero più intelligente: filtri, soglie e MMR

Nel Capitolo 9 abbiamo costruito un semplice sistema RAG. Era una pipeline che suddivideva i documenti in chunk, li incorporava in ChromaDB e, quando arrivava una domanda dall'utente, recuperava i chunk pertinenti e li includeva nel prompt. Questo permetteva all'LLM di rispondere a domande su informazioni su cui non era mai stato addestrato, come documenti aziendali interni e manuali di prodotto. Tutto sembrava funzionare perfettamente.

Ma provando a porre una gamma più ampia di domande, le debolezze emergono rapidamente. Chiedi della politica di rimborso per i consumatori e si mescolano contenuti del negozio per dipendenti, oppure chiedi qualcosa che non è presente in nessun documento e l'LLM fabbrica una risposta plausibile, oppure aumenti il numero di risultati di ricerca e la qualità delle risposte effettivamente peggiora.

In questo capitolo affronteremo questi tre problemi uno alla volta. Useremo il filtraggio sui metadati per limitare quali documenti vengono cercati, le soglie sul punteggio di similarità per escludere risultati non correlati alla domanda e il tuning di K e MMR per migliorare la quantità e la diversità dei chunk forniti all'LLM. Non servono nuovi strumenti. Stiamo perfezionando la configurazione e l'utilizzo di similarity_search() e del vector store Chroma che già conosci.

10.1) Cosa c'è di sbagliato nel nostro RAG?

Nel Capitolo 9 abbiamo inserito nel vector store solo due documenti — una politica di rimborso e una politica di spedizione — e ciascun documento copriva un argomento distinto. Abbiamo anche testato solo domande le cui risposte erano chiaramente presenti o assenti nei documenti. Questa volta creeremo uno scenario più realistico. Aggiungeremo al vector store una guida del negozio per dipendenti. Anche questo documento contiene contenuti relativi ai rimborsi, ma è destinato ai dipendenti, non ai consumatori generici. Poi porremo varie domande e vedremo quali problemi sorgono.

Preparazione dei dati: aggiunta della guida del negozio per dipendenti

Ecco i due documenti del Capitolo 9 come riferimento.

data/docs/refund_policy.md:

markdown
# 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:

markdown
# 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.

Aggiungi qui la guida del negozio per dipendenti.

Crea data/docs/employee_store.md:

markdown
# 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.

La directory data/docs/ ora contiene tre file: refund_policy.md, shipping_info.md, employee_store.md. Esegui di nuovo lo script di ingestione del Capitolo 9 per ricostruire il vector store.

python
# ingest.py — stessa pipeline di ingestione del Capitolo 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")

Output:

Loaded 3 documents
Created 12 chunks
Stored 12 chunks in ChromaDB

Problema 1: documenti irrilevanti mescolati nei risultati di ricerca

Cerchiamo le condizioni di rimborso.

python
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]}...")

Output:

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...

Guarda il Result 2. Un cliente ha chiesto delle condizioni di rimborso, e la politica di rimborso del negozio per dipendenti è inclusa nei risultati. La politica di rimborso per i consumatori consente rimborsi completi entro 30 giorni, ma il negozio per dipendenti consente rimborsi solo entro 7 giorni per articoli non aperti, con rimborsi accreditati come punti benefit. Se entrambe le politiche vengono passate insieme all'LLM, al cliente potrebbero essere fornite condizioni di rimborso riservate ai dipendenti.

Problema 2: risultati restituiti anche quando non esiste contenuto pertinente

Ora chiediamo qualcosa che non esiste da nessuna parte nei nostri documenti. Useremo similarity_search_with_score(), che abbiamo imparato nel Capitolo 9, per vedere anche i valori di distanza. In ChromaDB, valori di distanza più bassi significano maggiore similarità.

python
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]}...")

Output:

[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...

Non c'è alcuna informazione sul processo di assunzione da nessuna parte. I valori di distanza sono tutti superiori a 1.4, indicando una similarità molto bassa, eppure similarity_search_with_score() ha comunque restituito tre chunk. Vediamo quale risposta produce la catena RAG quando questi chunk le vengono passati.

python
from rag_chain import build_rag_chain  # catena RAG dal Capitolo 9
 
chain = build_rag_chain()
answer = chain.invoke("What is the hiring process at this company?")
print(answer)

Output:

I don't have enough information to answer that question.

L'LLM ha risposto che non aveva informazioni sufficienti per rispondere. Questo perché abbiamo incluso l'istruzione "say you don't have enough information" nel system prompt di build_rag_chain(). Tuttavia, l'LLM non può sempre fare questa valutazione. Se i chunk recuperati contengono frasi che appaiono correlate alla domanda, l'LLM potrebbe generare una risposta errata basata su quel contenuto.

Problema 3: aumentare i risultati di ricerca aiuta sempre?

Potresti pensare "non sarebbe meglio avere più contesto?". Aumentare k da 3 a 10 aumenta effettivamente la probabilità che i chunk necessari siano inclusi. Ma allo stesso tempo entrano anche più chunk irrilevanti. Poiché l'LLM riceve tutti questi chunk come contesto e genera le risposte a partire da essi, informazioni non necessarie o errate possono finire nella risposta. Più contesto non significa necessariamente risposte migliori.

Inoltre, tutti i chunk recuperati vengono passati all'LLM come token. Man mano che k cresce, i costi delle chiamate API aumentano e i tempi di risposta rallentano.

Abbiamo identificato tre problemi. Ora risolviamoli uno alla volta.

10.2) Filtraggio sui metadati: restringere lo spazio di ricerca

Quando abbiamo cercato le condizioni di rimborso nel Problema 1, sono apparse insieme sia la politica di rimborso per i clienti sia quella del negozio per dipendenti. Questo è accaduto perché non abbiamo indicato a similarity_search() in quali documenti cercare.

Il filtraggio sui metadati associa a ciascun chunk attributi come categoria, origine e anno di pubblicazione, e poi filtra i chunk in base a questi attributi prima di eseguire la ricerca per similarità. Solo i chunk che soddisfano le condizioni passano al calcolo della similarità. Svolge un ruolo simile alla clausola WHERE di SQL.

10.2.1) Contenuto del documento vs. metadati del documento

L'oggetto Document di cui abbiamo parlato nel Capitolo 9 contiene due cose:

  • page_content: il testo stesso. Viene incorporato in un vettore ed è ciò su cui opera la ricerca per similarità.
  • metadata: un dizionario che contiene attributi come origine e categoria. Questi valori non vengono incorporati.

La ricerca per similarità opera su page_content, mentre il filtraggio sui metadati opera sulle informazioni di metadata.

10.2.2) Ricostruire il vector store: aggiungere i metadati

Aggiungeremo un attributo category per il filtraggio e ricostruiremo il vector store. Dobbiamo solo aggiungere il codice di assegnazione dei metadati all'ingest.py della sezione 10.1.

python
# 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
 
# Step 1: Carica i documenti (come in 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")
 
# Step 2: [NUOVO] Assegna i metadati di categoria in base al nome del file
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")
 
# Step 3: Suddividi in chunk (come in 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")
 
# Step 4: Costruisci il vector store (come in 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")

Output:

Loaded 3 documents
Created 12 chunks
Stored 12 chunks in ChromaDB

L'unica differenza rispetto all'ingest.py originale è l'aggiunta dei metadati category a ciascun documento.

10.2.3) Applicare i filtri alla ricerca

Ora possiamo usare il parametro filter di similarity_search() per limitare quali documenti vengono cercati. Cercheremo con la stessa query del Problema 1, ma aggiungeremo un filtro per cercare solo nei documenti rivolti ai clienti.

python
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]}...")

Output:

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...

La politica di rimborso del negozio per dipendenti che si era mescolata nel Problema 1 non è più inclusa. Questo perché abbiamo specificato che dovevano essere cercati solo i chunk con category uguale a "customer".

Combinare più condizioni

L'esempio precedente usava una singola condizione (category uguale a "customer"). Quando più condizioni devono essere applicate contemporaneamente, puoi combinarle con operatori logici come $and e $or.

python
# $and: solo i chunk che soddisfano TUTTE le condizioni
filter={
    "$and": [
        {"category": "customer"},
        {"source": "data/docs/refund_policy.md"},
    ]
}
 
# $or: i chunk che soddisfano QUALSIASI condizione
filter={
    "$or": [
        {"source": "data/docs/refund_policy.md"},
        {"source": "data/docs/shipping_info.md"},
    ]
}

Altri operatori includono $ne (diverso da), $gt (maggiore di) e $lt (minore di). Consulta la documentazione di ChromaDB per l'elenco completo degli operatori.

10.3) Soglie di similarità: escludere i risultati a bassa pertinenza

Nel Problema 2, quando abbiamo chiesto del processo di assunzione, sono stati restituiti chunk con valori di distanza superiori a 1.4. In ChromaDB, valori di distanza così alti indicano quasi nessuna pertinenza. Eppure sono stati comunque recuperati. Questo perché similarity_search() restituisce sempre k risultati.

Questo problema può essere risolto impostando una soglia di distanza. I risultati più lontani della soglia non vengono inclusi nel contesto passato all'LLM.

Quindi quale soglia dovremmo impostare? Confrontiamo prima i valori di distanza tra domande che hanno contenuto pertinente nei documenti e domande che non ne hanno.

10.3.1) Confrontare le distanze: domande con e senza contenuto pertinente

python
queries = [
    "Can I cancel a subscription service?",           # esiste contenuto pertinente
    "What is the hiring process at this company?",     # nessun contenuto pertinente
]
 
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]}...")

Output:

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...

Le domande con contenuto pertinente hanno valori di distanza intorno a 0.75, mentre le domande senza contenuto pertinente hanno valori superiori a 1.4. Testa diverse domande in questo modo, poi scegli una soglia che separi chiaramente i due casi. La soglia giusta può variare a seconda del modello di embedding, della natura dei tuoi documenti e della dimensione dei chunk, quindi è meglio determinarla testando direttamente con i tuoi dati.

10.3.2) Filtrare i risultati di ricerca con una soglia di distanza

Una volta impostata una soglia, costruiamo una funzione che escluda i risultati che superano la soglia. Solo i chunk che superano il filtro vengono inclusi nel contesto passato all'LLM. Se nessun chunk rimane al di sotto della soglia, saltiamo completamente la chiamata all'LLM e rispondiamo con "I don't have enough information to answer that question."

python
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):
    """Restituisce solo i chunk al di sotto della soglia di distanza. Restituisce None se nessuno passa."""
    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).content

Testiamo con le stesse domande di prima.

python
# Domanda con contenuto pertinente
print(safe_answer("Can I cancel a subscription service?"))
print("---")
# Domanda senza contenuto pertinente
print(safe_answer("What is the hiring process at this company?"))

Output:

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: controllare la quantità e la diversità dei risultati di ricerca

In questa sezione confronteremo direttamente come cambiano i risultati di ricerca con diversi valori di k e impareremo a conoscere la ricerca MMR, che migliora la diversità dei risultati.

10.4.1) Cosa succede quando aumenti K?

Nel Problema 3 abbiamo detto che aumentare k porta anche più chunk irrilevanti. Verifichiamolo. Cercheremo con k=10 ed esamineremo i valori di distanza di ciascun chunk.

python
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]}...")

Output:

 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...
...

I primi 3 risultati hanno distanze inferiori a 1.0 e sono tutti relativi ai rimborsi. A partire dal 4° risultato, le distanze salgono oltre 1.4 e iniziano a comparire chunk non correlati alle condizioni di rimborso — come le informazioni sulla spedizione. Con k=10, tutti questi chunk vengono passati all'LLM.

I costi dell'aumento di k sono i seguenti:

  • Rumore: i chunk con classificazione più bassa potrebbero essere completamente non correlati alla domanda. Quando tali chunk vengono inclusi nel prompt, l'LLM potrebbe includere informazioni non necessarie o errate nella sua risposta.
  • Costo maggiore: più chunk significano più token inviati all'LLM, aumentando i costi delle chiamate API.
  • Risposte più lente: più token da elaborare significano tempi di risposta più lunghi.

10.4.2) MMR: ottenere sia pertinenza che diversità

In ambienti reali, man mano che la raccolta di documenti cresce, è comune che emergano più chunk con contenuto simile. L'impostazione chunk_overlap del Capitolo 9, che faceva condividere parte del contenuto ai chunk adiacenti, è anch'essa una fonte di duplicazione. In questi casi, anche cercando con k=3 si potrebbero restituire tre chunk quasi identici.

MMR (Maximum Marginal Relevance) è un metodo di ricerca che impedisce ai risultati di essere sbilanciati verso lo stesso contenuto. La ricerca per similarità standard restituisce i k chunk più vicini alla query, il che può far sì che chunk simili si raggruppino in cima. MMR dà priorità ai chunk che sono sia pertinenti alla query sia diversi dai risultati già selezionati.

Ecco come funziona:

  1. Proprio come la ricerca per similarità standard, recupera prima fetch_k chunk candidati più vicini alla query.
  2. Seleziona come primo risultato il chunk più vicino alla query.
  3. Dai candidati rimanenti, seleziona il chunk successivo che è pertinente alla query ma diverso nel contenuto dai chunk già selezionati.
  4. Lo step 3 si ripete finché non sono stati selezionati k chunk.

Il risultato è un insieme di chunk che mantengono la pertinenza evitando contenuti sovrapposti.

python
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]}...")

Output:

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...

Con i dati attuali non c'è una grande differenza rispetto alla ricerca standard, perché il dataset è piccolo. Tuttavia, man mano che i documenti crescono fino a centinaia o migliaia, chunk simili si raggruppano frequentemente in cima ai risultati, ed è qui che MMR diventa molto utile. fetch_k è la dimensione del pool di candidati da cui MMR seleziona — iniziare con 10–20 è un approccio comune.

10.4.3) Quando smettere di fare tuning

Ci sono diversi parametri da regolare: k, fetch_k, filtri sui metadati, soglie di distanza e altro. Seguire queste semplici regole aiuta a fare il tuning in modo efficiente:

  1. Prepara diverse domande — alcune con contenuto pertinente nei documenti e altre senza.
  2. Esegui le domande ed esamina direttamente i chunk recuperati.
  3. Se viene riscontrato un problema, modifica un solo parametro alla volta e ripeti il test con le stesse domande.

Se le domande con contenuto pertinente producono risposte corrette e le domande senza contenuto pertinente portano all'astensione, hai raggiunto la qualità di base. Ottenere le impostazioni perfette fin dall'inizio non è facile. Rispondi ai problemi scoperti durante l'uso reale e migliora in modo incrementale.