RAG híbrido para textos legales: fragmentación, BM25, BGE y reclasificación
Un pipeline legal RAG para la Constitución de la India: particionamiento en bloques padre-hijo, recuperación con BM25 más BGE, fusión de rangos, reclasificación con codificador cruzado y expansión de consultas, probado en pruebas de referencia.
Fuentes: Beyond Basic RAG: Building a Production-Style Legal Research Assistant (Part-I)
El texto legal representa una gran dificultad para cualquier sistema de recuperación de información. Tomemos como ejemplo la Constitución de la India: cuenta con cientos de artículos y cláusulas, además de disposiciones adicionales, anexos, enmiendas, tablas y una red de referencias cruzadas. El pasaje que responde a una pregunta puede ser una sola oración, pero su correcta interpretación puede depender del artículo circundante, de una excepción relacionada o de otra disposición a la que haga referencia. Por lo tanto, un sistema útil debe equilibrar la precisión y el contexto, encontrando el pasaje exacto sin separarlo del texto que le da significado.
Muchas implementaciones iniciales de generación mejorada por recuperación de información (RAG) siguen una fórmula sencilla: extraer texto de un PDF, dividirlo en fragmentos de tamaño fijo, incrustarlos y devolver las coincidencias más cercanas. Eso es un buen punto de partida, pero los sistemas en producción suelen requerir más. Deben mantener la estructura del documento, soportar tanto búsquedas exactas como semánticas, reordenar las opciones disponibles, recuperar el contexto circundante, rastrear cada resultado hasta su fuente y determinar si realmente se obtuvo la evidencia necesaria.
Este texto describe un proyecto de investigación que reproduce dicho flujo de trabajo de tipo de producción de forma que se pueda inspeccionar paso a paso, utilizando la Constitución de la India como primer corpus, ya que su estructura pone de manifiesto numerosas debilidades de la búsqueda vectorial simple. No se presenta como un producto final, sino como un intento por estudiar cómo se integran los componentes habituales de un pipeline RAG de producción, dónde fallan y cómo la evaluación puede orientar la siguiente decisión de diseño.
1. Ingestión: de PDF legal a contenido estructurado
Antes de poder considerar la división en fragmentos, los embeddings o la recuperación de información, el PDF de origen debe convertirse en contenido que el software pueda procesar de manera fiable. Un PDF registra cómo se ve un documento, no necesariamente cómo debería leerlo un programa. Los títulos, cláusulas, notas al pie, listas, tablas y los límites entre páginas pueden ser evidentes para una persona, pero aún así, al extraer el contenido de forma básica, aparecen como texto desvinculado. Si se pierde esa estructura en la fase de ingreso de datos, las etapas posteriores generarán fragmentos con un orden de lectura incorrecto, títulos separados o tablas dañadas.
El proyecto utilizó MinerU para convertir la Constitución a Markdown, manteniendo al mismo tiempo la importante información sobre el formato. Se eligió porque, según la comparación del autor, gestionaba las tablas y listas de manera más fiable que el otro método de extracción probado. El objetivo no era solo recopilar cada palabra, sino también preservar las relaciones entre ellas. El contenido extraído conservó:
- cómo se anidan los títulos,
- dónde comienza y termina cada párrafo y cláusula,
- listas con viñetas y numeradas,
- tablas,
- la página de la que proviene cada elemento, y
- notas al pie junto con la estructura que las rodea.
Markdown resultó adecuado como formato intermedio para esta función. Las personas pueden leerlo durante la inspección manual, y contiene marcadores estructurales que Python puede procesar. Un título como ## Derechos fundamentales puede servir posteriormente para guiar la creación de bloques parentales, mientras que las listas y tablas pueden ser reconocidas y tratadas como bloques separados.
La extracción se realizó en Google Colab y no en una máquina local. El OCR y el análisis de maquetación pueden ser recursos intensivos, especialmente para un documento largo lleno de tablas y formato complejo; una laptop con hardware limitado tuvo dificultades para manejar la carga de trabajo. Colab ofreció un entorno aislado más adecuado con mayor capacidad de procesamiento, por lo que merece la pena considerarlo en esta etapa si su hardware local es modesto. La orden utilizada en el cuaderno de notas fue:
!mineru -p c.pdf -o output --dump-content-list -b pipeline -l en
2. Fragmentación
Con la Constitución en formato Markdown estructurado, la siguiente pregunta fue cómo dividirla para su recuperación. La respuesta común es la fragmentación de tamaño fijo: cortar después de un número determinado de caracteres o tokens, a menudo con cierta superposición. Es sencilla y funciona de manera aceptable para textos en prosa uniformes, pero los documentos legales están organizados por significado y no por longitud. Un límite fijo puede:
- separar un título del artículo que se encuentra debajo de él,
- cortar una cláusula del requisito que la condiciona,
- dividir una lista por la mitad de sus elementos,
- fragmentar una tabla en partes incompletas, y
- combinar artículos no relacionados solo porque caben en una misma ventana de token.
Los fragmentos más grandes conservan más contexto, pero hacen que la recuperación sea menos precisa. Los fragmentos más pequeños permiten una correspondencia mejor, aunque las condiciones circundantes importantes pueden desaparecer. Por lo tanto, el proyecto utilizó una estrategia de dos niveles:
Structured document
↓
Heading-aware parent chunks
↓
Semantic child chunks
Por qué ayudan los dos niveles
La búsqueda se realiza sobre los niños pequeños, ya que su contenido limitado proporciona a BM25 señales más claras y precisas para la recuperación de información. Una vez seleccionado un niño, el parent_id que lleva apunta a la sección padre más amplia:
Query
↓
Retrieve focused child
↓
Read parent_id
↓
Return complete parent context
Eso divide las responsabilidades en dos. Los fragmentos hijos proporcionan precisión en la recuperación, mientras que los fragmentos padres garantizan la completitud del contexto.
Fragmentos padres conscientes de los encabezados
El algoritmo de fragmentación en padres se basa en dos elementos generados durante la ingestión:
data/processed/cr.md
data/extracted/auto/blockpage.json
El archivo Markdown contiene el texto limpio y su estructura visible. El archivo blockpage.json alberga los bloques de layout ordenados generados durante la extracción, incluyendo información de los niveles de encabezado. Markdown sigue siendo la fuente principal del texto padre, mientras que el JSON funciona como una referencia estructural ordenada para vincular los encabezados de Markdown con el documento extraído. La implementación se encuentra en un único script:
code/chunking_parent.py
El algoritmo funciona en un orden fijo.
Paso 1: cargar ambos archivos. El script lee el Markdown ya procesado y también el archivo JSON de los bloques de diseño:
md_text = MARKDOWN_PATH.read_text(encoding="utf-8")
with BLOCKPAGE_PATH.open("r", encoding="utf-8") as file:
blockpage = json.load(file)
Al utilizar ambos, se impide que el procesador de texto trate el Markdown como texto plano y sin estructura. El Markdown proporciona el contenido, mientras que el JSON ayuda a mantener el orden de los títulos encontrados durante la extracción.
Paso 2: dividir el Markdown en bloques. Las líneas en blanco sirven como separador:
for raw_block in md_text.split("\n\n"):
Cada bloque se clasifica luego en uno de varios tipos:
heading
paragraph
list
table
superscript
blank
El texto que comienza con #, ## o otro nivel de encabezado, por ejemplo, se reconoce como un encabezado:
HEADING_RE = re.compile(r"^(#{1,6})\s+")
Las listas, tablas y bloques con superíndices tienen sus propias reglas de detección, lo que los mantiene vinculados a la sección a la que pertenecen.
Paso 3: seguir el encabezado activo. Cuando aparece un encabezado, su texto se agrega al heading_path actual. La secuencia también busca el encabezado extraído correspondiente en blockpage.json, recorriendo el JSON de forma secuencial y sin volver a escanear desde el principio cada vez:
def find_heading(heading_text: str):
nonlocal search_pos
for index in range(search_pos, len(blockpage)):
block = blockpage[index] if (
block.get("type") == "text"
and block.get("text") == heading_text
and "text_level" in block
):
search_pos = index + 1
return block
Al escanear hacia adelante, los títulos se mantienen en su orden original y se evita encontrar un título anterior idéntico.
Paso 4: recopilar contenido relacionado. Los párrafos, listas, tablas y bloques similares se añaden a la pila padre actual. Cuando llega un nuevo título después de que ya se haya recopilado el texto principal, la pila padre actual se cierra y se inicia otra nueva. Conceptualmente, se ve así:
Heading
├── Paragraph
├── Clause
├── List
└── Table
↓
Parent chunk
Paso 5: asignar metadatos estables. Cada elemento padre completado se almacena con un ID secuencial, un ID de documento, la ruta del encabezado y el texto completo:
{
"parent_id": f"parent_{len(chunks):04d}",
"doc_id": DOC_ID,
"heading_path": heading_path.copy(),
"text": "\n\n".join(stack),
}
Un padre simplificado se ve así:
{
"parent_id": "parent_0164",
"doc_id": "constitution_of_india",
"heading_path": ["PART XII"],
"text": "# PART XII\n\nFINANCE, PROPERTY, CONTRACTS AND SUITS..."
}
Un padre soltero puede acogerse a varias disposiciones relacionadas, por ejemplo:
PART XII — Finance
Article 264 — Interpretation
Article 265 — Taxes not to be imposed without authority of law
Article 266 — Consolidated Funds and public accounts
Article 267 — Contingency Fund
El proceso generó 333 bloques padre.
Bloques hijos semánticos
Los bloques padre mantienen el contexto intacto, pero algunos son demasiado amplios para una coincidencia precisa, por lo que los más grandes se dividen en bloques hijos semánticos más pequeños. Ese trabajo se encuentra en un cuaderno de notas:
code/semantic-children.ipynb
Los textos padres cortos, según el umbral aproximado de 400 tokens del cuaderno de notas, se mantienen intactos como un único hijo, ya que dividir algo ya lo suficientemente compacto como para funcionar como una unidad definida no aporta beneficio alguno. Los textos más largos son procesados por un herramienta de fragmentación que respeta la estructura y compara el significado.
Paso 1: dividir el texto padre en bloques. El texto padre se separa en encabezados, párrafos, listas y tablas. Las tablas HTML se reconocen mediante su propio patrón:
table_pattern = re.compile(
r"(<table[\s\S]*?</table>)",
re.IGNORECASE
)
El texto ordinario se divide en líneas en blanco y se clasifica mediante encabezados y marcadores de lista de Markdown, de modo que la prosa, las listas y las tablas pueden gestionarse de forma independiente en lugar de asumir que una sola estructura sirve para todo.
Paso 2: asociar los encabezados a su contenido. Un encabezado nunca debe convertirse en un elemento hijo por sí solo. El procesador mantiene cada encabezado temporalmente y lo coloca al principio del bloque que sigue:
if pending_heading:
block.text = pending_heading + "\n\n" + block.text
pending_heading = None
Un niño recuperado mantiene, por lo tanto, el encabezado que identifica a su sujeto. Los bloques de lista consecutivos también se agrupan antes de la comparación semántica, lo que ayuda a preservar el orden lógico de las oraciones y los elementos enumerados.
Paso 3: comparar párrafos adyacentes. Para los bloques de párrafo vecinos, la libreta genera embeddings BGE:
embedder = SentenceTransformer(
"BAAI/bge-base-en-v1.5"
)
Luego, la libreta calcula la similitud de coseno entre cada párrafo y el anterior:
similarity = cosine_similarity(
emb1,
emb2
)[0][0]
Si dos bloques son lo suficientemente similares en significado, pueden permanecer dentro del mismo hijo, y una disminución en la similitud indica que el siguiente bloque da inicio a un nuevo tema. El umbral configurado es:
Similarity threshold: 0.40
No obstante, la similitud es solo un factor a considerar; también se tienen en cuenta el tipo y el tamaño del bloque.
Paso 4: aplicar reglas de tamaño y estructura. Los valores predeterminados del desglosador semántico son:
Preferred minimum size: 800 characters
Maximum combined size: 1,600 characters
Tiny-block threshold: 150 characters
Se puede añadir un bloque al hijo actual cuando este aún está por debajo del mínimo deseado, cuando los párrafos vecinos son lo suficientemente similares, cuando uno de los bloques es muy pequeño y no debería estar solo, o cuando los bloques consecutivos pertenecen a la misma lista o tabla. El hijo combinado normalmente debe mantenerse dentro de 1,600 caracteres. Una tabla no se añadirá a un texto prosaico normal solo para alcanzar el tamaño mínimo:
if (
current_type == BlockType.TABLE
and block.type != BlockType.TABLE
) or (
block.type == BlockType.TABLE
and current_type != BlockType.TABLE
):
is_under_min = False
Una lista de gran tamaño puede cortarse en sus marcadores de elemento, mientras que un único bloque no listado de gran tamaño se mantiene como una sola unidad estructural y no se divide al azar. El resultado no es ni un particionamiento puramente semántico ni uno basado exclusivamente en el tamaño. Combina ambos aspectos:
Document structure
+
Semantic similarity
+
Minimum and maximum sizes
Paso 5: conservar el enlace padre. Cada hijo generado recibe su propio ID y mantiene el ID de su padre:
{
"parent_id": parent["parent_id"],
"child_id": f"{parent['parent_id']}_child_{index}",
"doc_id": parent.get("doc_id", ""),
"heading_path": parent.get("heading_path", []),
"text": chunk["text"],
"type": chunk.get("type", "mixed"),
"embedding": encode_text(chunk["text"])
}
Usando al padre Finanzas como caso de ejemplo:
parent_0164
│
├── parent_0164_child_1
│ Article 264 and introductory Finance context
│
├── parent_0164_child_2
│ Articles 265 and 266 concerning taxation and public funds
│
└── parent_0164_child_3
Article 267 concerning the Contingency Fund
Todos los tres niños mantienen:
{
"parent_id": "parent_0164"
}
Un hijo almacenado se ve así:
{
"child_id": "parent_0164_child_2",
"parent_id": "parent_0164",
"doc_id": "constitution_of_india",
"heading_path": ["PART XII"],
"type": "list",
"text": "265. Taxes not to be imposed save by authority of law...",
"embedding": [0.012, -0.034, 0.021]
}
Al final, el corpus contiene 871 elementos infantiles relacionados con 333 padres.
3. Búsqueda híbrida, desde la consulta hasta el contexto del padre
Con 871 elementos infantiles disponibles, surgió la pregunta de cómo encontrar las pruebas adecuadas para la consulta del usuario. La búsqueda se realiza en fragmentos infantiles y no en los padres, ya que las partes más cortas se alinean con mayor facilidad con un artículo, cláusula o idea específica. Los padres más extensos solo se recuperan una vez que se conocen los elementos infantiles más relevantes.
Por qué un método único no es suficiente
Las preguntas legales pueden presentarse de diferentes maneras. Una consulta como “¿Qué dice el Artículo 23?” contiene un número de artículo que también aparece en la fuente, por lo que la búsqueda por palabras clave es muy adecuada para ella. Un usuario podría preguntar lo mismo de forma diferente: “¿Puede el Estado exigir a alguien que realice un servicio obligatorio?”. Esa redacción no menciona el Artículo 23, así que el sistema debe relacionar “exigir a alguien que realice un servicio” con la idea de servicio obligatorio para un fin público. Estos dos casos requieren capacidades diferentes:
- coincidencia literal, para números de artículo, frases legales, nombres y términos inusuales, y
- coincidencia basada en el significado, cuando la redacción de la pregunta se desvía del texto.
En lugar de elegir una opción, el sistema aplica al mismo corpus secundario dos métodos distintos: la búsqueda dispersa BM25 y la búsqueda densa BGE.
La rama escasa: BM25
BM25 clasifica según las palabras que una consulta comparte con cada hijo. En el momento de la indexación, las rutas de los encabezados y el texto de los hijos se convierten a minúsculas y se tokenizan, y el índice registra qué hijos contienen cada término, con qué frecuencia aparece y cuán raro es en todo el corpus. Tres factores son importantes: la frecuencia con que un término de la consulta aparece en un hijo, qué tan poco común es ese término en el corpus y la longitud del hijo en comparación con el promedio. Los términos raros tienen más importancia que las palabras comunes, lo que hace que BM25 sea especialmente útil con números de artículos y expresiones legales específicas.
Por ejemplo, una búsqueda de Article 23 compulsory service favorece en gran medida los resultados que contienen 23, compulsory y service. BM25 no necesita comprender el significado más amplio, ya que la presencia directa de estos términos constituye una señal muy fuerte. Su debilidad radica en que un pasaje relevante puede obtener una puntuación baja cuando la consulta y el texto original utilizan vocabularios diferentes.
La rama densa: BGE y FAISS
La rama densa realiza búsquedas por significado y no solo por superposición de palabras. Cada elemento se representa mediante su ruta de encabezado junto con su texto:
embedding_text = heading_path + child_text
Incluir el encabezado aporta contexto, lo que ayuda a que los pasajes y consultas relacionados aparezcan cerca unos de otros en el espacio de embedding.
Elegir el modelo de embedding con una prueba de referencia
La primera versión del proyecto utilizó nomic-ai/nomic-embed-text-v1.5, pero el autor quería confirmar si era la opción más adecuada para este corpus. Una prueba de referencia controlada de recuperación densa comparó cuatro configuraciones: Nomic sin prefijos, Nomic con los prefijos de consulta y documento recomendados, BAAI/bge-base-en-v1.5 y intfloat/e5-base-v2.
Cada modelo buscó en el mismo conjunto de 871 fragmentos fijos, con 383 preguntas de evaluación que habían sido mapeadas automáticamente.
- MRR: otorga más puntos cuando el primer fragmento relevante se encuentra más cerca de la parte superior.
BGE obtuvo los mejores valores en MRR, Recall@10, Recall@20 y mean coverage@10. El MRR aumentó de 0.624 con Nomic a 0.652, mientras que Recall@20 pasó de 0.877 a 0.911. Esto era importante porque posteriormente la fusión y el reordenamiento utilizan un conjunto limitado de candidatos, y un niño relevante que no aparezca en la recuperación inicial no puede ser recuperado después.
Nomic obtuvo un puntaje ligeramente más alto en All-recall@10 (0.347 frente a 0.334), lo que significa que fue marginalmente mejor para recuperar a cada niño etiquetado en las preguntas con varios niños. Aun así, encontrar al menos un niño relevante antes resultó más importante para recuperar el contexto del padre. La latencia de consulta fue casi idéntica. Por lo tanto, los embeddings de los niños almacenados en Nomic fueron reemplazados por embeddings BGE, manteniendo los límites semánticos existentes para que el cambio en los embeddings quedara separado de las decisiones anteriores de particionamiento.
Los niños se incluyen como texto plano sin prefijo de instrucción. Las consultas, por otro lado, reciben la instrucción de recuperación que recomienda BGE:
query_text = (
"Represent this sentence for searching relevant passages: "
+ user_query
)
El mismo modelo BGE incluye consultas e hijos. Los vectores están normalizados en L2, por lo que el producto interno se comporta como una similitud coseno, donde puntuaciones más altas indican mayor similitud.
El índice FAISS
Los vectores hijos normalizados se introducen en un índice FAISS Hierarchical Navigable Small World:
index = faiss.IndexHNSWFlat(
embedding_dimension,
32,
faiss.METRIC_INNER_PRODUCT,
)
index.hnsw.efConstruction = 200
index.hnsw.efSearch = 64
index.add(embeddings)
HNSW organiza los embeddings como un grafo navegable, de modo que la búsqueda se realiza a través de vecinos prometedores y no compara la consulta con cada vector. Una comparación exhaustiva seguiría siendo manejable para 871 documentos. Se eligió HNSW para que el diseño siga funcionando cuando se añadan al corpus más estatutos, regulaciones, sentencias y documentos similares. Los documentos se volvieron a incrustar con code/reembed_children_bge.py, y el índice se reconstruyó con code/rebuild_dense_index.py. La preparación del benchmark, las definiciones de los modelos, las consultas y los resultados se encuentran en code/dense_benchmark/v2/.
Combinación de las dos clasificaciones
BM25 y la recuperación densa generan listas de clasificación separadas. Un documento puede estar bien posicionado en ambas, muy alto solo en una de ellas, o no aparecer en ninguna. No se pueden sumar simplemente las puntuaciones brutas, ya que una puntuación BM25 y la similitud vectorial operan en escalas diferentes. Las dos listas de clasificación se fusionan mediante la Fusión de Rangos Recíprocos:
RRF score = Σ 1 / (k + rank + 1)
El pipeline utiliza k = 60. Cada hijo obtiene puntos según su posición en cada lista y no según su puntuación original. Un hijo que ocupa un lugar alto en ambas ramas recoge contribuciones de ambas y generalmente asciende, mientras que uno que es fuerte en una sola rama puede seguir estando en el conjunto de candidatos. La etapa de fusión simplificada:
sparse_results = bm25_search(query)
dense_results = dense_search(query)
fused_candidates = reciprocal_rank_fusion(
sparse_results,
dense_results,
)
RRF genera un amplio conjunto de candidatos. Mejora las posibilidades de que sobrevivan pruebas útiles, pero no compara la consulta completa con cada pasaje en detalle. Esa es la tarea del reordenador.
Reordenamiento con codificador cruzado
La búsqueda densa es económica, ya que la consulta y los elementos hijos se codifican por separado, de modo que los embeddings de los hijos pueden calcularse una vez y reutilizarse para cada consulta. El costo es que la codificación independiente limita cuán estrechamente el modelo puede ponderar sus palabras entre sí. Un codificador cruzado sigue otro enfoque y recibe la consulta junto con un hijo candidato al mismo tiempo:
pairs = [
(query, child_text),
...
]
scores = reranker.predict(pairs)
Dado que la consulta y el pasaje se ingresan como una sola entrada, el modelo puede analizar directamente cómo se relacionan sus tokens entre sí, lo que generalmente permite emitir un juicio de relevancia más preciso que los resultados obtenidos mediante BM25 o la búsqueda densa.
candidates = fused_candidates[:30]
reranked_children = cross_encoder.rank(query, candidates)
El reclasificador califica a cada elemento de nuevo y vuelve a ordenar a los candidatos. Las posiciones anteriores de BM25, dense y RRF solo generan el conjunto de candidatos, mientras que la puntuación del codificador cruzado determina el orden final. Luego, el sistema examina los primeros 20 elementos reclasificados y mantiene aquellos que obtienen una puntuación de al menos 0.30:
selected_children = [
child
for child in reranked_children[:20]
if child.score >= 0.30
]
Ese umbral es una política de filtrado para este modelo y pipeline en particular. No debe interpretarse como una probabilidad universal de relevancia, y cambiar los reordenadores implicaría evaluarlo y calibrarlo nuevamente. Las tablas requieren especial atención porque algunas superan el límite de 512 tokens establecido por los reordenadores. Antes de tokenizar, el flujo de trabajo marca las parejas de tablas que son demasiado largas, para que no causen errores de indexación ni desaparezcan silenciosamente.
Expansión de los elementos hijos al contexto del padre
Los reordenadores siguen funcionando con los elementos hijos, ya que estos constituyen unidades de comparación específicas. Sin embargo, si solo se devuelven esos hijos, es posible que se omitan las oraciones circundantes, condiciones, excepciones o artículos relacionados. Por lo tanto, cada hijo mantiene el parent_id asignado durante el proceso de fragmentación:
{
"child_id": "parent_0082_child_1",
"parent_id": "parent_0082",
"text": "23. Prohibition of traffic in human beings and forced labour..."
}
Una vez completada la filtración por umbral, el sistema toma el parent_id de cada hijo seleccionado y busca al padre completo en la colección parent-chunk:
parent_id = selected_child["parent_id"]
parent = parent_lookup[parent_id]
Varios niños seleccionados pueden compartir un mismo padre, y devolverlo repetidamente duplicaría el contexto; por eso los niños se agrupan por parent_id. Cada padre único se muestra una vez junto con los niños que lo relacionan, y la puntuación del hijo principal se convierte en la puntuación de ese padre fuente:
Selected child 1 ──┐
Selected child 2 ──┼── parent_0082
Selected child 3 ──┘
Un niño pequeño que contenga el lenguaje relevante del Artículo 23 podría incluir a parent_0082, cuyo padre a su vez proporciona la sección más amplia sobre el “Derecho contra la Explotación”, incluidas las cláusulas circundantes y el contenido relacionado del Artículo 24. Esto mantiene intacta la división de responsabilidades: los fragmentos correspondientes al niño deciden qué pruebas son relevantes, mientras que los fragmentos del padre ofrecen el contexto en el que aparecen.
La fuente padre devuelta también conserva su ID de documento y la ruta del encabezado, los IDs de los fragmentos hijos correspondientes, sus tipos de bloque y las puntuaciones de reclasificación, de modo que se puede rastrear la fuente devuelta a través del proceso para comprender por qué fue elegida. El experimento de recuperación híbrida se encuentra en code/hybridrag.ipynb.
4. Expansión de consultas y preguntas de múltiples pasos: cerrando la brecha léxica
El pipeline híbrido funcionó bien cuando la redacción del usuario reflejaba estrechamente la Constitución. Una pregunta directa como “¿Qué es el Artículo 21?” contiene una referencia exacta, lo que proporciona una señal fuerte a BM25 y a la recuperación densa.
Las preguntas conceptuales fueron más difíciles. Los usuarios suelen describir un principio con términos de sentencias, libros de texto o comentarios jurídicos y no con el lenguaje propio de la Constitución. Una pregunta sobre la “doctrina de la estructura básica” podría requerir los Artículos 13 y 368, aunque la expresión “estructura básica” no aparece en ninguno de ellos. En otras palabras, el usuario tiene la idea jurídica correcta, pero el documento la denomina de manera diferente.
Lo que mostró la evaluación
El problema se probó en una notebook de Kaggle que incluía preguntas directas y un conjunto más difícil de preguntas conceptuales relacionadas con artículos constitucionales esperados. El experimento precede a la migración a BGE. En ese momento, los 871 niños utilizaban embeddings provenientes de nomic-ai/nomic-embed-text-v1.5, y las consultas se codificaban con el mismo modelo sin prefijos de recuperación. Ese detalle es importante porque el experimento tenía como objetivo aislar el efecto de la reformulación de las consultas, y no clasificar los modelos de embeddings; por lo tanto, la misma pipeline híbrida basada en Nomic manejó ambas versiones de cada pregunta conceptual.
Una pregunta se considera aprobada únicamente si el contexto padre recuperado contenía todos los artículos de destino relacionados con ella, por lo que el porcentaje reportado mide la cobertura total de recuperación de los objetivos y no la precisión factual de ninguna respuesta escrita por un LLM. La comparación reveladora se da entre los dos conjuntos conceptuales. La expansión de la consulta elevó la tasa de aprobación en la recuperación del 56,0 % al 74,5 %, lo que representa un aumento de 18,5 puntos porcentuales. Permitió recuperar el conjunto completo de objetivos para 37 preguntas más y redujo los fallos de 88 a 51.
El resultado sugirió que muchos fallos no se debieron a la falta de documentos o a malas delimitaciones de los fragmentos. La evidencia existía en el corpus, pero la consulta original no proporcionó a la recuperación el vocabulario necesario para encontrarla.
Expansión sin cambiar la intención
La expansión reformula una pregunta conceptual en palabras que es más probable que contenga el texto original. Supongamos que la pregunta se refiere a qué elementos impiden al Parlamento modificar la identidad esencial de la Constitución. La forma ampliada podría incluir la doctrina de la estructura básica, el núcleo de la Constitución que no puede ser enmendado, las leyes que entran en conflicto con los Derechos Fundamentales, el Artículo 13, y la facultad de enmienda del Artículo 368. Lo que desea el usuario no ha cambiado, pero el texto más extenso añade señales de recuperación como:
- el nombre común de la doctrina,
- terminología constitucional relacionada,
- expresiones alternativas de la misma idea,
- lenguaje institucional o estructural relevante, y
- números de artículo explícitos, cuando se pueden inferir con certeza.
El mismo tratamiento abarcó la separabilidad, la esencia y sustancia del contenido, el activismo judicial, y cómo los derechos fundamentales se relacionan con los principios directivos.
En las pruebas de Kaggle, las preguntas ampliadas se redactaron con antelación e incluso se enumeraron los artículos probables, por lo que deberían considerarse como un experimento controlado de reformulación de consultas y no como una evaluación de un modelo de expansión automática. Muestra cuánto puede mejorar la recuperación de información cuando la consulta contiene un lenguaje alineado con la fuente, pero un planificador de consultas en tiempo real necesitaría una evaluación separada sin acceso a las etiquetas de destino. El benchmark posterior sustituyó a Nomic por BGE porque este último obtuvo mejores resultados en el corpus fijo de hijos, aunque los resultados de Kaggle siguieron influyendo en el diseño: un modelo de embedding mejorado mejoró la recuperación semántica, mientras que la expansión de consultas abordaba un problema diferente, la brecha léxica entre el usuario y el documento.
Expansión versus subconsultas
La expansión resulta útil cuando una necesidad de información se expresa en términos poco familiares o indirectos, ya que permite formular una consulta más completa. Las preguntas de múltiples pasos son diferentes, ya que contienen varias necesidades de información que pueden referirse a distintas partes del documento. Considere una solicitud para comparar las soluciones previstas en los Artículos 32 y 226. Si se busca como una única consulta larga, las señales resultantes entran en competencia: un resultado excelente para el Artículo 32 puede saturar la lista de candidatos, mientras que el Artículo 226 recibe poca representación; además, reordenar los candidatos combinados no garantiza que ambos aspectos queden reflejados.
En cambio, una pregunta de múltiples pasos puede dividirse en subconsultas específicas:
- ¿Qué soluciones y jurisdicciones establece el Artículo 32?
- ¿Qué facultades y jurisdicciones establece el Artículo 226?
- ¿En qué difieren constitucionalmente los Artículos 32 y 226?
Cada subconsulta recorre todo el proceso de recuperación (BM25 y recuperación densa, luego fusión de rangos, después reclasificación con codificador cruzado y finalmente expansión de los elementos principales), y los elementos principales resultantes se fusionan y se eliminan las duplicadas para formar pruebas combinadas. Esto evita que una parte de una pregunta compleja agote la capacidad destinada a otra, y facilita la inspección de la recuperación, ya que cada ramificación se ajusta a una necesidad específica.
La estrategia de enrutamiento resultante
La evaluación mostró que no existe una sola transformación de consulta que sea adecuada para todas las preguntas. Las preguntas directas ya contienen señales fuertes y deberían mantenerse tal como están. Las preguntas conceptuales se benefician de la expansión del vocabulario, mientras que las preguntas de múltiples pasos necesitan dividirse para que cada requisito tenga su propia ramificación:
- Preguntas factuales directas o específicas de artículos: realizar la búsqueda con la consulta original.
Este enfoque de enrutamiento evita modificar innecesariamente las consultas simples, al tiempo que proporciona a las complejas la estructura que necesitan. La evaluación original de Kaggle está implementada en code/hybrid_eval.ipynb, mientras que los experimentos posteriores de expansión y ramificación se encuentran en code/hybridrag.ipynb.
Lecciones del proyecto
La lección principal es que un mejor RAG rara vez depende de un modelo perfecto. La calidad de la recuperación proviene de decisiones interconectadas relacionadas con la extracción, el fragmentado, los embeddings, la combinación de candidatos, el reordenamiento y la recuperación del contexto. La evaluación modificó varias decisiones a lo largo del proceso. El benchmark denso reemplazó a Nomic por BGE manteniendo los límites semánticos; el experimento de Kaggle demostró que las preguntas conceptuales se benefician de una terminología más cercana a la del texto original, y los fallos en múltiples pasos apuntaron hacia subconsultas específicas y ramas de recuperación independientes.
Los pasos siguientes naturales incluyen envolver la pipeline en una aplicación de chatbot con un backend estructurado y una interfaz frontal para hacer preguntas e inspeccionar fuentes, luego empaquetarla con Docker y desplegarla en una plataforma en la nube, donde el cargamento de modelos, el uso de memoria, la latencia, los índices persistentes, el registro de actividades, la escalabilidad y los costos se convierten en cuestiones importantes. El sistema sigue evolucionando y algunas fallas aún no se han resuelto, lo cual es precisamente el objetivo de este ejercicio: mantener cada etapa inspeccionable, identificar dónde se pierden las pruebas y permitir que la evaluación guíe las próximas mejoras.
Lecturas relacionadas
- Ingestión de datos RAG en producción para bancos: PDFs, OCR, tablas y conectores — Cómo ingresar documentos bancarios para RAG: clasificar PDFs, ejecutar OCR con indicadores de revisión, mantener la estructura de Word, manejar tablas e imágenes y crear conectores resilientes.
- Limpieza de datos para RAG en producción: codificación, OCR, eliminación de duplicados y PII — Cómo convertir documentos empresariales analizados en texto listo para la recuperación: reparación de codificación, eliminación de textos genéricos y duplicados, indicadores de OCR riesgosos, manejo de idiomas y enmascaramiento de PII.