Un sistema RAG multi-tenant tiene un modo de fallo que los sistemas single-tenant no tienen: puede producir una respuesta que es fluida, bien citada y completamente correcta en apariencia, mientras contiene silenciosamente datos de otro cliente. Esto no es un caso borde hipotético —es la consecuencia directa y predecible de un atajo muy común, donde el alcance del tenant se aplica como un filtro sobre los resultados de búsqueda en lugar de como una restricción estricta sobre la búsqueda en sí. La búsqueda de similitud vectorial no sabe qué es un tenant; si no le indicas, a nivel de índice, exactamente qué puede y qué no puede ver una consulta dada, devolverá felizmente el vector más similar en todo el índice, sin importar a quién pertenezca.
La solución suena simple —“solo filtra por tenant ID”— y los detalles de implementación son donde realmente se complica. Los índices de vecinos más cercanos aproximados (HNSW, IVFFlat) no están construidos pensando en seguridad a nivel de fila, filtrar después del retrieval en lugar de antes crea un riesgo real de exposición de datos, y filtrar lo suficientemente agresivo para garantizar el aislamiento puede colapsar silenciosamente el recall de maneras que parecen un bug de relevancia en lugar del tradeoff de seguridad que realmente son. Este artículo cubre pre-filtrado versus post-filtrado mecánicamente, dónde realmente falla el aislamiento de tenants, y cómo razonar sobre el costo de recall que viene con hacerlo de forma segura.
Aprenderás:
- Por qué el post-filtrado (buscar primero, luego descartar resultados del tenant incorrecto) es un riesgo real de exposición de datos, no solo una ineficiencia
- La diferencia mecánica entre pre-filtrado y post-filtrado en un índice ANN, y por qué no son intercambiables
- Cómo implementar aislamiento estricto de tenants en Postgres con
pgvectory seguridad a nivel de fila - Por qué el pre-filtrado agresivo puede causar un colapso de recall medible, y cómo razonar sobre ese tradeoff
- Índice-por-tenant versus índice compartido con filtrado, y cuándo cada uno es la arquitectura correcta
- Una lista de verificación concreta para auditar un sistema RAG multi-tenant existente en busca de riesgos de filtración
Tabla de contenidos
- El riesgo real: filtrar resultados vs. filtrar la búsqueda
- Pre-filtrado vs. post-filtrado, mecánicamente
- Dónde se adjuntan los metadatos del tenant: volviendo al chunking
- Aislamiento estricto de tenants con pgvector y seguridad a nivel de fila
- El colapso de recall: lo que cuesta el filtrado estricto
- Índice-por-tenant vs. índice compartido con filtrado
- Auditando un sistema existente en busca de riesgos de filtración
- Errores comunes
- Conclusión
El riesgo real: filtrar resultados vs. filtrar la búsqueda
El patrón peligroso parece razonable a primera vista: ejecuta una búsqueda de similitud sobre todo el índice, obtén los k mejores resultados, luego filtra cualquier cosa que no pertenezca al tenant solicitante antes de pasarle el resto al LLM.
# The unsafe pattern — do not use this for tenant isolation
def unsafe_search(query_embedding, tenant_id, k=5):
results = vector_index.search(query_embedding, k=k) # searches everything
return [r for r in results if r.metadata["tenant_id"] == tenant_id] # filters after
El bug obvio e inmediato es que esto puede devolver menos de k resultados, o ninguno, si los mejores resultados pertenecen todos a otros tenants —eso solo ya sería razón suficiente para evitarlo. Pero el problema más profundo es lo que ocurre en el hueco entre “buscar todo” y “filtrar después”: si esta lógica alguna vez tiene un bug —el filtro se salta bajo un camino de error, una capa de caché devuelve resultados pre-filtrado, una declaración de logging captura la respuesta cruda antes del filtrado— los datos privados de otro tenant ya fueron recuperados a memoria y están a un error de llegar al cliente equivocado. El post-filtrado convierte el aislamiento de tenants en una propiedad del código de aplicación que tiene que ejecutarse correctamente en cada solicitud, en lugar de una propiedad de la búsqueda en sí. Esa es una garantía materialmente más débil, y es el lugar equivocado para poner un límite de seguridad.
Pre-filtrado vs. post-filtrado, mecánicamente
El post-filtrado busca primero el índice completo y descarta después los resultados que no coinciden —el patrón anterior. Su única ventaja es la simplicidad de implementación, y debe evitarse para cualquier cosa donde el aislamiento de tenants sea un requisito estricto en lugar de una preferencia de relevancia suave.
El pre-filtrado aplica la restricción del tenant antes o durante la búsqueda de similitud, para que los vectores pertenecientes a otros tenants nunca se comparen contra la consulta —no recuperados, no puntuados, no presentes en memoria como candidatos. Este es el único patrón que te da una garantía de seguridad real en lugar de una de mejor esfuerzo.
La complicación mecánica es que las estructuras de vecinos más cercanos aproximados (grafos HNSW, clústeres IVFFlat) están construidas para traversar el índice completo de manera eficiente, e intersectar ingenuamente “solo los vectores del tenant X” con “los vecinos más cercanos en este grafo” no es gratuito —un traversal de grafo diseñado para encontrar los puntos globalmente más cercanos no sabe automáticamente omitir porciones grandes del grafo que pertenecen a otros tenants. Dependiendo de la implementación del índice, el pre-filtrado real se maneja de dos maneras: traversal de grafo filtrado, donde la búsqueda ANN en sí omite candidatos que no coinciden durante el traversal (soportado nativamente por algunas bases de datos vectoriales y versiones más nuevas de pgvector), o índices particionados por metadatos, donde el límite del tenant se aplica estructuralmente —un índice o partición por tenant— para que no haya grafo al que atravesar accidentalmente.
Dónde se adjuntan los metadatos del tenant: volviendo al chunking
El filtrado por tenant solo puede ser tan confiable como los metadatos adjuntos a cada chunk en el momento de la ingesta, lo que significa que este problema comienza mucho antes de que se ejecute cualquier consulta de búsqueda. Si un documento se divide en chunks y un campo tenant_id no se adjunta a cada registro de chunk —no solo al documento padre— un filtro aplicado en el momento de la consulta no tiene nada consistente sobre qué filtrar. Esta es la misma disciplina de ingesta cubierta en estrategias de chunking para RAG: sin importar cómo se divida un documento, el paso de chunking también es el lugar natural para estampar cada chunk resultante con sus metadatos de alcance completos, ya que ese es el último punto en el pipeline donde el chunk todavía está inequívocamente vinculado a su documento fuente y tenant.
def chunk_with_tenant_metadata(document: dict, chunk_fn) -> list[dict]:
chunks = chunk_fn(document["body"])
return [
{
"text": chunk,
"tenant_id": document["tenant_id"],
"document_id": document["id"],
"document_version": document["version"],
"access_tier": document.get("access_tier", "default"),
}
for chunk in chunks
]
Esto importa más específicamente para el chunking jerárquico, donde los metadatos de un chunk hijo deben coincidir con los de su padre —un pipeline que adjunta tenant_id al registro padre pero olvida propagarlo a cada chunk hijo crea exactamente el tipo de inconsistencia que hace que una consulta filtrada pierda filas silenciosamente, o peor aún, recupere un chunk hijo que nunca fue verificado contra un límite de tenant porque el campo que necesitaba simplemente no estaba ahí.
Aislamiento estricto de tenants con pgvector y seguridad a nivel de fila
Para una configuración de Postgres con tabla compartida e índice compartido, la línea de base más segura es combinar un pre-filtro de cláusula WHERE con la seguridad a nivel de fila (RLS) de Postgres, para que el aislamiento de tenants se aplique en la capa de base de datos independientemente de lo que el código de aplicación haga correctamente u olvide hacer:
ALTER TABLE documents ENABLE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON documents
USING (tenant_id = current_setting('app.current_tenant_id')::uuid);
-- set once per request/connection, before any query runs
SET app.current_tenant_id = '3fa85f64-5717-4562-b3fc-2c963f66afa6';
SELECT id, body, embedding <=> :query_embedding AS distance
FROM documents
ORDER BY embedding <=> :query_embedding
LIMIT 5;
Con RLS habilitado, la consulta anterior nunca ve las filas de otro tenant —la política es aplicada por Postgres mismo a nivel de fila, antes de que siquiera se calcule la distancia vectorial, lo que significa que un bug en el código de aplicación que olvide añadir una cláusula WHERE tenant_id = ... aún no puede filtrar datos, porque la capa de base de datos aplica el límite de manera independiente. Este es el mismo principio detrás de los metadatos de alcance como un pre-filtro estricto en lugar de una verificación posterior, aplicado específicamente al caso donde la dimensión de alcance es un límite de seguridad en lugar de solo una señal de relevancia —la mecánica es similar, pero las consecuencias de equivocarse son categóricamente diferentes.
RLS sí agrega un overhead por consulta para evaluar la política, y necesita que current_setting esté configurado correctamente en cada conexión —un pool de conexiones que reutiliza conexiones entre tenants sin restablecer esta configuración en cada checkout reintroduce exactamente el riesgo de filtración que RLS intenta prevenir, por lo que esto necesita estar integrado en la gestión del ciclo de vida de las conexiones, no solo añadido al esquema.
El colapso de recall: lo que cuesta el filtrado estricto
El pre-filtrado por tenant es necesario, pero interactúa mal con la aproximación del índice ANN de una manera que vale la pena entender concretamente en lugar de descubrir en producción. HNSW e IVFFlat son aproximados —intercambian una pequeña cantidad de recall por grandes ganancias de velocidad al no comparar exhaustivamente cada vector, sino traversando un grafo o un conjunto de clústeres construidos para usualmente encontrar los verdaderos vecinos más cercanos, no siempre.
Cuando los datos de un tenant son una pequeña fracción de un índice compartido grande, la búsqueda filtrada tiene que trabajar más para encontrar suficientes candidatos que coincidan, porque la estructura del grafo o clúster fue construida a través de todo el índice, no ajustada para el subconjunto de ningún tenant en particular. En el peor caso —un tenant pequeño compartiendo un índice con otros mucho más grandes— una búsqueda ANN filtrada puede devolver menos resultados relevantes de los esperados, o coincidencias de menor calidad, simplemente porque el traversal aproximado no encontró suficientes vectores de ese tenant dentro de su presupuesto de búsqueda normal antes de detenerse.
Números ilustrativos de una pequeña prueba desarrollada —un índice HNSW compartido construido a partir de un corpus de juguete dividido entre tres tenants simulados de diferentes tamaños, consultado con la misma metodología recall@5 usada anteriormente en esta serie, comparando búsqueda filtrada contra el índice dedicado propio de cada tenant como línea de base:
| Tenant | Fracción del índice compartido | Recall@5 filtrado (índice compartido) | Recall@5 (índice dedicado) |
|---|---|---|---|
| Grande | ~80% | 0,86 | 0,88 |
| Mediano | ~18% | 0,79 | 0,85 |
| Pequeño | ~2% | 0,61 | 0,84 |
Esta es una ilustración a escala de juguete, no un benchmark general, pero demuestra el mecanismo real claramente: el tenant grande apenas nota el índice compartido, mientras que el tenant pequeño pierde más de veinte puntos de recall puramente por ser un pequeño fragmento de una estructura aproximada mucho más grande. El filtrado no cuesta lo mismo a cada tenant, y los tenants pequeños en un índice compartido grande absorben la mayor parte del costo de recall —que es precisamente el tipo de regresión que un número único de recall agregado, promediado entre todos los tenants, nunca detectaría.
La corrección que más importa es medir esto en tu propia distribución de tenants en lugar de asumirlo —aumentar el parámetro de tamaño de lista de candidatos de la búsqueda ANN (ef_search en HNSW) en tiempo de consulta intercambia algo de latencia por un recall filtrado materialmente mejor, y vale la pena ajustarlo específicamente para tus tenants más pequeños en lugar de un único valor global por defecto dimensionado para el caso promedio.
Índice-por-tenant vs. índice compartido con filtrado
Hay dos arquitecturas estructuralmente diferentes, y la elección correcta depende del número de tenants y la distribución del tamaño de los tenants, no de una mejor práctica general.
El índice compartido con filtrado (una tabla, un índice HNSW, tenant ID como columna filtrada) es operacionalmente más simple —un índice para construir, ajustar y mantener— y funciona bien cuando los tenants son numerosos y cada uno es relativamente pequeño, ya que un índice dedicado por tenant a esa escala significaría mantener miles de índices pequeños con overhead significativo por índice.
El índice-por-tenant (una tabla separada o un índice HNSW separado por tenant, a veces mediante esquemas separados o bases de datos físicas separadas) evita completamente el problema del colapso de recall, ya que la búsqueda ANN de cada tenant solo traversa los datos de ese tenant —no hay penalización de aproximación por ser un pequeño fragmento de una estructura mucho más grande. También proporciona la historia de aislamiento más limpia posible, ya que no hay ninguna estructura compartida que pueda configurarse incorrectamente. El costo es operacional: provisionar, migrar y monitorear un índice por tenant no escala limpiamente más allá de unos pocos cientos de tenants sin una inversión real en infraestructura.
Un término medio común para un sistema con un puñado de tenants muy grandes y una larga cola de pequeños: índices dedicados para los tenants grandes (donde el colapso de recall sería más costoso y donde el overhead de un índice dedicado se justifica fácilmente), y un índice compartido protegido por RLS para los tenants más pequeños, dimensionado y ajustado en ef_search específicamente para esa distribución.
Auditando un sistema existente en busca de riesgos de filtración
Para un sistema ya en producción, una lista de verificación concreta:
- Rastrea exactamente dónde se aplica el filtro de tenant, de extremo a extremo, desde la solicitud de API hasta la consulta SQL o de índice vectorial real. Si el filtro se aplica en el código de aplicación después de que una llamada de búsqueda devuelve resultados, ese es el patrón de post-filtrado —trátalo como una corrección prioritaria, no como una preferencia de estilo.
- Verifica el comportamiento del pool de conexiones contra RLS o configuraciones de alcance de sesión. Confirma que
current_setting(o su equivalente) se restablece en cada checkout de conexión, no solo en la primera conexión —un contexto de tenant obsoleto en una conexión reutilizada es un camino real de filtración. - Prueba con una consulta adversaria. Crea deliberadamente una consulta que probablemente coincida estrechamente con el contenido de otro tenant (nombres de productos similares, formulación similar) y confirma cero resultados entre tenants, no solo “el resultado principal parecía bien”.
- Mide el recall@k filtrado por bucket de tamaño de tenant, no solo de forma agregada —un número global de recall puede verse saludable mientras tus tenants más pequeños son silenciosamente mal atendidos, exactamente el fallo que la prueba ilustrativa anterior demostró.
- Confirma que las capas de caché respetan el alcance del tenant. Un caché con clave solo en el texto de la consulta, sin el tenant ID como parte de la clave, puede servir los resultados en caché de un tenant a otro —una omisión fácil cuando el caché se añade después de que la lógica de retrieval ya existe.
Errores comunes
Tratar el filtrado de tenants como una preocupación solo de la capa de aplicación. Si lo único que está entre una consulta y los datos de otro tenant es una línea de código de aplicación, a un refactoring de ser eliminada o eludida, esa es una garantía frágil. Empuja el límite hacia abajo a la capa de base de datos o de índice donde la tecnología subyacente lo soporte.
Reutilizar conexiones del pool sin restablecer el contexto de tenant de alcance de sesión. RLS y current_setting son solo tan seguros como el ciclo de vida de la conexión a su alrededor —verifica explícitamente, con una prueba, que la solicitud de un nuevo tenant en una conexión reutilizada no pueda heredar el contexto del tenant anterior.
Dimensionar ef_search (o el parámetro equivalente de presupuesto de búsqueda ANN) una vez, globalmente, y nunca revisarlo por tenant. Un valor ajustado contra el volumen de datos de tu tenant más grande es muy probablemente insuficiente para el recall filtrado de tus tenants más pequeños, exactamente como muestran los números anteriores.
Asumir que una métrica de recall agregada representa a cada tenant de manera justa. El recall promedio de un índice compartido puede verse completamente saludable mientras tenants específicos —generalmente los más pequeños— obtienen silenciosamente peores resultados. Desglosa las mediciones de recall por bucket de tamaño de tenant, no solo por promedio general, de la misma manera que evaluar un pipeline RAG siempre debe medirse en los patrones de tráfico que realmente importan, no en un agregado conveniente.
Olvidar que el caché y el logging también pueden filtrar límites de tenants. Una clave de caché sin un componente de tenant, o un log de depuración que captura resultados de búsqueda pre-filtrado, pueden deshacer una implementación de filtrado que de otro modo sería correcta. Audita toda la ruta de solicitudes, no solo la consulta en sí.
Conclusión
El aislamiento de tenants en RAG no es un problema de filtrado que resuelves una vez en el código de aplicación —es una propiedad que debe aplicarse estructuralmente, en la capa de base de datos o de índice, para que una sola cláusula WHERE olvidada o un paso de filtrado faltante no pueda convertirse en una filtración de datos. El pre-filtrado, la seguridad a nivel de fila y (para tus tenants más grandes o más sensibles) los índices dedicados son los mecanismos que hacen del aislamiento una garantía en lugar de una convención. Y una vez que el aislamiento es sólido, mide el costo de recall que introduce por tenant, no solo de forma agregada —los tenants que más probablemente sean servidos silenciosamente mal por un índice compartido y filtrado son exactamente los más pequeños, que generalmente es el último lugar donde alguien piensa mirar.

