volver al blog

Filtrar por metadatos en RAG: por qué tu búsqueda devuelve tres resultados

ragbases-de-datosarquitectura

Pides diez y te llegan tres

El buscador semántico va bien en las pruebas. Entonces llega el requisito de siempre: que cada cliente vea solo sus documentos. Añades un WHERE cliente_id = ? a la consulta, despliegas y la calidad se hunde. Preguntas que antes respondía bien ahora devuelven fragmentos irrelevantes, o directamente tres resultados cuando pediste diez.

El filtro funciona. El problema es dónde se aplica.

Un índice vectorial aproximado no recorre todos tus vectores: navega por una estructura que visita una fracción minúscula del espacio y se detiene cuando cree tener suficientes candidatos. Si el filtro se aplica después de esa navegación, el índice ya ha gastado su presupuesto de exploración en documentos que no pertenecen al cliente. Los descarta al final y te entrega lo que sobreviva.

Con un cliente que representa el 2 % del corpus, de 200 candidatos explorados sobreviven cuatro. Y esos cuatro no son los cuatro mejores de ese cliente: son los que casualmente cayeron cerca del camino que recorrió el grafo.

Las tres formas de combinar filtro y vector

EstrategiaQué haceVa bien cuandoEl riesgo
Post-filtradoBusca en el índice, luego descarta lo que no cumpleEl filtro deja pasar a casi todo (> 50 % del corpus)Devuelve menos resultados de los pedidos, y los peores
Pre-filtrado exactoResuelve primero el filtro, compara por fuerza bruta ese subconjuntoEl subconjunto es pequeño (miles de vectores)Latencia lineal: inviable si el filtro deja cientos de miles
Filtrado durante el recorridoEl índice comprueba el filtro mientras navega y solo cuenta candidatos válidosCaso general, sobre todo con filtros selectivosRequiere soporte del motor y un índice sobre el metadato

La tercera es la que quieres casi siempre, y es la que los motores han ido incorporando en los últimos años con nombres distintos. Qdrant estima la cardinalidad del filtro y decide sola entre recorrer el grafo con la condición aplicada o hacer fuerza bruta sobre el subconjunto. Weaviate implementa el mismo tipo de recorrido filtrado sobre HNSW. pgvector añadió en la versión 0.8 los iterative index scans: si tras filtrar faltan filas, vuelve al índice a por más en lugar de rendirse.

Lo importante no es qué motor usas. Es saber cuál de las tres cosas está haciendo el tuyo ahora mismo, porque el comportamiento por defecto de varios de ellos es el post-filtrado.

Medir antes de tocar nada

El síntoma es fácil de cuantificar: cuántos resultados pediste frente a cuántos te devolvió, y con qué similitud. En pgvector se ve en dos consultas.

import psycopg

# La consulta ingenua: el filtro va en el WHERE y el índice no lo sabe.
CONSULTA = """
SELECT id, texto, 1 - (embedding <=> %(v)s::vector) AS similitud
FROM fragmentos
WHERE cliente_id = %(cliente)s
ORDER BY embedding <=> %(v)s::vector
LIMIT 10
"""

with psycopg.connect(DSN) as conn, conn.cursor() as cur:
    # Sin scan iterativo: el índice devuelve ef_search candidatos globales
    # y el WHERE se come casi todos. Esto es post-filtrado de facto.
    cur.execute("SET hnsw.ef_search = 40")
    cur.execute(CONSULTA, {"v": consulta_vec, "cliente": 42})
    base = cur.fetchall()

    # Con scan iterativo: si faltan filas, pgvector vuelve al índice a por más.
    # relaxed_order es más rápido; strict_order garantiza el orden por distancia.
    cur.execute("SET hnsw.iterative_scan = 'relaxed_order'")
    cur.execute("SET hnsw.max_scan_tuples = 20000")  # techo de seguridad
    cur.execute(CONSULTA, {"v": consulta_vec, "cliente": 42})
    mejor = cur.fetchall()

print(len(base), len(mejor))          # p. ej. 3 frente a 10
print(base[0][2], mejor[0][2])        # y el primero de la lista, mejor

Si el primer número es menor que tu LIMIT, tienes post-filtrado silencioso. Y si es igual pero la similitud media sube al activar el recorrido iterativo, también lo tenías: simplemente había suficientes sobras para rellenar el hueco.

Conviene además crear un índice B-tree sobre cliente_id. Sin él, el motor no puede estimar cuántas filas deja pasar el filtro ni decidir si le sale más barato ignorar el índice vectorial y comparar a pelo.

La selectividad manda

Antes de afinar parámetros, calcula qué fracción del corpus deja pasar cada filtro. Es una consulta trivial y cambia por completo la decisión.

  • Por encima del 50 %: el post-filtrado va bien. Sube algo ef_search y sigue con tu vida.
  • Entre el 1 % y el 50 %: territorio del recorrido filtrado. Es donde caen casi todos los casos reales.
  • Por debajo del 1 %: la fuerza bruta sobre el subconjunto suele ganar. Comparar 5.000 vectores en memoria son unos pocos milisegundos, y el resultado es exacto por definición.

Ese último punto sorprende a mucha gente. Cuando el filtro es muy selectivo, el índice aproximado no aporta nada: estorba. Varios motores lo detectan solos, pero si el tuyo no lo hace, un if en tu código de recuperación resuelve el caso.

El diseño de los metadatos es la mitad del trabajo

La mayoría de los problemas de recuperación filtrada nacen en la ingesta, no en la consulta.

Guarda como metadato solo lo que vayas a filtrar. Cada campo adicional es un índice que mantener y una condición más que evaluar en cada salto del grafo. Si nadie filtra por autor, que viva fuera del payload.

Prefiere campos de baja cardinalidad y alta selectividad: cliente_id, idioma, tipo_documento, estado. Los rangos de fecha abiertos (“últimos 90 días”) son los peores compañeros de un índice vectorial, porque su selectividad cambia cada día y con ella el plan que elige el motor. Si el rango es siempre el mismo, precalcula un booleano es_reciente y filtra por él.

Y no mezcles permisos con relevancia. Un filtro de acceso no es un criterio de ranking: se aplica siempre, no se relaja nunca y no se negocia con el LIMIT. Si al activar el recorrido iterativo pones un techo de tuplas escaneadas, asegúrate de que el resultado sea “menos resultados”, nunca “resultados de otro cliente”.

Qué hacer el lunes

Tres pasos, en este orden.

  1. Registra en tus trazas cuántos resultados pediste y cuántos devolvió la recuperación. Si esa diferencia no está en tu observabilidad, no sabes si tienes el problema.
  2. Calcula la selectividad de tus filtros más usados con un COUNT y decide estrategia por tramo.
  3. Activa el recorrido filtrado de tu motor, crea el índice sobre el metadato y vuelve a medir el recall contra una referencia exacta.

El orden importa. Cambiar el modelo de embeddings porque “el RAG recupera mal” cuando el fallo era un WHERE mal colocado es una de las formas más caras de perder un trimestre.