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
381 lines
16 KiB
Markdown
381 lines
16 KiB
Markdown
# 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:**
|
|
```json
|
|
{"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):
|
|
|
|
```bash
|
|
# ---------- 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:
|
|
|
|
```bash
|
|
# 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):
|
|
|
|
```json
|
|
{
|
|
"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:
|
|
```bash
|
|
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:
|
|
```bash
|
|
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/`:
|
|
```bash
|
|
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:
|
|
```bash
|
|
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.
|
|
|
|
```bash
|
|
# 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.:
|
|
|
|
```json
|
|
{"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()](file:///home/azurian/speech-to-speech/src/speech_to_speech/RAG/retriever.py#L380-L489)
|
|
- Modelo petición HTTP: [SearchRequest](file:///home/azurian/speech-to-speech/src/speech_to_speech/RAG/router.py#L71-L82)
|
|
- Campos RuntimeConfig: [RuntimeConfig.rag_*](file:///home/azurian/speech-to-speech/src/speech_to_speech/api/openai_realtime/runtime_config.py#L40-L78)
|
|
- Query-string handler WS: [realtime_endpoint parsing scope](file:///home/azurian/speech-to-speech/src/speech_to_speech/api/openai_realtime/websocket_router.py#L626-L667)
|
|
- Hook LLM con inyección: [_inject_rag_context](file:///home/azurian/speech-to-speech/src/speech_to_speech/LLM/base_openai_compatible_language_model.py#L292-L389)
|