Python & AI Tutorials Logo
LangChain & LangGraph

10. Recuperación más inteligente: filtros, umbrales y MMR

En el capítulo 9, construimos un sistema RAG sencillo. Era un pipeline que dividía documentos en fragmentos, los incrustaba en ChromaDB y, cuando llegaba la pregunta de un usuario, recuperaba los fragmentos relevantes y los incluía en el prompt. Esto permitía que el LLM respondiera preguntas sobre información con la que nunca había sido entrenado, como documentos internos de empresa y manuales de producto. Todo parecía funcionar correctamente.

Pero prueba a hacer una variedad más amplia de preguntas y las debilidades aparecen rápidamente. Preguntas sobre la política de reembolsos para consumidores y se mezcla contenido de la tienda de empleados, o preguntas sobre algo que no está en ningún documento y el LLM fabrica una respuesta plausible, o aumentas el número de resultados de búsqueda y la calidad de las respuestas en realidad empeora.

En este capítulo, abordaremos estos tres problemas uno por uno. Usaremos el filtrado por metadatos para restringir qué documentos se buscan, umbrales de puntuación de similitud para excluir resultados no relacionados con la pregunta, y ajuste de K y MMR para mejorar la cantidad y diversidad de los fragmentos que se pasan al LLM. No se necesitan herramientas nuevas. Estamos refinando la configuración y el uso de similarity_search() y del vector store Chroma que ya conoces.

10.1) ¿Qué está mal en nuestro RAG?

En el capítulo 9, solo pusimos dos documentos en el vector store —una política de reembolsos y una política de envíos— y cada documento cubría un tema distinto. También solo probamos preguntas cuyas respuestas estaban claramente presentes o ausentes en los documentos. Esta vez, crearemos un escenario más realista. Añadiremos una guía de la tienda de empleados al vector store. Este documento también contiene contenido relacionado con reembolsos, pero está destinado a empleados, no a consumidores generales. Luego haremos varias preguntas y veremos qué problemas surgen.

Preparación de datos: añadir la guía de la tienda de empleados

Aquí están los dos documentos del capítulo 9 como referencia.

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.

Añade aquí la guía de la tienda de empleados.

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.

El directorio data/docs/ ahora contiene tres archivos: refund_policy.md, shipping_info.md, employee_store.md. Vuelve a ejecutar el script de ingesta del capítulo 9 para reconstruir el vector store.

python
# ingest.py — el mismo pipeline de ingesta del 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")

Salida:

Loaded 3 documents
Created 12 chunks
Stored 12 chunks in ChromaDB

Problema 1: documentos irrelevantes mezclados en los resultados de búsqueda

Busquemos las condiciones de reembolso.

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

Salida:

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

Fíjate en el Result 2. Un cliente preguntó por las condiciones de reembolso, y la política de reembolsos de la tienda de empleados aparece entre los resultados. La política de reembolsos para consumidores permite reembolsos completos dentro de los 30 días, pero la tienda de empleados solo permite reembolsos dentro de los 7 días para artículos sin abrir, con los reembolsos acreditados como puntos de beneficios. Si ambas políticas se pasan juntas al LLM, podrían darse al cliente condiciones de reembolso exclusivas para empleados.

Problema 2: se devuelven resultados incluso cuando no existe contenido relevante

Ahora preguntemos por algo que no existe en ninguna parte de nuestros documentos. Usaremos similarity_search_with_score(), que aprendimos en el capítulo 9, para ver también los valores de distancia. En ChromaDB, valores de distancia más bajos significan mayor similitud.

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

Salida:

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

No hay información sobre el proceso de contratación en ninguna parte. Los valores de distancia están todos por encima de 1.4, lo que muestra una similitud muy baja, y sin embargo similarity_search_with_score() aún devolvió tres fragmentos. Veamos qué respuesta produce la cadena RAG cuando se le pasan estos fragmentos.

python
from rag_chain import build_rag_chain  # cadena RAG del capítulo 9
 
chain = build_rag_chain()
answer = chain.invoke("What is the hiring process at this company?")
print(answer)

Salida:

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

El LLM respondió que no tenía suficiente información para responder. Esto se debe a que incluimos la instrucción "di que no tienes suficiente información" en el system prompt de build_rag_chain(). Sin embargo, el LLM no siempre puede hacer este juicio. Si los fragmentos recuperados contienen frases que parecen relacionadas con la pregunta, el LLM puede generar una respuesta incorrecta basada en ese contenido.

Problema 3: ¿aumentar los resultados de búsqueda siempre ayuda?

Podrías pensar "¿no sería mejor tener más contexto?". Aumentar k de 3 a 10 sí incrementa la probabilidad de que se incluyan los fragmentos necesarios. Pero, al mismo tiempo, también entran más fragmentos irrelevantes. Como el LLM recibe todos estos fragmentos como contexto y genera respuestas a partir de ellos, información innecesaria o incorrecta puede terminar en la respuesta. Más contexto no significa necesariamente mejores respuestas.

Además, todos los fragmentos recuperados se pasan al LLM como tokens. A medida que k crece, los costos de las llamadas a la API aumentan y los tiempos de respuesta se ralentizan.

Hemos identificado tres problemas. Ahora resolvámoslos uno por uno.

10.2) Filtrado por metadatos: reducir el espacio de búsqueda

Cuando buscamos las condiciones de reembolso en el Problema 1, aparecieron juntas tanto la política de reembolsos para clientes como la política de reembolsos de la tienda de empleados. Esto sucedió porque no le indicamos a similarity_search() en qué documentos buscar.

El filtrado por metadatos adjunta atributos como categoría, fuente y año de publicación a cada fragmento, y luego filtra los fragmentos según estos atributos antes de ejecutar la búsqueda por similitud. Solo los fragmentos que cumplen las condiciones pasan por el cálculo de similitud. Cumple un rol similar al de la cláusula WHERE de SQL.

10.2.1) Contenido del documento vs. metadatos del documento

El objeto Document que aprendimos en el capítulo 9 contiene dos cosas:

  • page_content: el texto en sí. Se incrusta en un vector y es sobre lo que opera la búsqueda por similitud.
  • metadata: un diccionario que contiene atributos como la fuente y la categoría. Estos valores no se incrustan.

La búsqueda por similitud opera sobre page_content, mientras que el filtrado por metadatos opera sobre la información de metadata.

10.2.2) Reconstruir el vector store: añadir metadatos

Añadiremos un atributo category para el filtrado y reconstruiremos el vector store. Solo necesitamos agregar código de asignación de metadatos al ingest.py de 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
 
# Paso 1: Cargar documentos (igual que 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")
 
# Paso 2: [NUEVO] Asignar metadatos de categoría según el nombre del archivo
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")
 
# Paso 3: Dividir en fragmentos (igual que 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")
 
# Paso 4: Construir el vector store (igual que 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")

Salida:

Loaded 3 documents
Created 12 chunks
Stored 12 chunks in ChromaDB

La única diferencia con el ingest.py original es la adición de metadatos category a cada documento.

10.2.3) Aplicar filtros a la búsqueda

Ahora podemos usar el parámetro filter de similarity_search() para restringir qué documentos se buscan. Buscaremos con la misma consulta del Problema 1, pero añadiendo un filtro para buscar solo en los documentos orientados al cliente.

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

Salida:

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 política de reembolsos de la tienda de empleados que se mezclaba en el Problema 1 ya no aparece. Esto se debe a que especificamos que solo deben buscarse los fragmentos cuyo category sea igual a "customer".

Combinar múltiples condiciones

El ejemplo anterior usó una única condición (category igual a "customer"). Cuando se necesitan aplicar varias condiciones simultáneamente, puedes combinarlas con operadores lógicos como $and y $or.

python
# $and: solo fragmentos que cumplen TODAS las condiciones
filter={
    "$and": [
        {"category": "customer"},
        {"source": "data/docs/refund_policy.md"},
    ]
}
 
# $or: fragmentos que cumplen CUALQUIER condición
filter={
    "$or": [
        {"source": "data/docs/refund_policy.md"},
        {"source": "data/docs/shipping_info.md"},
    ]
}

Otros operadores incluyen $ne (distinto de), $gt (mayor que) y $lt (menor que). Consulta la documentación de ChromaDB para la lista completa de operadores.

10.3) Umbrales de similitud: excluir resultados de baja relevancia

En el Problema 2, cuando preguntamos por el proceso de contratación, se devolvieron fragmentos con valores de distancia por encima de 1.4. En ChromaDB, valores de distancia tan altos indican casi ninguna relevancia. Y aun así fueron recuperados. Esto se debe a que similarity_search() siempre devuelve k resultados.

Este problema se puede resolver estableciendo un umbral de distancia. Los resultados más lejanos que el umbral no se incluyen en el contexto que se pasa al LLM.

Entonces, ¿qué umbral deberíamos establecer? Primero comparemos los valores de distancia entre preguntas que tienen contenido relevante en los documentos y preguntas que no.

10.3.1) Comparar distancias: preguntas con y sin contenido relevante

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

Salida:

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

Las preguntas con contenido relevante tienen valores de distancia en torno a 0.75, mientras que las preguntas sin contenido relevante tienen valores por encima de 1.4. Prueba varias preguntas de este modo y luego elige un umbral que separe claramente los dos casos. El umbral adecuado puede variar según el modelo de embedding, la naturaleza de tus documentos y el tamaño de los fragmentos, así que lo mejor es determinarlo probando directamente con tus propios datos.

10.3.2) Filtrar los resultados de búsqueda por umbral de distancia

Una vez establecido un umbral, construyamos una función que filtre los resultados que lo superen. Solo los fragmentos que sobreviven al filtro se incluyen en el contexto que se pasa al LLM. Si no queda ningún fragmento por debajo del umbral, omitimos por completo la llamada al LLM y respondemos con "No tengo suficiente información para responder esa pregunta".

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):
    """Devuelve solo los fragmentos por debajo del umbral de distancia. Devuelve None si ninguno pasa."""
    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

Probemos con las mismas preguntas de antes.

python
# Pregunta con contenido relevante
print(safe_answer("Can I cancel a subscription service?"))
print("---")
# Pregunta sin contenido relevante
print(safe_answer("What is the hiring process at this company?"))

Salida:

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 y MMR: controlar la cantidad y la diversidad de los resultados de búsqueda

En esta sección, compararemos directamente cómo cambian los resultados de búsqueda con diferentes valores de k, y aprenderemos sobre la búsqueda MMR, que mejora la diversidad de los resultados.

10.4.1) ¿Qué pasa cuando aumentas K?

En el Problema 3, dijimos que aumentar k también trae más fragmentos irrelevantes. Verifiquémoslo. Buscaremos con k=10 y examinaremos los valores de distancia de cada fragmento.

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

Salida:

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

Los 3 primeros resultados tienen distancias por debajo de 1.0 y todos están relacionados con reembolsos. A partir del 4.º resultado, las distancias saltan por encima de 1.4, y empiezan a aparecer fragmentos no relacionados con las condiciones de reembolso, como información de envíos. Con k=10, todos estos fragmentos se pasan al LLM.

Los costos de aumentar k son los siguientes:

  • Ruido: los fragmentos de menor rango pueden estar completamente desvinculados de la pregunta. Cuando esos fragmentos se incluyen en el prompt, el LLM puede incorporar información innecesaria o incorrecta en su respuesta.
  • Mayor costo: más fragmentos significan más tokens enviados al LLM, lo que incrementa los costos de las llamadas a la API.
  • Respuestas más lentas: más tokens que procesar significa tiempos de respuesta más largos.

10.4.2) MMR: lograr relevancia y diversidad a la vez

En entornos reales, a medida que crece la colección de documentos, es común que surjan múltiples fragmentos con contenido similar. La configuración chunk_overlap del capítulo 9, que hacía que los fragmentos adyacentes compartieran parte del contenido, también es una fuente de duplicación. En tales casos, incluso buscar con k=3 podría devolver tres fragmentos casi idénticos.

MMR (Maximum Marginal Relevance) es un método de búsqueda que evita que los resultados se sesguen hacia el mismo contenido. La búsqueda por similitud estándar devuelve los k fragmentos más cercanos a la consulta, lo que puede hacer que fragmentos similares se agrupen al principio. MMR prioriza los fragmentos que son tanto relevantes para la consulta como diferentes de los resultados ya seleccionados.

Así funciona:

  1. Al igual que la búsqueda por similitud estándar, primero recupera fetch_k fragmentos candidatos más cercanos a la consulta.
  2. Selecciona el fragmento más cercano a la consulta como primer resultado.
  3. De los candidatos restantes, selecciona el siguiente fragmento que sea relevante para la consulta pero diferente en contenido de los fragmentos ya seleccionados.
  4. El paso 3 se repite hasta que se seleccionan k fragmentos.

El resultado es un conjunto de fragmentos que mantienen la relevancia mientras evitan contenido superpuesto.

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

Salida:

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 los datos actuales, no hay una gran diferencia respecto a la búsqueda estándar porque el conjunto de datos es pequeño. Sin embargo, a medida que los documentos crecen a cientos o miles, los fragmentos similares se agrupan con frecuencia al principio de los resultados, y aquí es donde MMR resulta muy útil. fetch_k es el tamaño del grupo de candidatos del que MMR selecciona; empezar con 10–20 es un enfoque habitual.

10.4.3) Cuándo dejar de ajustar

Hay múltiples parámetros que ajustar: k, fetch_k, filtros de metadatos, umbrales de distancia y más. Seguir estas reglas sencillas te ayuda a ajustar de manera eficiente:

  1. Prepara varias preguntas: algunas con contenido relevante en los documentos y otras sin él.
  2. Ejecuta las preguntas y examina directamente los fragmentos recuperados.
  3. Si encuentras un problema, cambia solo un parámetro a la vez y vuelve a probar con las mismas preguntas.

Si las preguntas con contenido relevante producen respuestas correctas y las preguntas sin contenido relevante dan como resultado una abstención, has alcanzado la calidad de referencia. No es fácil conseguir la configuración perfecta desde el principio. Responde a los problemas que descubras durante el uso real y mejora de forma incremental.