RAG en tiempo de consulta con diez millones de documentos: embudos, enrutadores, agentes y evaluaciones
RAG con diez millones de documentos: un sistema de recuperación en menos de 200 ms que incluye filtros SQL, búsqueda híbrida y reclasificación, además de enrutamiento de consultas, evaluaciones y un script de Qdrant.
Fuentes: Scaling RAG to 10 Million Documents Part 2: Optimizing retrieval and generation
Insertar diez millones de documentos en una base de datos vectorial es solo la mitad del trabajo. La primera mitad de este tema trató sobre la ingesta: routers de documentos como Docling, Unstructured y Apache Tika, particionamiento según el formato, indexación delta con MinHash, y un diseño de almacenamiento dual que indexa archivos reales sin dañar los datos ni elevar los costos.
La segunda mitad se refiere al tiempo de consulta. Millones de fragmentos incrustados no sirven de nada si la recuperación tarda ocho segundos, se bloquea por códigos de error específicos, expone datos confidenciales de nóminas, o permite la inyección de comandos ocultos en algún archivo antiguo. Lo que sigue aborda un sistema de recuperación que responde en menos de 200 ms, triaje inteligente de consultas, orquestación por agentes, evaluación continua y un script completo.
1. Un sistema de recuperación en múltiples etapas
Un tutorial típico indica que se debe incrustar la consulta del usuario y realizar una búsqueda de similitud con top_k=5 en toda la base de datos. Con diez millones de documentos, este enfoque genera dos problemas importantes.
- Los embeddings densos capturan bien el significado abstracto, pero fallan con palabras clave exactas, números alfanuméricos y códigos de error. Una búsqueda de
Error 504 Gateway Timeoutpodría devolver discusiones generales sobre tiempos de espera y no la página específica de solución de problemas. - Evaluar cada candidato con un modelo de relevancia complejo es demasiado lento para un uso interactivo.
Por lo tanto, los sistemas en producción tratan la recuperación de información como un embudo que reduce gradualmente los candidatos:
[ 10,000,000 Total Document Chunks ]
│
▼
[ Step 1: SQL Pre-Filter ] ────────► Filter by Tenant / Dept / Role / Region
│
▼
[ ~50,000 Candidates ]
│
▼
[ Step 2: Hybrid Search ] ─────────► Dense Vectors (Qdrant) + Sparse BM25
│
▼
[ Top 100 Candidates ]
│
▼
[ Step 3: Cross-Encoder ] ───────► Cohere Rerank / BGE-Reranker
│
▼
[ Final Top 5 Chunks ] ──────────► Passed to LLM Context Window
Etapa 1: prefiltrado relacional
Antes de aplicar cualquier cálculo de alta dimensión, los filtros de metadatos basados en el rol del usuario, el departamento o el estado del documento reducen el espacio de búsqueda. Al pasar de diez millones de fragmentos a quizás 50,000, se disminuye la latencia y, al mismo tiempo, se aplica el control de acceso:
from qdrant_client.models import Filter, FieldCondition, MatchValue
# Restrict search space by user session permissions before distance scoring
user_access_filter = Filter(
must=[
FieldCondition(key="department", match=MatchValue(value="Engineering")),
FieldCondition(key="is_active", match=MatchValue(value=True))
]
)
Etapa 2: búsqueda híbrida
Las búsquedas reales existen en dos variantes complementarias, y es útil comprender ambas antes de analizar el algoritmo de fusión.
La búsqueda densa o semántica convierte el texto en representaciones vectoriales en la base de datos vectorial, colocando cerca a las palabras con significados similares. Funciona bien con paráfrasis, sinónimos y expresiones conversacionales, aunque puede pasar por alto una palabra clave exacta y devolver documentos que solo comparten un concepto.
La búsqueda dispersa o por palabras clave, con algoritmos como BM25 o el más antiguo TF-IDF, califica un término según la frecuencia con la que aparece en un documento en relación con lo raro que es en todo el corpus. Detecta palabras exactas de manera fiable, pero no tiene conocimiento alguno sobre su significado.
La respuesta tentadora es ejecutar ambos métodos y sumar sus puntuaciones, y aquí es donde entra en juego la Fusión de Rangos Recíprocos. RRF combina varias listas ordenadas en una sola lista unificada sin necesidad de normalizar las puntuaciones brutas. Un documento que ocupa una posición cercana a la cima en ambos rankings termina en la parte superior de la lista combinada.
Etapa 3: reordenamiento con codificador cruzado
La búsqueda híbrida genera rápidamente alrededor de cien candidatos, pero ordenarlos únicamente por distancia vectorial aún puede hacer que aparezcan resultados irrelevantes. Dos formas de codificación explican cómo solucionar esto.
Un bi-codificador es el método utilizado en la búsqueda vectorial tradicional. Los documentos se convierten en vectores durante su procesamiento, la consulta del usuario se convierte en otro vector en el momento de la búsqueda, y se comparan mediante conceptos geométricos simples como la similitud coseno o el producto escalar. Este método es rápido, pero solo ofrece una precisión moderada.
Un codificador cruzado lee la consulta y un documento juntos y genera una puntuación que indica cuán relevante es ese documento para dicha consulta. Es exhaustivo y, por lo tanto, lento, ya que calificar un millón de documentos para una sola búsqueda tomaría horas. La solución es combinarlos. El codificador binario identifica los 100 candidatos principales, y el codificador cruzado reordena solo esos para seleccionar los diez mejores, lo cual tarda menos de 100 ms.
2. Un enrutador de consultas condicionales
La mayoría de las interfaces de chat son un cuadro de texto simple, por lo que los usuarios pueden escribir lo que quieran. Un mensaje como “resuma nuestra conversación hasta ahora” o “traduzca los resultados al alemán” no necesita en absoluto una búsqueda en documentos, y enviarlo a través de todo el proceso desperdicia tiempo y dinero.
Un enrutador condicional se encuentra en la entrada como un clasificador rápido que decide a dónde ir cada mensaje:
User Query
│
▼
[ Intent Classifier / Router ]
│
├── Simple Math / Logic ──────────► Direct Calculator / Python REPL
├── Conversational / Follow-up ───► Direct LLM Memory Context
└── Domain Knowledge Request ─────► Full Hybrid RAG Pipeline
Conceptualmente, una instrucción breve pide al modelo que clasifique la consulta en una de varias opciones, como recuperarla de la documentación interna o dar una respuesta directa:
# Conceptual Router Pattern
def route_query(user_query: str) -> str:
prompt = f"""Classify the user query into one of these routes:
- RETRIEVE: Needs internal company documentation/database lookup.
- COMPUTE: Pure math, calculation, or logic.
- DIRECT: Conversational, greetings, or basic language rewrites.
Query: {user_query}
Classification:"""
# Run a fast, lightweight classifier (or small local SLM)
decision = fast_classifier(prompt).strip()
return decision
3. Más allá de una sola búsqueda: orquestación y retroalimentación
Muchas preguntas reales requieren varios pasos de razonamiento. Considere una solicitud para comparar las cifras de ventas de APAC en 2025 con las pautas de cumplimiento de 2024 y señalar las discrepancias. Esto se divide en tres subtareas: encontrar y extraer los datos de ventas de APAC de 2025, obtener las pautas de cumplimiento de 2024 y buscar conflictos entre ellas.
Un sistema de una sola consulta falla en este caso porque intenta responder todo en una sola solicitud. El RAG agente aborda este problema mediante marcos de orquestación como LangGraph o CrewAI, que dividen el problema en tareas especializadas coordinadas. Un planificador puede crear varios agentes, cada uno utilizando el mismo pipeline de recuperación para recopilar su parte de las pruebas:
[ Orchestrator / Planner ]
│
┌─────────────────┴─────────────────┐
▼ ▼
[ Researcher Agent ] [ Compliance Agent ]
• Retrieves 2025 Sales Data • Retrieves 2024 Regulations
• Extracts regional tables • Parses policy constraints
│ │
└─────────────────┬─────────────────┘
▼
[ Synthesis Agent ]
• Reconciles numbers
• Validates output consistency
│
Confidence Score Check
│ │
[ Low Confidence ] [ High Confidence ]
│ │
▼ ▼
Loop back & refine Final Guardrail Validation
Autocorrección
Cuando la recuperación de información devuelve fragmentos con puntuaciones de similitud bajas, o cuando el agente sintetizador detecta una contradicción, este debe reaccionar. Rechaza el texto de calidad deficiente, vuelve a redactar la consulta ampliando abreviaturas o probando sinónimos, y consulta nuevamente al motor de recuperación, en lugar de ofrecer una respuesta genérica inventada. Los agentes organizan y toman decisiones, pero sin un pipeline RAG sólido como base no tienen nada con qué trabajar.
4. Evaluación continua
Un sistema de producción necesita una evaluación continua a través de marcos automatizados como Ragas, TruLens o DeepEval. Estos analizan varias dimensiones:
┌──► Faithfulness (Is the answer grounded in the retrieved chunks?)
RAG Evaluation ─┼──► Answer Relevance (Did it actually answer the user's prompt?)
├──► Context Recall (Did retrieval find all necessary reference chunks?)
└──► System Latency & Token Cost (Is it cost-effective at scale?)
Más allá de las métricas operativas, varios indicadores de nivel de servicio miden qué tan bien funciona la recuperación de información:
- La fidelidad corresponde a la proporción de las afirmaciones en la respuesta que está respaldada por el contexto recuperado, y sirve como verificación contra las alucinaciones.
- La precisión del contexto es la proporción de los fragmentos recuperados, entre todos los incluidos en la solicitud, que son realmente relevantes.
- El recuerdo del contexto verifica si se recuperó cada hecho verídico necesario para responder a la pregunta.
- La latencia, tanto integral como por etapa, abarca los procesos de enrutamiento, filtrado, búsqueda híbrida, reclasificación y generación.
Cada indicador requiere un valor de referencia para poder evaluar los acuerdos de calidad y rendimiento.
5. Un script completo de recuperación y generación
El script en Python a continuación se ocupa de la fase de ejecución. Aplica un prefiltrado de metadatos, recupera vectores de Qdrant, reúne el contexto y llama a un servidor local de LM Studio para realizar la inferencia:
from openai import OpenAI
from qdrant_client import QdrantClient
from qdrant_client.models import Filter, FieldCondition, MatchValue
# 1. Initialize Clients
# Pointing to local LM Studio running on port 8080
ai_client = OpenAI(base_url="http://127.0.0.1:8080/v1", api_key="lm-studio")
qdrant_client = QdrantClient(url="http://localhost:6333")
COLLECTION_NAME = "enterprise_knowledge_base"
EMBEDDING_MODEL = "nomic-ai/nomic-embed-text-v1.5"
def retrieve_and_generate(user_query: str, user_department: str) -> str:
print(f"\n🔍 Processing query: \"{user_query}\" for department: [{user_department}]")
# 2. Vectorize the User Query
query_resp = ai_client.embeddings.create(
input=[user_query],
model=EMBEDDING_MODEL
)
query_vector = query_resp.data[0].embedding
# 3. Stage 1 & 2: SQL Pre-Filter + Vector Search
# Filter by user department and active document status
access_filter = Filter(
must=[
FieldCondition(key="department", match=MatchValue(value=user_department))
]
)
search_results = qdrant_client.search(
collection_name=COLLECTION_NAME,
query_vector=query_vector,
query_filter=access_filter,
limit=3
)
if not search_results:
return "No relevant or authorized documents found."
# 4. Context Assembly with Breadcrumbs
context_blocks = []
for hit in search_results:
breadcrumb = hit.payload.get("breadcrumb", "General")
text = hit.payload.get("text", "")
context_blocks.append(f"[{breadcrumb}]\n{text}")
full_context = "\n\n---\n\n".join(context_blocks)
# 5. Generation via Local LLM
system_prompt = (
"You are an enterprise technical assistant. "
"Answer the user query strictly using the provided context. "
"If the context does not contain the answer, explicitly state that you do not know.\n\n"
f"Context:\n{full_context}"
)
completion = ai_client.chat.completions.create(
model="local-model",
messages=[
{"role": "system", "content": system_prompt},
{"role": "user", "content": user_query}
],
temperature=0.1
)
return completion.choices[0].message.content
# Example Execution
if __name__ == "__main__":
response = retrieve_and_generate(
user_query="How do I enable TLS 1.3 in config.yaml?",
user_department="Engineering"
)
print("\n🤖 Final Answer:\n", response)
La forma de una arquitectura de producción
Pasar de un prototipo a un sistema que gestiona millones de documentos empresariales implica tratar RAG como una disciplina de ingeniería integral y no como una simple llamada a una biblioteca. Cuatro hábitos lo resumen:
- Procesar los datos de manera ordenada. La estructura es más importante que la ingeniosidad; por eso se deben utilizar analizadores especializados, mantener rastros de la estructura y evitar volver a indexar archivos que no han cambiado mediante hashing delta.
- Filtrar temprano. Los alcances de seguridad y los metadatos relacionales deben procesarse en un paso SQL que se ejecute antes de cualquier cálculo vectorial costoso.
- Combinar estrategias de búsqueda. Alinear vectores densos con coincidencias de palabras clave BM25 y un reordenador basado en codificador cruzado.
- Respetar los límites. Dirigir el flujo de forma condicional para evitar búsquedas innecesarias, aplicar medidas de control de entrada y salida, y mantener a los humanos involucrados en acciones de alto riesgo.
Leer más
- Diseñando RAG para 100 millones de documentos: particionamiento, búsqueda híbrida y costos — Qué cambia cuando un sistema RAG crece hasta alcanzar miles de millones de fragmentos: ingestión asíncrona, enrutamiento, particionamiento, recuperación híbrida, reclasificación, autorización, caché y control de costos.
- Cómo depurar búsquedas vacías cuando un sistema RAG no devuelve resultados — Una lista de verificación paso a paso para los sistemas RAG que no devuelven fragmentos útiles: ingestión, umbrales de puntuación, filtros, reescritura de consultas, búsqueda híbrida, reclasificación, soluciones alternativas y telemetría.