16 KiB
RAG Multi-Tenant / Alcance Usuario & Rol
Documentación Técnica — Vocero S2S v0.2.11+
Fecha: 01/09/2026 Ámbito: Módulo RAG Server-Side (
speech_to_speech.RAG) + WebSocket en Tiempo Real
1. Objetivo
Esta modificación introduce un mecanismo de aislamiento del alcance (scope) en el RAG del pipeline Speech-to-Speech, para permitir que distintos clientes (usuarios autenticados, roles) reciban solo los chunks de la Knowledge Base autorizados, sin compartir información privada entre sesiones distintas.
Antes de este release el RAG era único y global: cada sesión WS podía ver toda la KB compartida. Ahora se puede filtrar la búsqueda por:
user_id(usuario lógico individual)roles(lista de roles: agente, supervisor, backoffice…)allowed_source_prefixes(prefijos personalizados en el camposource)- Metadatos arbitrarios (
metadata_filtercon coincidencia exacta y sufijo*_inpara listas OR)
Los filtros se aplican ANTES del ranking de similaridad del coseno (mediante máscara booleana NumPy), así que no hay pérdida de precisión ni degradación de rendimiento apreciable.
2. Comparativa: sistema anterior vs sistema nuevo
| Criterio | Antes (≤ v0.2.11 vanilla) | Después (v0.2.11 + RAG scoping) |
|---|---|---|
| Alcance retrieval | Singleton _GLOBAL_RAG → todo el mundo ve todo |
Sigue siendo singleton, pero search() aplica máscara de scope por sesión |
| Aislamiento datos | ❌ Ninguno: clientes distintos comparten chunks privados (PII) | ✅ Cada sesión WS declara user_id/roles/prefijos; solo los chunks autorizados son candidatos al ranking |
| Uso memoria | 1 índice, 1 modelo embedding | 1 índice, 1 modelo embedding (sin aumento — el filtro es O(n) con máscara numpy) |
| Población KB | source libre, metadata opcional |
source y metadata deben seguir una convención para los filtros (véase §4) |
| Declarar el scope | ❌ No existía | ✅ Query-string del WebSocket en el momento de la conexión |
| Probar filtros por HTTP | ❌ /v1/rag/search solo aceptaba query/top_k/threshold |
✅ /v1/rag/search ahora acepta también allowed_source_prefixes y metadata_filter |
| Cambiar scope en caliente | — | ⚠️ Solo modificando runtime_config (no expuesto por session.update — véase §9.1) |
| Hook LLM | _inject_rag_context llamaba search(query, top_k, threshold) |
El mismo hook, pero pasa además allowed_prefixes y meta_filter deducidos de runtime_config |
| Logging retrieval | El log contenía count + sources_summary |
El log incluye también scope_prefixes y scope_meta para auditoría y depuración |
3. Cambios en el codebase
| Archivo | Tipo | Descripción |
|---|---|---|
src/speech_to_speech/RAG/retriever.py |
Core búsqueda | Firma de search(...) extendida con allowed_source_prefixes y metadata_filter. Aplica máscara booleana numpy antes del ranking. Soporte *_in para coincidencia OR sobre listas en metadata. |
src/speech_to_speech/RAG/router.py |
API HTTP | SearchRequest y endpoint POST /v1/rag/search propagan los nuevos filtros. |
src/speech_to_speech/api/openai_realtime/runtime_config.py |
Sesión | Añadidos 3 campos en RuntimeConfig: rag_user_id, rag_roles (CSV → list), rag_allowed_source_prefixes (CSV → list). |
src/speech_to_speech/api/openai_realtime/websocket_router.py |
WS claim | Dentro de realtime_endpoint, justo después de register(), lee los parámetros de scope desde la query-string del WS y los escribe en ConnState.runtime_config de la sesión. |
src/speech_to_speech/LLM/base_openai_compatible_language_model.py |
Hook LLM | _inject_rag_context(...) ahora recibe runtime_config y construye allowed_prefixes (shared + roles + user + custom) y metadata_filter (user_id, roles_in), pasándolos a search(). El log de inject incluye ahora también el scope. |
Ningún archivo eliminado; la compatibilidad con versiones anteriores está
garantizada (todos los nuevos parámetros tienen valor None por defecto, así
que los clientes que no pasan scope siguen viendo TODA la KB igual que
antes — comportamiento "legacy").
4. Convenciones: cómo etiquetar chunks por usuario/rol
4.1 Convención en el campo source (OBLIGATORIA si usas prefijos)
La parte inicial de source determina el alcance de visibilidad a través del
filtro allowed_source_prefixes. Usa estos prefijos estándar:
users/<id>/<ruta> # ← solo el usuario <id> ve estos chunks
roles/<nombre_rol>/<ruta> # ← solo usuarios con el rol <nombre_rol>
shared/<ruta> # ← todo el mundo lo ve (equivale a public/)
public/<ruta> # ← todo el mundo lo ve
<cualquier_otro>/<ruta> # ← visible SOLO si se pasa explícitamente en
# rag_allowed_source_prefixes al conectar el WS
Ejemplos válidos:
users/123/preferencias_usuario
users/123/anamnesis_2026
roles/agente/procedimiento/abrir_ticket
roles/supervisor/informes/kpi_mensuales
shared/intro_general
public/horarios_helpdesk
kb_cliente_alpha/contratos/clausula_23 # ← necesita prefixes=kb_cliente_alpha/ en WS
4.2 Convención en metadata (recomendada, usada por metadata_filter)
| Clave | Tipo | Semántica |
|---|---|---|
user_id |
str/int |
Identificador del usuario propietario. El filtro automático del WS usa coincidencia EXACTA. |
roles |
list[str] |
Roles autorizados. El filtro automático del WS usa roles_in: [...] (coincidencia OR: basta uno). |
scopes |
list[str] |
Opcional (no usado por el filtro automático — para lógicas personalizadas). |
Ejemplos JSON válidos:
{"user_id": "123", "nota": "datos sensibles"}
{"roles": ["agente", "supervisor"], "version": 3}
4.3 Los dos filtros no se excluyen — trabajan en AND
allowed_source_prefixes y metadata_filter se combinan con AND (lógico):
un chunk pasa si ambos coinciden.
💡 Consejo práctico: usa solo uno de los dos para no complicarte. El sistema WS por defecto usa ambos para mayor seguridad: si te equivocas con el prefijo, el metadata filter te protege igualmente contra fugas de información, y viceversa.
5. Cómo usarlo
5.1 Cargar chunks con alcance
Usar /v1/rag/add/document (splittea automáticamente) o
/v1/rag/add/chunks (pre-chunkizados):
# ---------- Usuario individual 123 ----------
curl -X POST http://127.0.0.1:12345/v1/rag/add/document \
-H 'Content-Type: application/json' \
-d '{
"text": "Preferencia usuario 123: café sin azúcar, alergia a los frutos secos.",
"source": "users/123/preferencias_usuario",
"metadata": {"user_id": "123"}
}'
# ---------- Visible a roles agente + supervisor ----------
curl -X POST http://127.0.0.1:12345/v1/rag/add/document \
-H 'Content-Type: application/json' \
-d '{
"text": "Procedimiento: para abrir ticket enviar mail a support@example.com con ID cliente.",
"source": "roles/agente/procedimiento/abrir_ticket",
"metadata": {"roles": ["agente", "supervisor"]}
}'
# ---------- Visible a TODOS ----------
curl -X POST http://127.0.0.1:12345/v1/rag/add/document \
-H 'Content-Type: application/json' \
-d '{
"text": "Horarios helpdesk: lun-vie 9-18.",
"source": "shared/horarios_helpdesk",
"metadata": {"scopes": ["public"]}
}'
# ---------- Alcance custom (ej. cliente alpha completo) ----------
curl -X POST http://127.0.0.1:12345/v1/rag/add/document \
-H 'Content-Type: application/json' \
-d '{
"text": "Cláusula 23 contrato Alpha: penalización 5% por retraso > 10 días.",
"source": "kb_cliente_alpha/contratos/clausula_23",
"metadata": {"customer": "alpha"}
}'
5.2 Conectar el WebSocket declarando el alcance
El sistema de scope se rellena en el momento de la conexión WS mediante
query-string (más sencillo y estándar que meter campos custom dentro de
session.update).
| Parámetro WS | Alias | Ejemplo | Explicación |
|---|---|---|---|
user_id |
rag_user_id |
user_id=123 |
Id usuario. Genera prefijo users/123/ y filtro metadata.user_id=="123". |
roles |
rag_roles |
roles=agente,supervisor |
Lista CSV de roles. Genera prefijos roles/agente/, roles/supervisor/ y filtro metadata.roles ∩ [agente,supervisor] ≠ ∅. |
prefixes |
allowed_source_prefixes / rag_allowed_source_prefixes |
prefixes=shared/,kb_cliente_alpha/ |
Lista CSV de prefijos custom ADICIONALES (además de user/rol/shared) que quieres forzar visibles. |
Ejemplo URL WS — Usuario 123, rol agente, quiere ver también kb_cliente_alpha/
ws://host:12345/v1/realtime?user_id=123&roles=agente&prefixes=kb_cliente_alpha/
Qué se incluye AUTOMÁTICAMENTE con esta URL:
allowed_source_prefixes = [
"shared/", # siempre (público builtin)
"public/", # siempre (público builtin)
"roles/agente/", # de roles=agente
"users/123/", # de user_id=123
"kb_cliente_alpha/" # de prefixes=
]
metadata_filter = {
"roles_in": ["agente"], # de roles=agente
"user_id": "123" # de user_id=123
}
Ambos filtros se pasan a search(). Resultado: la búsqueda solo puede pescar
chunks autorizados.
5.3 Pruebas por HTTP /v1/rag/search
Para depurar sin abrir un WS, usa el endpoint HTTP pasando explícitamente los filtros:
# Busca como si fuese "usuario 123 con rol agente"
curl -sS -X POST http://127.0.0.1:12345/v1/rag/search \
-H 'Content-Type: application/json' \
-d '{
"query": "cómo abro un ticket",
"top_k": 5,
"threshold": 0.05,
"allowed_source_prefixes": ["shared/", "users/123/", "roles/agente/"],
"metadata_filter": {
"user_id": "123",
"roles_in": ["agente", "supervisor"]
}
}' | python3 -m json.tool
Respuesta (estructura igual; solo cambia el pool de chunks sobre el que se busca):
{
"query": "cómo abro un ticket",
"count": 1,
"results": [
{
"text": "Procedimiento: para abrir ticket enviar mail a support@example.com...",
"source": "roles/agente/procedimiento/abrir_ticket",
"chunk_index": 0,
"score": 0.71,
"metadata": {"roles": ["agente", "supervisor"]}
}
]
}
5.4 Limpieza
Eliminar todos los chunks de un usuario:
curl -X POST http://127.0.0.1:12345/v1/rag/remove \
-H 'Content-Type: application/json' \
-d '{"source_prefix": "users/123/", "exact": false, "persist": true}'
Eliminar todos los chunks de un rol:
curl -X POST http://127.0.0.1:12345/v1/rag/remove \
-H 'Content-Type: application/json' \
-d '{"source_prefix": "roles/backoffice/", "exact": false, "persist": true}'
Recargar el índice tras editar archivos en kb/:
curl -X POST http://127.0.0.1:12345/v1/rag/reload \
-H 'Content-Type: application/json' \
-d '{"force_rebuild": true}'
6. Logging & Depuración
Qué buscar en los logs PM2 de la pipeline:
| Momento | Nivel | Verás |
|---|---|---|
| Conexión WS con scope | INFO | RAG scope cargado desde WS — user_id=X roles=Y prefixes=Z |
| Fallo al parsear el scope | WARNING | error al aplicar scope RAG desde query-string: ... |
| Ningún chunk para ese scope | DEBUG | ningún chunk relevante para turn=... scope_prefixes=... scope_meta=... |
| Retrieval OK (inject prompt) | INFO | inyectados N chunks para turn=... (scope_prefixes=..., scope_meta=...) |
Útil para ver los scopes activos en ejecución:
pm2 logs start_pipeline_rag --lines 200 | grep -i "RAG scope"
7. Ejemplo completo end-to-end
Escenario: Un CRM externo abre 2 sesiones WS: una para el usuario 123 (con email privado en el RAG) y otra para el usuario 456.
# 1) Poblar KB
curl -X POST http://127.0.0.1:12345/v1/rag/upsert/document -H 'Content-Type: application/json' \
-d '{"text":"Cuenta 123: mario.rossi@secretmail.example","source":"users/123/contactos","metadata":{"user_id":"123"}}'
curl -X POST http://127.0.0.1:12345/v1/rag/upsert/document -H 'Content-Type: application/json' \
-d '{"text":"Cuenta 456: lucia.bianchi@otro.example","source":"users/456/contactos","metadata":{"user_id":"456"}}'
curl -X POST http://127.0.0.1:12345/v1/rag/upsert/document -H 'Content-Type: application/json' \
-d '{"text":"Horarios secretaría 9-18","source":"shared/horarios"}'
# 2) Usuario 123 se conecta
ws://127.0.0.1:12345/v1/realtime?user_id=123
El usuario 123 pregunta por voz: "¿Cuál es mi correo?" → el RAG devuelve el suyo, NO devuelve el correo del 456 (filtrado).
# 3) Usuario 456 se conecta
ws://127.0.0.1:12345/v1/realtime?user_id=456
Misma pregunta → RAG devuelve el correo del 456, no ve el del 123.
Ambos ven shared/horarios porque se incluye por defecto.
8. Compatibilidad con versiones anteriores
- Sin parámetros WS: sigue funcionando igual que antes — no se aplica filtro alguno, toda la KB es visible. Útil para escenarios legacy.
- API
/v1/rag/*existentes: firma invariable; los nuevos campos son opcionales (Optional[...]), así que scripts preexistentes no se rompen. - Archivos persistidos en disco: ningún cambio a
_dynamic.jsonl,_chunks.jsonl,_index.npz— formato idéntico.
9. Límites actuales y posibles evoluciones
9.1 Scope inmutable durante la sesión
Actualmente el scope solo se puede fijar en ws.accept() por query-string.
Una vez conectado el WS no puedes cambiar user_id/roles a través de
session.update — tendrías que desconectar y reconectar.
Motivo: session.update está tipado como RealtimeSessionCreateRequest
del paquete openai-python, así que no queremos romper compatibilidad con los
SDKs adjuntando campos custom no oficiales.
Cómo desbloquearlo (opción futura): crear un evento custom
session.scope_update (no estándar) en RealtimeService.parse_client_event
que actualizaría ConnState.runtime_config.rag_*.
9.2 Aislamiento soft (lógico), no hard
Esta es la "Solución A": un solo índice compartido, con filtro vectorizado Python/NumPy. Suficiente para el 90 % de los casos. No protege de bugs que se salten el filtro (p. ej. una parche futuro que olvide pasarlo).
Si hiciera falta aislamiento a nivel de proceso/índice (datos sanitarios o
financieros), hace falta la "Solución B": registro de múltiples
RAGRetriever, uno por tenant, con índices NPZ separados.
9.3 Roles múltiples y permisos jerárquicos
Hoy el filtro roles_in es un OR plano. No existe la noción de jerarquía
(p. ej. supervisor hereda todos los permisos de agente).
Solución alternativa: al rellenar metadata.roles escribe todos los roles
que pueden ver el chunk, no solo el rol "base". Ej.:
{"roles": ["agente", "supervisor", "admin"]} # en vez de solo ["agente"]
9.4 Cache de embeddings
La máscara se recalculo en cada search(). Es extremadamente rápida para
n < 500.000 chunks; por encima, se podría precalcular una bitmask por scope
y reutilizarla para sesiones del mismo tipo.
10. Referencias de código
- Filtro core: RAGRetriever.search()
- Modelo petición HTTP: SearchRequest
- Campos RuntimeConfig: RuntimeConfig.rag_*
- Query-string handler WS: realtime_endpoint parsing scope
- Hook LLM con inyección: _inject_rag_context