Python & AI Tutorials Logo
LangChain & LangGraph

10. Une recherche plus intelligente : filtres, seuils et MMR

Au chapitre 9, nous avons construit un système RAG simple. Il s'agissait d'un pipeline qui découpait les documents en fragments, les intégrait dans ChromaDB, et lorsqu'une question d'utilisateur arrivait, récupérait les fragments pertinents et les incluait dans le prompt. Cela permettait au LLM de répondre à des questions sur des informations sur lesquelles il n'avait jamais été entraîné, comme des documents internes d'entreprise et des manuels de produits. Tout semblait fonctionner parfaitement.

Mais essayez de poser une plus grande variété de questions et les faiblesses apparaissent rapidement. Vous posez une question sur la politique de remboursement des consommateurs et du contenu sur le magasin des employés s'y mélange, ou vous posez une question sur quelque chose qui ne figure dans aucun document et le LLM fabrique une réponse plausible, ou vous augmentez le nombre de résultats de recherche et la qualité des réponses chute en réalité.

Dans ce chapitre, nous traiterons ces trois problèmes un par un. Nous utiliserons le filtrage par métadonnées pour restreindre quels documents sont recherchés, les seuils de score de similarité pour exclure les résultats sans rapport avec la question, et le réglage de K et MMR pour améliorer la quantité et la diversité des fragments fournis au LLM. Aucun nouvel outil n'est nécessaire. Nous affinons la configuration et l'utilisation de similarity_search() et du vector store Chroma que vous connaissez déjà.

10.1) Qu'est-ce qui ne va pas avec notre RAG ?

Au chapitre 9, nous n'avions placé que deux documents dans le vector store — une politique de remboursement et une politique d'expédition — et chaque document couvrait un sujet distinct. Nous n'avions également testé que des questions dont les réponses étaient soit clairement présentes, soit clairement absentes dans les documents. Cette fois, nous allons créer un scénario plus réaliste. Nous ajouterons un guide du magasin des employés dans le vector store. Ce document contient aussi du contenu lié aux remboursements, mais il est destiné aux employés, et non aux consommateurs ordinaires. Ensuite, nous poserons diverses questions et verrons quels problèmes surgissent.

Préparation des données : ajout du guide du magasin des employés

Voici les deux documents du chapitre 9 pour référence.

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.

Ajoutez ici le guide du magasin des employés.

Créez 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.

Le répertoire data/docs/ contient maintenant trois fichiers : refund_policy.md, shipping_info.md, employee_store.md. Réexécutez le script d'ingestion du chapitre 9 pour reconstruire le vector store.

python
# ingest.py — même pipeline d'ingestion que le chapitre 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")

Sortie :

Loaded 3 documents
Created 12 chunks
Stored 12 chunks in ChromaDB

Problème 1 : des documents non pertinents mélangés aux résultats de recherche

Cherchons les conditions de remboursement.

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

Sortie :

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

Regardez le résultat 2. Un client a posé une question sur les conditions de remboursement, et la politique de remboursement du magasin des employés est incluse dans les résultats. La politique de remboursement des consommateurs autorise des remboursements complets sous 30 jours, mais le magasin des employés n'autorise les remboursements que sous 7 jours pour les articles non ouverts, avec des remboursements crédités sous forme de points d'avantages. Si les deux politiques sont transmises ensemble au LLM, le client pourrait se voir proposer des conditions de remboursement réservées aux employés.

Problème 2 : des résultats renvoyés même lorsqu'aucun contenu pertinent n'existe

Posons maintenant une question sur quelque chose qui n'existe nulle part dans nos documents. Nous utiliserons similarity_search_with_score(), que nous avons découvert au chapitre 9, pour voir également les valeurs de distance. Dans ChromaDB, des valeurs de distance plus faibles signifient une similarité plus élevée.

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

Sortie :

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

Il n'existe aucune information sur le processus de recrutement nulle part. Les valeurs de distance sont toutes supérieures à 1,4, montrant une très faible similarité, et pourtant similarity_search_with_score() a quand même renvoyé trois fragments. Voyons quelle réponse la chaîne RAG produit lorsque ces fragments lui sont transmis.

python
from rag_chain import build_rag_chain  # chaîne RAG du chapitre 9
 
chain = build_rag_chain()
answer = chain.invoke("What is the hiring process at this company?")
print(answer)

Sortie :

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

Le LLM a répondu qu'il n'avait pas assez d'informations pour répondre. C'est parce que nous avons inclus l'instruction « dites que vous n'avez pas assez d'informations » dans le system prompt de build_rag_chain(). Cependant, le LLM ne peut pas toujours porter ce jugement. Si les fragments récupérés contiennent des expressions qui semblent liées à la question, le LLM peut générer une réponse incorrecte basée sur ce contenu.

Problème 3 : augmenter le nombre de résultats de recherche est-il toujours utile ?

Vous pourriez penser : « plus de contexte ne serait-il pas mieux ? ». Augmenter k de 3 à 10 augmente effectivement la probabilité que les fragments nécessaires soient inclus. Mais en même temps, davantage de fragments non pertinents entrent aussi. Comme le LLM reçoit tous ces fragments comme contexte et génère des réponses à partir d'eux, des informations inutiles ou incorrectes peuvent finir dans la réponse. Plus de contexte ne signifie pas nécessairement de meilleures réponses.

De plus, tous les fragments récupérés sont transmis au LLM sous forme de tokens. À mesure que k augmente, les coûts des appels API augmentent et les temps de réponse ralentissent.

Nous avons identifié trois problèmes. Maintenant, résolvons-les un par un.

10.2) Filtrage par métadonnées : réduire l'espace de recherche

Lorsque nous avons cherché les conditions de remboursement dans le problème 1, la politique de remboursement des clients et celle du magasin des employés sont apparues ensemble. Cela s'est produit parce que nous n'avons pas indiqué à similarity_search() dans quels documents chercher.

Le filtrage par métadonnées attache des attributs comme la catégorie, la source et l'année de publication à chaque fragment, puis filtre les fragments en fonction de ces attributs avant d'exécuter la recherche de similarité. Seuls les fragments correspondant aux conditions passent par le calcul de similarité. Cela joue un rôle similaire à la clause WHERE de SQL.

10.2.1) Contenu du document vs métadonnées du document

L'objet Document que nous avons découvert au chapitre 9 contient deux choses :

  • page_content : le texte lui-même. Il est intégré dans un vecteur et c'est sur lui que la recherche de similarité opère.
  • metadata : un dictionnaire qui contient des attributs comme la source et la catégorie. Ces valeurs ne sont pas intégrées.

La recherche de similarité opère sur page_content, tandis que le filtrage par métadonnées opère sur les informations de metadata.

10.2.2) Reconstruire le vector store : ajout des métadonnées

Nous allons ajouter un attribut category pour le filtrage et reconstruire le vector store. Il suffit d'ajouter du code d'attribution de métadonnées à l'ingest.py de la section 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
 
# Étape 1 : Charger les documents (identique à 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")
 
# Étape 2 : [NOUVEAU] Assigner la métadonnée category selon le nom de fichier
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")
 
# Étape 3 : Découper en fragments (identique à 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")
 
# Étape 4 : Construire le vector store (identique à 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")

Sortie :

Loaded 3 documents
Created 12 chunks
Stored 12 chunks in ChromaDB

La seule différence par rapport à l'ingest.py original est l'ajout de la métadonnée category à chaque document.

10.2.3) Appliquer des filtres à la recherche

Nous pouvons maintenant utiliser le paramètre filter de similarity_search() pour restreindre quels documents sont recherchés. Nous chercherons avec la même requête que dans le problème 1, mais en ajoutant un filtre pour ne rechercher que dans les documents destinés aux clients.

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

Sortie :

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 politique de remboursement du magasin des employés qui s'était mélangée dans le problème 1 n'est plus incluse. C'est parce que nous avons spécifié que seuls les fragments dont la category est égale à "customer" devaient être recherchés.

Combiner plusieurs conditions

L'exemple ci-dessus utilisait une condition unique (category égale "customer"). Lorsque plusieurs conditions doivent être appliquées simultanément, vous pouvez les combiner avec des opérateurs logiques comme $and et $or.

python
# $and : seulement les fragments satisfaisant TOUTES les conditions
filter={
    "$and": [
        {"category": "customer"},
        {"source": "data/docs/refund_policy.md"},
    ]
}
 
# $or : les fragments satisfaisant N'IMPORTE QUELLE condition
filter={
    "$or": [
        {"source": "data/docs/refund_policy.md"},
        {"source": "data/docs/shipping_info.md"},
    ]
}

Les opérateurs supplémentaires incluent $ne (différent de), $gt (supérieur à) et $lt (inférieur à). Consultez la documentation ChromaDB pour la liste complète des opérateurs.

10.3) Seuils de similarité : exclure les résultats peu pertinents

Dans le problème 2, lorsque nous avons posé une question sur le processus de recrutement, des fragments dont les valeurs de distance dépassaient 1,4 ont été renvoyés. Dans ChromaDB, des valeurs de distance aussi élevées indiquent une pertinence quasi nulle. Et pourtant, ils ont quand même été récupérés. C'est parce que similarity_search() renvoie toujours k résultats.

Ce problème peut être résolu en définissant un seuil de distance. Les résultats plus éloignés que le seuil ne sont pas inclus dans le contexte transmis au LLM.

Alors quel seuil devrions-nous définir ? Comparons d'abord les valeurs de distance entre des questions qui ont du contenu pertinent dans les documents et des questions qui n'en ont pas.

10.3.1) Comparer les distances : questions avec et sans contenu pertinent

python
queries = [
    "Can I cancel a subscription service?",           # du contenu pertinent existe
    "What is the hiring process at this company?",     # aucun contenu pertinent
]
 
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]}...")

Sortie :

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

Les questions avec du contenu pertinent ont des valeurs de distance autour de 0,75, tandis que les questions sans contenu pertinent ont des valeurs supérieures à 1,4. Testez plusieurs questions de ce type, puis choisissez un seuil qui sépare clairement les deux cas. Le bon seuil peut varier selon le modèle d'embedding, la nature de vos documents et la taille des fragments, il est donc préférable de le déterminer en testant directement avec vos propres données.

10.3.2) Filtrer les résultats de recherche par seuil de distance

Une fois un seuil défini, construisons une fonction qui filtre les résultats dépassant le seuil. Seuls les fragments qui passent le filtre sont inclus dans le contexte transmis au LLM. S'il ne reste aucun fragment en dessous du seuil, nous évitons complètement l'appel au LLM et répondons par « Je n'ai pas assez d'informations pour répondre à cette 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):
    """Ne renvoie que les fragments en dessous du seuil de distance. Renvoie None si aucun ne passe."""
    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

Testons avec les mêmes questions que précédemment.

python
# Question avec du contenu pertinent
print(safe_answer("Can I cancel a subscription service?"))
print("---")
# Question sans contenu pertinent
print(safe_answer("What is the hiring process at this company?"))

Sortie :

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 et MMR : contrôler la quantité et la diversité des résultats de recherche

Dans cette section, nous comparerons directement comment les résultats de recherche changent avec différentes valeurs de k, et découvrirons la recherche MMR, qui améliore la diversité des résultats.

10.4.1) Que se passe-t-il lorsque vous augmentez K ?

Dans le problème 3, nous avons dit qu'augmenter k apporte aussi davantage de fragments non pertinents. Vérifions-le. Nous chercherons avec k=10 et examinerons les valeurs de distance de chaque fragment.

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

Sortie :

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

Les 3 premiers résultats ont des distances inférieures à 1,0 et sont tous liés aux remboursements. À partir du 4e résultat, les distances bondissent au-dessus de 1,4, et des fragments sans rapport avec les conditions de remboursement — comme les informations d'expédition — commencent à apparaître. Avec k=10, tous ces fragments sont transmis au LLM.

Les coûts liés à l'augmentation de k sont les suivants :

  • Bruit : les fragments moins bien classés peuvent être complètement sans rapport avec la question. Lorsque de tels fragments sont inclus dans le prompt, le LLM peut inclure des informations inutiles ou incorrectes dans sa réponse.
  • Coût plus élevé : plus de fragments signifie plus de tokens envoyés au LLM, ce qui augmente les coûts des appels API.
  • Réponses plus lentes : plus de tokens à traiter signifie des temps de réponse plus longs.

10.4.2) MMR : obtenir à la fois pertinence et diversité

Dans les environnements réels, à mesure que la collection de documents grandit, il est courant que plusieurs fragments au contenu similaire émergent. Le paramètre chunk_overlap du chapitre 9, qui faisait partager du contenu à des fragments adjacents, est aussi une source de duplication. Dans de tels cas, même une recherche avec k=3 pourrait renvoyer trois fragments presque identiques.

MMR (Maximum Marginal Relevance) est une méthode de recherche qui empêche les résultats d'être biaisés vers le même contenu. La recherche de similarité standard renvoie les k fragments les plus proches de la requête, ce qui peut faire que des fragments similaires se regroupent en haut. MMR privilégie les fragments qui sont à la fois pertinents par rapport à la requête et différents des résultats déjà sélectionnés.

Voici comment cela fonctionne :

  1. Tout comme la recherche de similarité standard, elle récupère d'abord fetch_k fragments candidats les plus proches de la requête.
  2. Elle sélectionne le fragment le plus proche de la requête comme premier résultat.
  3. Parmi les candidats restants, elle sélectionne le prochain fragment qui est pertinent par rapport à la requête mais différent en contenu des fragments déjà sélectionnés.
  4. L'étape 3 se répète jusqu'à ce que k fragments soient sélectionnés.

Le résultat est un ensemble de fragments qui maintiennent la pertinence tout en évitant le contenu redondant.

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

Sortie :

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

Avec les données actuelles, il n'y a pas de grande différence par rapport à la recherche standard parce que le jeu de données est petit. Cependant, à mesure que les documents passent à des centaines ou des milliers, des fragments similaires se regroupent fréquemment en haut des résultats, et c'est là que MMR devient très utile. fetch_k est la taille du pool de candidats parmi lesquels MMR sélectionne — commencer avec 10 à 20 est une approche courante.

10.4.3) Quand arrêter le réglage

Il y a plusieurs paramètres à ajuster : k, fetch_k, les filtres de métadonnées, les seuils de distance, et plus encore. Suivre ces règles simples vous aide à régler efficacement :

  1. Préparez plusieurs questions — certaines avec du contenu pertinent dans les documents et d'autres sans.
  2. Exécutez les questions et examinez directement les fragments récupérés.
  3. Si un problème est trouvé, ne changez qu'un seul paramètre à la fois et retestez avec les mêmes questions.

Si les questions avec du contenu pertinent produisent des réponses correctes et que les questions sans contenu pertinent aboutissent à une abstention, vous avez atteint une qualité de référence. Obtenir des réglages parfaits dès le départ n'est pas facile. Répondez aux problèmes découverts lors de l'utilisation réelle et améliorez de manière incrémentale.