Files
vocero-s2s/docs/RAG_SCOPING.es.md
valenti f45a6e9c54
CI / ruff (push) Has been cancelled
CI / mypy (push) Has been cancelled
CI / pytest (push) Has been cancelled
CI / package (push) Has been cancelled
CI / Install smoke (${{ matrix.label }}) (linux, ubuntu-latest) (push) Has been cancelled
CI / Install smoke (${{ matrix.label }}) (macos-arm64, macos-14) (push) Has been cancelled
before language fix
2026-09-11 09:16:58 +00:00

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 campo source)
  • Metadatos arbitrarios (metadata_filter con coincidencia exacta y sufijo *_in para 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