Búsqueda híbrida y reclasificación en producción RAG

, recibimos una queja de uno de nuestros ingenieros de plataforma del equipo de infraestructura de que nuestro asistente de conocimiento interno está dando respuestas incorrectas cuando se le pregunta sobre la política de reintento para nuestros consumidores de cola de mensajes. Parece una pregunta sencilla y el asistente debería haber dado una respuesta bien documentada, pero me equivoqué.

El sistema devolvió una respuesta de tres párrafos sobre el retroceso exponencial con fluctuación. Todo era exacto, pero nada era lo que ella pedía. El documento real que quería describía una anulación personalizada que habíamos implementado para un servicio específico después de un incidente de producción que nos afectó seis meses antes. El documento utilizó repetidamente la frase “umbral de cola de mensajes no entregados”. Nuestro modelo de integración había decidido que el “retroceso exponencial” y el “umbral de cola de mensajes no entregados” eran semánticamente lo suficientemente cercanos.

Revisé los registros de recuperación y descubrí que el documento que necesitaba estaba en la posición once en los resultados justo fuera de los diez primeros que se pasaron al modelo. El sistema no había dejado de indexarlo o recuperarlo, pero estaba clasificado por debajo de otros diez documentos.

En este artículo

Por qué la recuperación densa por sí sola no es suficiente BM25: qué es, por qué sigue siendo importante y en qué falla Búsqueda híbrida: combinación de los dos Codificador cruzado Implementación de reclasificación Medición del impacto Filtrado de metadatos Juntándolo todo Una nota final sobre RAGAS

El problema de los vectores densos

Esta es la parte 3 de la serie The RAG for Enterprise y, si se ha perdido las partes anteriores, le recomiendo encarecidamente que las consulte primero: Una guía práctica de la base de conocimientos de RAG for Enterprise y Sus fragmentos fallaron en su RAG en producción.

La recuperación densa funciona convirtiendo texto en vectores de alta dimensión y encontrando los fragmentos cuyos vectores están geométricamente cerca del vector de consulta. Si dos fragmentos de texto significan cosas contextualmente similares, sus vectores deberían terminar cerca en el espacio de incrustación.

Esto es válido para consultas conceptuales como "¿Cuál es nuestro proceso de escalada de incidentes?". Esta consulta recuperará documentos relacionados con incidentes incluso si el documento utiliza palabras como "clasificación de gravedad" en lugar de "intensificación". El modelo de incrustación ha aprendido que estos conceptos están relacionados y la similitud del coseno lo captura.

El problema surge cuando entra en juego el lenguaje técnico específico. Un ingeniero que busca “configuración del umbral de la cola de mensajes no entregados” no está haciendo una pregunta conceptual. Quiere el término exacto del documento exacto, pero la representación incrustada de “umbral de cola de mensajes no entregados” ha sido promediada por todo lo demás en los párrafos circundantes en un único vector denso. Este promedio es una compensación. La misma propiedad que hace que la recuperación densa sea buena para la comparación conceptual la hace poco confiable para la búsqueda de términos exactos.

Los bicodificadores, los modelos que impulsan la recuperación densa, comprimen el significado de un fragmento completo en un vector de tamaño fijo. Esta compresión pierde información. La cuestión no es si alguna información debería perderse o no, sino qué información podemos permitirnos perder.

BM25: qué es y cómo ayuda

Antes de que existiera la recuperación neuronal, la búsqueda estaba dominada por métodos de frecuencia de términos. BM25, o Best Match 25, es uno de los mejores métodos que existen y se utiliza ampliamente en Elasticsearch, Solr, Weaviate y la mayoría de los sistemas de búsqueda de producción.

BM25 califica un documento según una consulta mirándolo desde múltiples direcciones. Sus principales componentes son:

El componente de las FDI, que se pregunta qué tan difícil es encontrar un término en cualquier parte del corpus. Un término que aparece en la mayoría de los documentos no nos dice casi nada sobre relevancia. El “umbral de cola de mensajes no entregados” que aparece sólo en unos pocos documentos es una señal fuerte. Las FDI dan más peso a los términos raros.

El componente de frecuencia del término pregunta con qué frecuencia aparece el término en este documento específico. Aquí el BM25 se vuelve inteligente y aplica una función de saturación en lugar de simplemente usar la frecuencia bruta. Debido a esta función de saturación, la partitura crece rápidamente al principio y luego se aplana.

El componente de normalización de longitud penaliza los documentos más largos. Un documento más largo naturalmente contiene más apariciones de términos. Sin normalización, aparecerían los documentos más largos, que no es lo que queremos.

BM25 no puede encontrar sinónimos, manejar paráfrasis ni entender que la “anulación de configuración” y los “ajustes personalizados” están relacionados. Es un modelo de bolsa de palabras y el orden de las palabras y la semántica no le importan. La frase "la configuración anula el comportamiento de reintento predeterminado" y "el comportamiento de reintento predeterminado se puede anular mediante la configuración" son idénticas a BM25 y esa es a la vez su fortaleza y su limitación.

Búsqueda híbrida: combinación de ambas

Weaviate admite la búsqueda híbrida de forma nativa, combinando puntuaciones de palabras clave BM25 y puntuaciones de similitud de vectores densos en una única lista clasificada a través de un método llamado Relative Score Fusion. El parámetro clave es alfa que controla la mezcla. Un alfa de 1 es búsqueda vectorial pura y un alfa de 0 es BM25 puro. Todo lo que hay entre ellos es una combinación ponderada.

from llama_index.core.retrievers import VectorIndexRetriever from llama_index.core.vector_stores import MetadataFilter, MetadataFilters # Alpha de 0.5 = igual peso a la palabra clave y al recuperador de señales semánticas = VectorIndexRetriever( index=index, similarity_top_k=10, vector_store_query_mode="hybrid", alpha=0.5, vector_store_kwargs={ "filtros": MetadataFilters(filtros=[ MetadataFilter(key="departamento", valor="ingeniería") ]) } )

Esta es la parte fácil, pero decidir qué valor alfa usar es la parte más difícil.

La pregunta es qué tan precisa es su consulta típica. Si la mayoría de sus consultas son conceptuales (“¿cómo funciona nuestro proceso de incidentes?”), un alfa más alto lo inclina hacia la coincidencia semántica. Si las consultas suelen ser búsquedas de términos exactos ("lista de verificación del artículo 17 del RGPD", "umbral DLQ de la política de reintento", "servicio X SLA"), un alfa más bajo le da más peso a la concordancia de palabras clave.

En la práctica, lo mejor es medirlo en lugar de limitarse a adivinar. Así es como sintonicé alfa en nuestro corpus utilizando un conjunto de evaluación etiquetado de 150 pares de documentos de consulta extraídos de nuestro historial de asistencia técnica de TI.

importar ragas desde ragas.metrics importar ContextPrecision, ContextRecall desde conjuntos de datos importar conjunto de datos importar numpy como np def evalua_alpha(alpha_value, eval_queries, ground_truth_docs): resultados =[]para consulta, expected_doc_ids en zip(eval_queries, ground_truth_docs): # Actualizar retriever con nuevo alpha retriever.alpha = alpha_value retrieved_nodes = retriever.retrieve(query) retrieved_ids = [n.node.metadata.get("doc_id") for n in retrieved_nodes] hit = any(doc_id in retrieved_ids for doc_id in wanted_doc_ids) rango = next( (i + 1 para i, doc_id en enumerar(retrieve_ids) si doc_id en doc_ids esperados), Ninguno ) results.append({"hit": hit, "rank": ranking}) hit_rate = np.mean([r["hit"] para r en resultados]) mrr = np.mean([1 / r["rank"] si r["rank"] else 0 para r en resultados]) return {"alpha": alpha_value, "hit_rate": hit_rate, "mrr": mrr} # Prueba en todo el rango alphas = [0.0, 0.25, 0.5, 0.75, 1.0] resultados = [evaluate_alpha(a, eval_queries, ground_truth_docs) for a in alphas] for r in results: print(f"Alpha: {r['alpha']:.2f} | Tasa de aciertos: {r['hit_rate']:.3f} MRR: {r['mrr']:.3f}")

En nuestro corpus de ingeniería, los resultados se veían así (sus números serán diferentes):

Alfa: 0,00 | Tasa de aciertos: 0,71 | MRR: 0,58 # Puro BM25 Alfa: 0,25 | Tasa de aciertos: 0,80 | MRR: 0,66 Alfa: 0,50 | Tasa de aciertos: 0,83 | MRR: 0,69 -> nuestro punto óptimo Alfa: 0,75 | Tasa de aciertos: 0,81 | MRR: 0,67 Alfa: 1,00 | Tasa de aciertos: 0,73 | MRR: 0,61# Puro denso

Los modos puros eran claramente peores que cualquier mezcla. La consulta de la cola de mensajes fallidos que había fallado antes pasó del rango once al rango cuatro con 0,5 alfa, porque la señal BM25 la levantó a pesar de que la señal densa todavía era ambivalente.

Una advertencia importante sobre estos números: si su corpus es principalmente documentación narrativa de formato largo, es posible que un alfa de 0,65 a 0,75 funcione mejor. Si contiene muchos identificadores técnicos exactos, códigos de error y nombres de productos, 0,35 – 0,5 probablemente le resulte más útil. No existe un valor correcto universal. Mida con sus propios datos.

Nota: Si su tasa de aciertos en alfa=0,0 (BM25 puro) es sustancialmente menor que en alfa=1,0 (denso puro), su corpus tiene contenido rico en vocabulario con paráfrasis; en este caso, debe inclinarse hacia un alfa más alto. Si ocurre lo contrario, tus usuarios buscan con términos técnicos precisos, entonces deberías inclinarte por un alfa inferior. Si son similares, comience en 0,5 y ajústelo desde allí.

El problema que la búsqueda híbrida no resuelve

Cuando pasas diez fragmentos recuperados a un LLM, los lee todos. Pero su atención no es uniforme en toda la ventana contextual. Múltiples estudios como el problema “perdido en el medio” han demostrado que los modelos prestan más atención al contexto cerca del principio y al final de su entrada y son menos confiables acerca de la información oculta en el medio. Si el trozo más relevante está en la posición ocho de diez, estás confiando en que el modelo lo saque de una zona relativamente desatendida.

Codificadores cruzados: qué son y por qué funcionan

Un bicodificador procesa la consulta y el documento de forma totalmente independiente. Cuando calcula la similitud, los dos vectores finalizados no conocen el otro.

Un codificador cruzado hace algo diferente. Toma la consulta y un solo documento, los concatena en una secuencia de entrada y los ejecuta juntos a través del transformador. Cada capa de atención del modelo ahora puede permitir que los tokens de consulta atiendan a los tokens de documentos y viceversa. El modelo ve la interacción completa entre los dos antes de producir su puntuación de relevancia.

La diferencia en lo que el modelo puede detectar es sustancial. Considere la consulta "¿Cuál es el límite de reintentos para el servicio de pago?" y dos fragmentos candidatos: "Los límites de reintento varían según el tipo de servicio. Para la mayoría de los servicios internos, el valor predeterminado es tres intentos con retroceso exponencial". y "El consumidor del servicio de pago está configurado con un máximo de cinco reintentos antes de que el mensaje se enrute a la cola de mensajes fallidos".

Un bicodificador podría clasificar el fragmento A más alto porque el “límite de reintentos” y “los límites de reintentos varían según el tipo de servicio” son semánticamente cercanos. Un codificador cruzado que lee ambos textos juntos se da cuenta inmediatamente de que el fragmento B contiene el número real del servicio específico sobre el que pregunta la consulta. La atención cruzada entre "servicio de pago" en la consulta y "consumidor de servicios de pago" en el documento proporciona evidencia directa de que el fragmento B es la respuesta correcta.

Esta es la razón por la que los codificadores cruzados son sustancialmente más precisos que los bicodificadores en las tareas de clasificación. La limitación es que no pueden precalcular nada. Un bicodificador puede preincrustar todos sus documentos en el momento del índice y luego simplemente incrustar la consulta en el momento de la búsqueda, realizando así dos operaciones. Un codificador cruzado debe procesar cada par (consulta, documento) en el momento de la consulta. Por un millón de documentos, eso equivale a un millón de pases hacia adelante. No puede ejecutar un codificador cruzado en todo su corpus.

La solución es el embudo de dos etapas: use el codificador bidireccional para lanzar una red amplia y recuperar los N candidatos principales rápidamente, luego use el codificador cruzado para volver a calificar solo esos N candidatos con precisión.

En la práctica, el codificador cruzado agrega aproximadamente entre 80 y 120 ms a la latencia de consulta al reclasificar 20 documentos con un modelo liviano en la CPU.

Implementación de reclasificación

Usamos ms-marco-MiniLM-L-6-v2 de la biblioteca de transformadores de oraciones. Fue entrenado en MS MARCO, un conjunto de datos de respuesta a preguntas a gran escala, y es el codificador cruzado de código abierto más utilizado para tareas de recuperación generales. Para contenido de dominio específico, puede realizar ajustes en sus propios pares etiquetados, pero el modelo general es un punto de partida razonable.

de sentencia_transformers import CrossEncoder reranker = CrossEncoder("cross-encoder/ms-marco-MiniLM-L-6-v2") def rerank_nodes(query: str, retrieved_nodes: list, top_n: int = 5) -> list: """ Toma una consulta y una lista de objetos LlamaIndex NodeWithScore, devuelve top_n nodos reclasificados por puntuación de codificador cruzado. """ # Build (query, chunk_text) pares para los pares de codificadores cruzados = [(query, node.node.get_content()) for node in retrieved_nodes] # Califica todos los pares y devuelve una lista de puntuaciones flotantes = reranker.predict(pairs) # Adjunta puntuaciones a los nodos y ordena por nodo, puntúa en zip(retried_nodes, puntuaciones): node.score = float(score) reranked = sorted(retrieved_nodes, key=lambda n: n.score, reverse=True) return reranked[:top_n] # Recuperación completa + reclasificación de la consulta de canalización = "¿Cuál es el límite de reintentos para la cola de mensajes fallidos del servicio de pago?" # Etapa 1: recuperar más de lo que necesita (20 candidatos) retrieved = retriever.retrieve(query) # top_k=20 en la configuración del recuperador # Etapa 2: reclasificar hasta 5 reranked = rerank_nodes(consulta, recuperado, top_n=5) # Inspeccionar qué pasó con las clasificaciones de los documentos print("Después de reclasificar:") for i, node in enumerate(reranked): source = node.node.metadata.get("fuente", "desconocido") print(f" Rango {i+1} | Puntuación: {node.score:.4f} | Fuente: {fuente}")

LlamaIndex también tiene un postprocesador nativo SentenceTransformerRerank que se integra limpiamente en su canal de consultas:

de llama_index.postprocessor.sbert_rerank importar SentenceTransformerRerank de llama_index.core importar QueryBundle de llama_index.core.query_engine importar RetrieverQueryEngine reranker_postprocessor = SentenceTransformerRerank( model="cross-encoder/ms-marco-MiniLM-L-6-v2", top_n=5 ) query_engine = RetrieverQueryEngine.from_args( retriever=retriever, node_postprocessors=[reranker_postprocessor] ) respuesta = query_engine.query( "¿Cuál es el límite de reintentos para la cola de mensajes fallidos del servicio de pago?")

top_n=5 aquí le dice al reclasificador cuántos documentos pasar al paso de generación. Aumentar esto le da al LLM más contexto pero aumenta tanto la latencia como el riesgo de ruido. En nuestro sistema, 5 era el punto ideal, contexto suficiente para preguntas de varias partes sin saturar el mensaje.

Nota: Registre la correlación de clasificación entre el orden de recuperación y el orden de reclasificación en una muestra de consultas. Si el codificador cruzado rara vez cambia la clasificación, su bicodificador ya está haciendo un buen trabajo y la reclasificación agrega poco valor, o su modelo de codificador cruzado es demasiado genérico para su dominio.

Medir el impacto

Estamos comparando las tres cosas juntas: recuperación densa pura (nuestra línea de base), búsqueda híbrida y reclasificación híbrida +. Ejecuté nuestro conjunto de evaluación de 150 consultas en las tres configuraciones.

desde ragas importar evaluar desde ragas.metrics importar (ContextPrecision, ContextRecall, AnswerRelevancy, Faithfulness) desde conjuntos de datos importar Conjunto de datos def build_ragas_dataset(consultas, contextos_recuperados, verdades_terrestres, respuestas_generadas): return Dataset.from_dict({ "pregunta": consultas, "contextos": contextos_recuperados, # lista de listas de cadenas "respuesta": respuestas_generadas, "ground_truth": ground_truths }) # Construya conjuntos de datos para cada configuración, luego evalúe baseline_dataset = build_ragas_dataset( consultas, baseline_contexts, ground_truths, baseline_answers ) hybrid_dataset = build_ragas_dataset( consultas, hybrid_contexts, ground_truths, hybrid_answers ) hybrid_rerank_dataset = build_ragas_dataset( consultas, hybrid_rerank_contexts, ground_truths, hybrid_rerank_answers ) métricas = [ContextPrecision(), ContextRecall(), AnswerRelevancy(), Faithfulness()] baseline_result = evaluar(baseline_dataset, métricas=métricas) hybrid_result = evaluar(hybrid_dataset, métricas=métricas) hybrid_rerank_result = evaluar(hybrid_rerank_dataset, métricas = métricas)

Los resultados de nuestro corpus de ingeniería (estos son números reales de nuestra evaluación interna):

Configuración | Precisión del contexto | Recordatorio de contexto | Relevancia de la respuesta | Fidelidad —————————-|——————-|—————-|——————|————- Sólo denso (alfa=1.0) | 0,61 | 0,74 | 0,78 | 0,82 Híbrido (alfa=0,5) | 0,71 | 0,83 | 0,81 | 0,85 Híbrido + Reclasificación (top 5) | 0,79 | 0,84 | 0,87 | 0,89

Algunas cosas que vale la pena señalar:

La recuperación del contexto mejoró sustancialmente de densa a híbrida (0,74 a 0,83), y luego apenas se movió con la reclasificación (0,84). Sucedió porque la recuperación mide si se recuperó o no el documento correcto. Reclasificar no ayuda a recordar porque funciona dentro del conjunto ya recuperado. La mejora híbrida en el retiro fue que el componente BM25 obtuvo coincidencias de términos exactos que el modelo denso había clasificado demasiado bajo.

La precisión del contexto aumentó significativamente con la reclasificación (0,71 a 0,79). La precisión mide qué proporción de los fragmentos recuperados son realmente relevantes. Reclasificar es hacer exactamente lo que debería, sacar el material irrelevante del top 5 y pasar al paso de generación.

La relevancia de las respuestas y la fidelidad mejoraron en cada etapa. Estas son métricas de extremo a extremo y reflejan el beneficio acumulativo de una mejor recuperación que fluye hacia una mejor generación.

Filtrado de metadatos

El filtrado de metadatos le permite limitar el espacio de recuperación antes de ejecutar la búsqueda vectorial. Si un usuario está en el departamento de ingeniería preguntando sobre los procesos de implementación, puede restringir la búsqueda a documentos de ingeniería incluso antes de que comience la comparación de incrustación. El resultado no es sólo una recuperación más rápida, sino también un grupo de candidatos más pequeño y relevante que hace que tanto BM25 como la puntuación densa sean más precisas.

from llama_index.core.vector_stores import (MetadataFilter, MetadataFilters, FilterOperator, FilterCondition) # Aplicar filtros según el contexto del usuario def build_retriever_with_filters( index, user_department: str, max_doc_age_days: int = 365, Classification_level: str = "internal"): from datetime import datetime, timedelta cutoff_date = (datetime.now() – timedelta (días = max_doc_age_days)). operator=FilterOperator.NE # Excluir confidencial a menos que esté autorizado ), ], condition=FilterCondition.AND ) return VectorIndexRetriever( index=index, similarity_top_k=20, vector_store_query_mode="hybrid", alpha=0.5, vector_store_kwargs={"filters": filters} )

Un runbook para un servicio que fue dado de baja hace dieciocho meses no sólo es inútil sino que también es peligroso si el sistema lo presenta como una respuesta segura a una pregunta sobre la infraestructura actual. Filtrar por update_at puede ayudarnos en este caso al no dejar que la información antigua salga a la luz.

Un modo de error a tener en cuenta: si su filtro es demasiado limitado y excluye el documento que realmente contiene la respuesta, obtendrá una respuesta incorrecta entregada con confianza de los documentos restantes. El enfoque correcto es comenzar con valores predeterminados sensatos (filtro de departamento, tal vez un filtro de fecha) y agregar filtros más estrictos solo para casos de uso documentados en los que haya verificado que ayudan en consultas reales.

El oleoducto completo

Así es como los tres componentes encajan en un flujo de recuperación (para demostración):

de llama_index.core.query_engine importar RetrieverQueryEngine de llama_index.postprocessor.sbert_rerank importar SentenceTransformerRerank de llama_index.core.response_synthesizers importar get_response_synthesizer de llama_index.llms.ollama importar Ollama # LLM (local, vía Ollama) llm = Ollama(model="llama3", request_timeout=120.0) # Etapa 1: Recuperador híbrido con filtros de metadatos retriever = build_retriever_with_filters( index=index, user_department="engineering", max_doc_age_days=365 ) # Etapa 2: Reclasificador de codificador cruzado reranker = SentenceTransformerRerank( model="cross-encoder/ms-marco-MiniLM-L-6-v2", top_n=5 ) # Etapa 3: Sintetizador de respuesta synthesizer = get_response_synthesizer( llm=llm, Response_mode="compact", # Fusiona varios fragmentos en un mensaje use_async=True ) # Ensamble el motor de consulta query_engine = RetrieverQueryEngine( retriever=retriever, node_postprocessors=[reranker], Response_synthesizer=synthesizer ) # Consultar respuesta = query_engine.query( "¿Cuál es el límite de reintentos para la cola de mensajes fallidos del servicio de pago?" ) print(response.response) # Atribución de fuente: importante para casos de uso empresarial para el nodo en respuesta.source_nodes: print(f" Fuente: {node.node.metadata.get('source')} | Puntuación: {nodo.puntuación:.4f}")

Una cosa a tener en cuenta es la configuración de Response_mode="compact": este modo fusiona múltiples fragmentos recuperados en una única llamada rápida en lugar de realizar una llamada LLM por fragmento. Para cinco fragmentos, reduce sustancialmente la latencia y mantiene manejable el uso de la ventana de contexto. Si está utilizando un modelo con un límite de contexto más pequeño o sus fragmentos son largos, Response_mode="tree_summarise" es una alternativa que se procesa en etapas.

Después de realizar todos estos cambios y pasarlo a producción, nuestro asistente de conocimiento interno pudo responder la pregunta sobre la política de reintento para nuestros consumidores de cola de mensajes con los datos correctos.

Una nota final sobre RAGAS

Una puntuación RAGAS no es una métrica de calidad del producto. Es un instrumento de diagnóstico. Una precisión de contexto de 0,79 indica que, en promedio, el 79 % de lo que se pasa al modelo es relevante. No le dice que el 79% de sus usuarios obtienen respuestas correctas.

RAGAS es realmente útil para medir el impacto de los cambios. Cuando introdujiste la búsqueda híbrida, ¿aumentó la recuperación de contexto? Cuando agregó la reclasificación, ¿mejoró la precisión del contexto sin destruir la recuperación? Éstas son las preguntas que puede responder de forma fiable. Úselo como un instrumento de antes/después cada vez que cambie algo en el proceso de recuperación y realice un seguimiento de los números a lo largo del tiempo a medida que evoluciona su corpus.

Donde esto deja la serie

En el primer artículo, construimos el proceso de indexación: ingerir documentos de Confluence y directorios locales con LlamaIndex, fragmentarlos, incrustarlos con BGE-large y almacenarlos en Weaviate. En el segundo, profundizamos en las estrategias de fragmentación y aprendimos que la forma de los fragmentos determina lo que el sistema de recuperación puede y no puede encontrar.

En este artículo, hemos abordado directamente el problema de la calidad de la recuperación. La búsqueda híbrida nos brindó mejoras significativas en la recuperación de consultas con términos exactos. La reclasificación del codificador cruzado mejoró la precisión de lo que pasamos al LLM. El filtrado de metadatos mantuvo los documentos obsoletos e irrelevantes fuera del grupo de candidatos incluso antes de que se ejecutaran los costosos pasos de puntuación. Las cifras de RAGAS mostraron una mejora acumulativa en cada etapa.

Estén atentos al próximo artículo de esta serie, donde resolveremos otra pregunta que desafía a los ingenieros en la construcción de sistemas RAG de producción.