Freelancer | Consultor digital
12 de noviembre de 2026
14 min
Hace unas semanas me enfrenté a un problema que, visto en retrospectiva, es casi un clásico del RAG mal entendido: un agente de IA que respondía de forma excelente a preguntas en lenguaje natural, pero que se caía por completo —o peor, alucinaba— cuando alguien preguntaba por un código exacto. Cosas como "¿qué programa es ECBTI 17?" o "¿qué es el código SNIES 1677?".
Este post es la historia completa de cómo llegué ahí, por qué mi primer instinto (filtrar por metadatos) fue un callejón sin salida, y cómo terminé implementando búsqueda híbrida con Reciprocal Rank Fusion directamente sobre PostgreSQL, sin añadir ni un servicio más a la infraestructura.
El sistema es un RAG clásico: documentos fragmentados en chunks, cada chunk con su embedding vectorial en pgvector, y una búsqueda por similitud de coseno que recupera los k fragmentos más parecidos a la pregunta del usuario. Funciona razonablemente bien. Hasta que no.
Estaba validando manualmente un cambio distinto (sobre el que escribiré en otro post) cuando encontré esto: le pregunté al agente por un programa académico escribiendo mal el nombre, y no lo encontró. Pensé que era un caso aislado. No lo era.
Monté un experimento adversarial con preguntas reales divididas en tres categorías:
El resultado, medido contra la base de conocimiento real del cliente (9 preguntas con respuesta esperada):
| Categoría | Resultado |
|---|---|
| Nombres mal escritos (errores de ortografía normales) | ✅ se recuperaban bien |
| Códigos exactos de catálogo | ❌ fallo sistemático, 0 de 3 |
| Término numérico exacto | ❌ no se recuperaba el fragmento con el monto |
Aciertos de recuperación: 4 de 9 (44%).
El patrón era nítido: la similitud vectorial tolera muy bien los errores de ortografía del lenguaje natural (el embedding de "agronegosios" y "agronegocios" son casi idénticos), pero falla de forma sistemática con cadenas cortas y exactas tipo "ECBTI 17". Un código así no tiene suficiente "significado semántico" para que el modelo de embeddings lo distinga bien de otros códigos parecidos.
El caso más grave no fue un simple "no encontré nada". Fue este: al preguntar por un código de catálogo, el sistema recuperó un montón de ruido (un directorio de contactos de la universidad) y el LLM, sin ningún dato real que citar, inventó un programa académico que no existe para rellenar el hueco. Confirmé con una consulta SQL directa que ese nombre no aparecía en ningún documento. El modelo no estaba mintiendo a propósito: estaba haciendo lo que hace cuando no tiene contexto suficiente, que es generar la respuesta más probable, verdadera o no.
Mi primer instinto, antes de este experimento, había sido otro: si el problema es que la búsqueda semántica no distingue bien ciertos campos, dejemos que el LLM filtre por metadatos estructurados. Cada documento tenía metadata (category, tags, priority) y la tool de RAG le pasaba al modelo la posibilidad de filtrar por esos campos antes de buscar.
El problema, descubierto por un incidente real en producción: el modelo inventaba valores de filtro que no existían. Una consulta llegó así:
{
"query": "Ingeniería de Sistemas",
"category": "programas académicos"
}
Ningún documento tenía esa metadata. El category era texto libre porque el vocabulario real estaba vacío, así que el modelo rellenó con un valor plausible — y la búsqueda vectorial, ya filtrada por ese campo inexistente, no encontró nada, aunque el documento correcto sí existía en la base.
Antes de eliminar el mecanismo sin pruebas, medí tres variantes contra el mismo dataset real:
| Modo | Descripción | Aciertos de recuperación |
|---|---|---|
| A | Filtro libre, el LLM pone lo que quiera | 1/8 (12.5%) |
| B | Vocabulario cerrado + barrera de membresía (el filtro solo se aplica si el valor existe realmente) | 6/8 (75%) |
| C | El LLM nunca ve category/tags/priority — solo busca por texto |
7/8 (87.5%) |
El resultado me sorprendió más de lo que esperaba: la opción de no exponer metadata al LLM en absoluto midió igual o mejor que construir todo un sistema de vocabulario autoritativo con validación. La mejora nunca vino de "filtrar bien" — vino de "dejar de filtrar mal". Un filtro inventado siempre es peor que ningún filtro, porque un filtro erróneo excluye el documento correcto antes de que la similitud tenga oportunidad de encontrarlo.
Eliminé por completo el mecanismo de filtrado (automático y el pin manual que un supervisor podía configurar). La metadata de los documentos se conservó —sigue siendo útil para reportes y clasificación— pero deja de usarse para restringir qué chunks puede ver la búsqueda.
Lección 1: si tu RAG falla con ciertas consultas, la tentación de "dale al LLM más control estructurado" puede empeorar las cosas. A veces la solución es reducir los grados de libertad del modelo, no aumentarlos.
Pero esto no resolvía el problema real: seguía sin encontrar "ECBTI 17".
Mi segunda hipótesis, antes de tocar el motor de búsqueda, fue sobre el chunking. El sistema soporta varias estrategias:
#/##/###), partiendo el documento por secciones reales.La base de conocimiento en cuestión usaba chunking semántico con un tamaño de 2000 caracteres. Mi teoría: un chunk tan grande podía estar agrupando demasiados programas académicos distintos en un solo fragmento, diluyendo la señal del código específico que el usuario buscaba.
Confirmé la teoría con una consulta SQL directa: efectivamente, un chunk de 4000+ caracteres contenía casi diez programas académicos distintos, cada uno con su propio código. El LLM tenía el dato correcto delante, mezclado con nueve más, y a menudo elegía mal entre ellos.
Probé reducir el chunk a 800 caracteres con estrategia estructural, esperando que cada programa quedara aislado en su propio fragmento. Y en efecto, eso ayudó — pero reveló un bug completamente distinto, mucho más interesante.
Al reducir el tamaño de chunk y cambiar a estrategia estructural, el visualizador de chunks de la aplicación me mostró algo así:
PROGRAMAINACTIVO POR ACUERDO DEL CONSEJO ACADÉMICOPrograma INACTIVO
para Aspirantes, únicamente se oferta para los antiguos del programa.
Tecnología en Gestión de Transportes (SNIES: 1678)ACUERDO NÚMERO 012
DEL 30 DE MAYO DE 2025Agradecemos sinceramente tu interés...
Columnas distintas de una hoja de Excel, pegadas sin ningún separador. El extractor de .xlsx sí escribía un tab entre celdas — lo confirmé leyendo el código:
def _extract_xlsx(file_bytes: bytes, *, markdown_headings: bool = False) -> str:
...
for row in sheet.iter_rows(values_only=True):
cells = [str(value).strip() for value in row if value is not None and str(value).strip()]
if cells:
rows.append("\t".join(cells)) # tab explícito aquí
Confirmé con SQL directo que el content guardado en la base de datos tenía cero caracteres de tab. El separador se perdía en algún punto entre la extracción y el chunking. Aislé el problema con un experimento mínimo y reproducible:
from langchain_text_splitters import MarkdownHeaderTextSplitter
text = "## Programas inactivos\nTecnología en Gestión de Transportes (SNIES: 1678)\tACUERDO NÚMERO 012 DEL 30 DE MAYO DE 2025\tAgradecemos tu interés"
splitter = MarkdownHeaderTextSplitter(headers_to_split_on=[("#", "h1"), ("##", "h2"), ("###", "h3")])
docs = splitter.split_text(text)
print(repr(docs[0].page_content))
Salida:
'Tecnología en Gestión de Transportes (SNIES: 1678)ACUERDO NÚMERO 012 DEL 30 DE MAYO DE 2025Agradecemos tu interés'
Ahí está: MarkdownHeaderTextSplitter de LangChain, usado por la estrategia de chunking estructural, colapsa literalmente el carácter tab al reconstruir el texto de cada sección. Celdas de columnas completamente distintas quedaban pegadas como si fueran una sola palabra larga.
El fix fue cambiar el separador a algo que sobreviviera al splitter:
cell_separator = " | " if markdown_headings else "\t"
...
rows.append(cell_separator.join(cells))
Verificado con el mismo experimento mínimo:
text = "## Programas inactivos\nTecnología en Gestión de Transportes (SNIES: 1678) | ACUERDO NÚMERO 012 DEL 30 DE MAYO DE 2025 | Agradecemos tu interés"
docs = splitter.split_text(text)
print(repr(docs[0].page_content))
# 'Tecnología en Gestión de Transportes (SNIES: 1678) | ACUERDO NÚMERO 012 DEL 30 DE MAYO DE 2025 | Agradecemos tu interés'
El separador sobrevive intacto.
Lección 2: cuando un pipeline de procesamiento de texto pasa por múltiples transformaciones (extracción → chunking → embedding), cualquiera de los pasos intermedios puede estar corrompiendo silenciosamente los datos sin que ningún test unitario lo detecte, porque cada paso se prueba aislado. El bug solo apareció al encadenar extracción real + chunking real con datos reales — ningún test que probara _extract_xlsx por separado, o chunk_structural por separado con texto sintético sin tabs, lo habría encontrado nunca.
Y aun con el bug corregido, ni el chunking ni el tamaño de fragmento resolvieron el problema original. Seguía sin encontrar los códigos exactos de forma consistente. El chunking es necesario pero no suficiente: ninguna estrategia de chunking arregla una limitación fundamental del modelo de embeddings con cadenas cortas y exactas.
Antes de implementar nada, investigué cómo resuelven este mismo problema otros proyectos open source de RAG. El patrón de fallo que había medido está bien documentado como una debilidad conocida de la búsqueda vectorial pura: "Searching for acronyms or short phrases... Searching for IDs, for example, gpt-3.5-turbo or titan-xlarge-v1.01".
Una aproximación habitual es un rerank con una suma ponderada simple:
score = vector_weight * vector_score + keyword_weight * keyword_score
Pero esa aproximación tiene una limitación estructural: el keyword scoring se calcula sobre los mismos candidatos que ya trajo la fase vectorial. Es un rerank, no una recuperación independiente. Si el candidato correcto nunca entra al top-N vectorial (que es exactamente mi problema: un código exacto con baja similitud semántica nunca llega al top-N), el keyword score nunca lo rescata. Rerankear basura sigue devolviendo basura.
Esto definió mi primera decisión de diseño: dos recuperaciones completamente independientes, no un rerank sobre el top-N de una sola fuente.
PostgreSQL ya tiene todo lo necesario para búsqueda de texto completo, sin añadir Elasticsearch ni ningún servicio nuevo. La pieza central es una columna tsvector generada automáticamente:
ALTER TABLE document_chunks
ADD COLUMN search_vector tsvector
GENERATED ALWAYS AS (to_tsvector('simple', immutable_unaccent(content))) STORED;
CREATE INDEX ix_document_chunks_search_vector_gin
ON document_chunks USING gin (search_vector);
Dos decisiones no obvias aquí, ambas descubiertas por error real, no por lectura de documentación:
Primera: unaccent() de PostgreSQL es STABLE, no IMMUTABLE — y una columna GENERATED exige que su expresión sea IMMUTABLE. La migración falló con un error claro (generation expression is not immutable) hasta que creé una función wrapper fijada al diccionario unaccent:
CREATE FUNCTION immutable_unaccent(text) RETURNS text AS $$
SELECT public.unaccent('unaccent', $1)
$$ LANGUAGE sql IMMUTABLE;
Patrón estándar documentado en la propia wiki de PostgreSQL para este caso exacto, pero que solo encontré después de que la migración fallara con un mensaje de error que, afortunadamente, es bastante explícito.
Segunda: elegí el diccionario simple en vez de spanish después de confirmar empíricamente que el stemmer en español normalizaba "ECACEN" a "ecac" — funciona, pero es arriesgado con acrónimos mixtos de español e inglés. simple no hace stemming: indexa las palabras tal cual, que es exactamente lo que quiero para códigos y acrónimos.
La función de búsqueda full-text:
rank = func.ts_rank_cd(DocumentChunk.search_vector, func.plainto_tsquery("simple", query_text))
statement = (
select(DocumentChunk, rank.label("rank"))
.where(
DocumentChunk.knowledge_base_id == knowledge_base_id,
DocumentChunk.search_vector.op("@@")(func.plainto_tsquery("simple", query_text)),
)
.order_by(rank.desc())
.limit(match_count)
)
El segundo problema de diseño: el score de similitud de coseno vive entre 0 y 1, pero ts_rank_cd no tiene cota superior fija. Compararlos directamente con una suma ponderada es frágil — exactamente el motivo por el que opté por Reciprocal Rank Fusion (RRF) como alternativa.
RRF no compara scores en absoluto. Solo usa la posición de cada candidato en su ranking respectivo:
def _fuse_rrf(vector_results, fulltext_results, k=60):
scores = defaultdict(float)
by_id = {}
for rank, result in enumerate(vector_results, start=1):
scores[result.chunk_id] += 1 / (k + rank)
by_id[result.chunk_id] = result
for rank, result in enumerate(fulltext_results, start=1):
scores[result.chunk_id] += 1 / (k + rank)
by_id.setdefault(result.chunk_id, result)
ordered_ids = sorted(scores, key=lambda cid: scores[cid], reverse=True)
return [by_id[cid] for cid in ordered_ids]
Un chunk que aparece en el puesto 1 de ambas listas gana un boost mayor que uno que solo aparece en una. Un chunk que solo existe en la lista full-text (porque su similitud vectorial era demasiado baja) sigue teniendo una oportunidad real de aparecer en el resultado final, en vez de quedar descartado desde el principio.
Un detalle de concurrencia que me costó un bug real: mi diseño inicial corría ambas búsquedas en paralelo con asyncio.gather. Pasó una prueba manual. Falló en un test de integración real con un error elocuente:
sqlalchemy.exc.IllegalStateChangeError: Method 'close()' can't be called here;
method '_connection_for_bind()' is already in progress
Una AsyncSession de SQLAlchemy no admite dos queries concurrentes sobre la misma conexión. La solución fue trivial una vez entendido el problema: ejecutar las dos búsquedas en secuencia, no en paralelo. El costo de latencia (una query más, no el doble de una sola) resultó aceptable en la medición final.
Medido contra el mismo dataset adversarial, comparando estrategia vectorial pura contra híbrida:
| Dataset | Vectorial puro | Híbrido (RRF) |
|---|---|---|
| Adversarial (códigos exactos) | 7/9 (0.778)* | 8/9 (0.889) |
| No regresión (caso general) | 7/8 (0.875) | 7/8 (0.875) — idéntico |
(El piso real subió de 4/9 a 7/9 tras corregir un error propio en el dataset: mi criterio de verificación esperaba el monto con comas ("5,440,000") cuando el documento real usaba el formato colombiano con puntos ("5.440.000"). Vale la pena mencionarlo: antes de medir una mejora, hay que estar seguro de que el dataset de medición es correcto.)
El dato que más me convenció no fue la tabla, sino reproducir el mismo fallo con el control más limpio posible: la misma consulta exacta ("SNIES 1677"), mismos parámetros de búsqueda, cambiando únicamente la estrategia activa.
Y lo igual de importante: el caso general —preguntas normales en lenguaje natural, con nombres mal escritos incluidos— no empeoró ni un punto. Cero regresión.
Un caso específico siguió fallando en ambos modos: un monto exacto en pesos para un número específico de créditos académicos, disperso entre varios chunks con montos muy similares. Ni la búsqueda vectorial ni la de texto completo lo resuelven, porque no es un problema de recuperación de texto — es un problema de extracción estructurada de datos numéricos, un tipo de solución completamente distinto (posiblemente parsing de tablas, no búsqueda).
No intenté forzar una solución para ese caso dentro de este mecanismo. Documentarlo como "fuera de alcance" es tan importante como resolver lo que sí se puede resolver — vender un mecanismo como solución universal es la forma más rápida de perder la confianza de quien lo va a usar en producción.
Los metadatos estructurados con un LLM en el medio son peligrosos por diseño: un filtro inventado siempre es peor que ningún filtro. A veces la mejora viene de quitarle control al modelo, no de dárselo con más barandillas.
El chunking puede amplificar o mitigar un problema, pero rara vez lo resuelve de raíz — y cualquier pipeline de transformación de texto en cadena puede estar corrompiendo datos en silencio en un paso intermedio que ningún test aislado detecta.
Dos recuperaciones independientes fusionadas por ranking (RRF) superan a un rerank sobre una sola fuente, y PostgreSQL ya tiene todo lo necesario para implementarlo sin añadir infraestructura nueva — la limitación de la búsqueda vectorial para códigos exactos no es un problema de "necesitas un vector database mejor", es un problema de usar solo una herramienta cuando el problema pide dos.
En Ceibot.io implementamos exactamente este tipo de solución en las bases de datos vectoriales de los agentes de IA que construimos para nuestros clientes: búsqueda híbrida con RRF, validada contra datasets adversariales reales, sin añadir infraestructura innecesaria. Si tu chatbot o agente RAG falla con códigos, referencias exactas o términos técnicos, hablemos.
Recibe contenido exclusivo sobre desarrollo web, IA y tendencias tecnológicas directamente en tu email.
Contactar para proyecto