Guía11 min

RAG en producción: permisos en la query, frescura e índices por tenant

Resumen

El chunking arma el índice. En producción el fallo es otro: el tenant sale del auth, no del prompt; cada query va a un namespace o with_tenant(); el filtro de metadata no es un ACL; y un documento vive hasta que lo reindexas. Pinecone, Weaviate y OpenAI Retrieval, verificados el 6 de septiembre de 2026.

PineconeWeaviateOpenAI
Índice RAG con tenants aislados, ACL en la query y reindex incremental

Qué resuelve

Esta pieza se queda en la decisión práctica: qué instalar, qué riesgo agrega y cómo aplicarlo sin romper operación.

La memoria y el RAG de un agente deciden qué recuperar. Esto es la capa que aguanta un SaaS: quién puede ver el chunk, cuándo dejó de ser cierto y de qué tenant sale el índice. Un demo con un PDF único no tiene tenants. Un agente que atiende a 40 clientes sí, y el primer bug de filtro $eq mal puesto filtra… o no.

Pinecone documenta el aislamiento físico por namespace (un tenant, un namespace; upserts y queries siempre apuntan a uno). Weaviate aísla por tenant/shard (with_tenant("tenantA"); un tenant no ve al otro). OpenAI Retrieval filtra atributos antes del search semántico (attribute_filter con eq/in/and/or); no te da un muro entre clientes. Fuentes verificadas el 6 de septiembre de 2026.

Contrato: tenant desde auth, query acotada, ACL en lectura, reindex por documento. Cero tenant en el prompt. Cero índice compartido “con un filtro por si acaso”.

Tres fallos que el chunking no cubre

El split de texto es la base. En producción se rompe por otra cosa:

  1. Fuga entre tenants. El modelo pide search(query, tenant_id) y el LLM inventa el tenant_id. O el filtro de metadata {tenant: "acme"} se olvida en un path de retry. Pinecone lo dice claro: un namespace mal elegido no se “corrige” con un $eq después; el filtro de metadata en un namespace de 100 GB escanea los 100 GB (100 RUs si cada tenant pesa 1 GB; el mismo query a un namespace de 1 GB cuesta 1 RU).
  2. Documento zombie. Subes politica-v3.pdf y dejas politica-v2 en el índice. El agente cita el reembolso viejo. No hay TTL mágico en el vector store: frescura = source_mtime / ingested_at + borrar chunks del document_id antes de upsert.
  3. ACL solo al ingest. Indexaste con acl: ["hr"] y seis meses después esa persona salió de HR. La query sigue encontrando el chunk. El permiso se evalúa en la query, con el sujeto del OAuth del usuario, no con un tag que escribiste el día del crawl.

Los techos por tenant cortan spend. El PII cubre qué no debe entrar al índice. Esto cubre que el vecino no lea tu Drive.

El tenant no viaja en el tool call

El ID de tenant sale del auth (JWT org_id, sesión, RunContext), se inyecta en el adapter y no es argumento del tool que ve el modelo. Si el schema de search_docs incluye tenant, el modelo lo alucina. El adapter hace:

async function searchDocs(ctx: AuthCtx, query: string, filter?: AclFilter) {
  const ns = namespaceFor(ctx.orgId); // nunca ctx.args.tenant
  return index.query({ namespace: ns, vector, topK: 8, filter: aclOf(ctx, filter) });
}

Pinecone: namespaces se crean al primer upsert; offboarding = borrar el namespace (operación liviana). Weaviate: multiTenancyConfig.enabled: true (off por defecto); CRUD y search exigen el tenant (with_tenant). autoTenantCreation (v1.25+) crea tenants por typo (TenantOne vs tenantOne vs TenntOne = tres shards). No lo dejes on en ingest de usuarios. Estados ACTIVE / INACTIVE (renombrados de HOT/COLD en v1.26); OFFLOADED pide storage frío.

OpenAI vector stores: un store por tenant, o un store compartido más attribute_filter obligatorio (eq sobre org_id) en cada vectorStores.search. El filtro corre antes del semántico. Si omites el filtro, buscas todo el store.

Query RAG acotada al namespace del tenant autenticado, sin tenant en el prompt

Namespace vs filtro: no son el mismo ACL

EnfoqueAísla de verdadCoste querySirve como ACL de rol
Pinecone 1 namespace / tenantFísico (serverless guarda namespaces aparte)1 RU / GB del namespaceNo: dentro del tenant aún filtras role/doc_class
Weaviate with_tenantShard por tenant; invisible entre tenantsOperación al shardRBAC colección+tenant (v1.29+; default v1.30+)
Metadata $eq tenant en un índice únicoNo. Bug de filtro = fugaPinecone: escanea todo el namespaceFrágil: $in de 10k allowed_user_ids es el anti-patrón oficial
OpenAI attribute_filterLógico, en ese vector storeDepende del storeÚtil para fecha/región/filename; no sustituye un store por cliente

El anti-patrón de Pinecone es literal: un filter.allowed_user_ids: {$in: [user_1 … user_10000]} en un namespace compartido. Usa namespace (o tenant Weaviate) para el cliente; metadata para clase de documento, idioma, document_id, ingested_at. Metadata: strings / number / bool / list of strings; tope 40 KB por record.

ACL de rol (finanzas vs HR) no vive solo en el índice. El sujeto (sub, grupos del IdP) se traduce a un filtro aditivo (doc_class in ctx.classes). Si el filtro queda vacío, no buscas: fail-closed. Un $or que “por si acaso incluye public” es un agujero.

Frescura: borrar, no “upsert encima y ya”

Un chunk no caduca solo. Contrato mínimo:

  1. Cada record lleva document_id, chunk_number, source_mtime (unix) e ingested_at.
  2. Reindex de un doc = listar por prefijo / filtrar document_id → delete → upsert. Pinecone recomienda IDs tenant#document#chunk y list por prefijo para no dejar huérfanos.
  3. Query con frescura: ingested_at >= now - max_age o source_mtime >= cursor del conector. OpenAI: attribute_filter gte/lte sobre unix timestamps (su propio ejemplo de rango de fechas).
  4. Crawl incremental: watermark por fuente (Drive modifiedTime, git HEAD, S3 LastModified). Full rebuild solo cuando cambia el embedder o el splitter.
  5. Delete del tenant = delete namespace / tenants.remove, no un cron que “marca deleted=true” y sigue retrieveando.

Si el conector falla a mitad, no dejes el índice a medias: o job idempotente (delete+upsert del mismo document_id) o no promociones el alias. El agente no tiene que “saber” que hay dos versiones; el índice no debería tenerlas.

Reindex por document_id: borrar chunks viejos, upsert v2, watermark de la fuente

Checklist de query en producción

  • Tenant resuelto en el adapter, logueado, nunca en el system prompt ni en args del LLM.
  • Cada retrieve declara namespace / with_tenant / vector store. Cero default al índice global.
  • Filtro ACL del sujeto (roles actuales, no tags de ingest). Vacío = no query.
  • top_k chico (8–20) + metadata de document_url para citar. Sin URL no se muestra al usuario.
  • Test de fuga: upsert en tenant A, query autenticado como B, expect 0 hits. Esto no lo cubre un eval de “respuesta correcta”.
  • Test de frescura: reemplaza un doc, query, expect solo v2.
  • Offboard: borrar namespace/tenant y revocar el API key de ese org. Weaviate RBAC por tenant no sustituye borrar datos.

El hub de construir agentes arma el loop. El de seguridad y coste es donde vive este contrato cuando el índice ya no es un folder de demo.

FAQ

¿Un filtro de metadata basta para un SaaS B2B? No como aislamiento. Pinecone cobra y escanea el namespace entero; un olvido de filtro cruza clientes. Namespace o with_tenant para el cliente; metadata para el resto.

¿Puedo poner tenant_id en el schema de la tool? No. El modelo lo rellena mal. El adapter lo saca del auth.

¿OpenAI File Search aísla tenants? No por sí. Un vector store por org, o attribute_filter obligatorio en cada search. Sin filtro, el store es un bucket.

¿autoTenantCreation en Weaviate? Útil en batch limpio. Peligroso con IDs de usuario: un typo crea un shard. Créalos en el signup.

¿Qué pasa con documentos “públicos” de la empresa? Índice o namespace de ese tenant, clase internal/public, ACL del sujeto. No un índice global “para todos los clientes del SaaS”.

¿El reindex tiene que ser síncrono en el turno del agente? No. El agente encola document_id; el worker borra+upsert. El retrieve no espera al embedder.

Fuentes