15 KiB
RAG Multi-Tenant / Scope Utente & Ruolo
Documentazione Tecnica — Vocero S2S v0.2.11+
Data: 01/09/2026 Ambito: Modulo RAG Server-Side (
speech_to_speech.RAG) + Realtime WebSocket
1. Obiettivo
Questa modifica introduce un meccanismo di isolamento dello scope nel RAG del pipeline Speech-to-Speech, per permettere a clienti diversi (utenti loggati, ruoli) di ricevere solo i chunk di Knowledge Base a loro autorizzati, senza condividere informazioni private tra sessioni diverse.
Prima di questo rilascio il RAG era unico e globale: ogni sessione WS poteva vedere l'intera KB condivisa. Ora è possibile filtrare il retrieval per:
user_id(utente logico singolo)roles(lista ruoli: agente, supervisore, backoffice…)allowed_source_prefixes(prefissi custom sullesourcedei chunk)- Metadati arbitrari (
metadata_filtercon match esatto e*_inper liste OR)
I filtri vengono applicati PRIMA del ranking della similarità del coseno (tramite maschera booleana NumPy), quindi non c'è perdita di accuratezza né decadimento prestazioni apprezzabile.
2. Confronto: sistema precedente vs sistema nuovo
| Criterio | Prima (≤ v0.2.11 vanilla) | Dopo (v0.2.11 + RAG scoping) |
|---|---|---|
| Retrieval scope | Singleton _GLOBAL_RAG → tutti vedono tutto |
Sempre singleton, ma con search() che applica maschera di scope per-sessione |
| Isolamento dati | ❌ Nessuno: clienti diversi condividono chunk privati (PII) | ✅ Ogni sessione WS dichiara user_id/roles/prefixes; solo chunk autorizzati sono candidati al ranking |
| Uso memoria | 1 indice, 1 modello embedding | 1 indice, 1 modello embedding (nessun aumento — filtro è O(n) con numpy mask) |
| Popolamento KB | source libera, metadata opzionale |
source e metadata devono rispettare una convenzione per i filtri (vedi §4) |
| Modo per dichiarare scope | ❌ Non esisteva | ✅ WebSocket query-string al momento della connessione |
| Test filtri via HTTP | ❌ /v1/rag/search accettava solo query/top_k/threshold |
✅ /v1/rag/search ora accetta anche allowed_source_prefixes e metadata_filter |
| Aggiornamento scope durante sessione | — | ⚠️ Possibile solo modificando runtime_config (non esposto da session.update — vedi §9.1) |
| Hook LLM | _inject_rag_context chiamava search(query, top_k, threshold) |
Stesso hook, ma passa anche allowed_prefixes e meta_filter dedotti da runtime_config |
| Logging retrieval | Log conteneva count + sources_summary |
Log include anche scope_prefixes e scope_meta per audit e debug |
3. Modifiche al codebase
| File | Tipo | Descrizione |
|---|---|---|
src/speech_to_speech/RAG/retriever.py |
Core search | Firma search(...) estesa con allowed_source_prefixes e metadata_filter. Applicazione maschera numpy booleana prima del ranking. Supporto *_in per match OR su liste metadata. |
src/speech_to_speech/RAG/router.py |
HTTP API | SearchRequest e endpoint POST /v1/rag/search propagano i nuovi filtri. |
src/speech_to_speech/api/openai_realtime/runtime_config.py |
Sessione | Aggiunti 3 campi in RuntimeConfig: rag_user_id, rag_roles (CSV coerced → list), rag_allowed_source_prefixes (CSV coerced → list). |
src/speech_to_speech/api/openai_realtime/websocket_router.py |
WS claim | In realtime_endpoint, subito dopo register() legge i parametri di scope dalla query-string del WS e li scrive nel ConnState.runtime_config della sessione. |
src/speech_to_speech/LLM/base_openai_compatible_language_model.py |
LLM hook | _inject_rag_context(...) ora riceve runtime_config e costruisce allowed_prefixes (shared + roles + user + custom) e metadata_filter (user_id, roles_in), passandoli a search(). Log di inject include ora lo scope. |
Nessun file eliminato; backward-compatibility garantita (tutti i nuovi parametri hanno
default None, quindi i clienti che non passano scope continuano a vedere TUTTA la KB
come prima — comportamento "legacy").
4. Convenzioni: come taggare chunk per utente/ruolo
4.1 Convenzione sul campo source (OBBLIGATORIA se usi i prefissi)
La parte iniziale di source determina lo scope di visibilità tramite il filtro
allowed_source_prefixes. Usa questi prefissi standard:
users/<id>/<resto_del_path> # ← solo l'utente <id> vede questi chunk
roles/<nome_ruolo>/<resto_del_path> # ← solo utenti con ruolo <nome_ruolo>
shared/<resto_del_path> # ← tutti vedono (equivalente a public/)
public/<resto_del_path> # ← tutti vedono
<altro_qualsiasi>/<path> # ← visibile SOLO se passato esplicitamente in
# rag_allowed_source_prefixes al WS connect
Esempi validi:
users/123/preferenze_utente
users/123/anamnesi_2026
roles/agente/procedure/apri_ticket
roles/supervisore/report/kpi_mensili
shared/intro_generale
public/orari_helpdesk
kb_cliente_alpha/contratti/clusola_23 # ← richiede prefixes=kb_cliente_alpha/ in WS
4.2 Convenzione su metadata (consigliata, usata da metadata_filter)
| Chiave | Tipo | Semantica |
|---|---|---|
user_id |
str/int |
Identificatore utente proprietario. Il filtro automatico WS usa match ESATTO. |
roles |
list[str] |
Ruoli autorizzati. Il filtro automatico WS usa roles_in: [...] (match OR: basta uno). |
scopes |
list[str] |
Opzionale (non usato dal filtro automatico — per logiche custom). |
Esempi JSON validi:
{"user_id": "123", "note": "dati sensibili"}
{"roles": ["agente", "supervisore"], "version": 3}
4.3 I due filtri non si escludono — lavorano in AND
allowed_source_prefixes e metadata_filter vengono uniti con AND
(logico): un chunk passa se sia inizia con almeno un prefisso consentito
sia soddisfa il metadata filter (se entrambi sono valorizzati).
💡 Consiglio pratico: usa solo uno dei due per non complicarti. Il sistema WS di default usa entrambi per maggiore safety: se sbagli il prefisso il metadata filter ti protegge comunque da leak, e viceversa.
5. Come si usa
5.1 Caricare chunk con scope
Usare /v1/rag/add/document (splitta automaticamente) o /v1/rag/add/chunks
(pre-chunkizzati):
# ---------- Utente singolo 123 ----------
curl -X POST http://127.0.0.1:12345/v1/rag/add/document \
-H 'Content-Type: application/json' \
-d '{
"text": "Preferenza utente 123: caffè senza zucchero, allergia alle noci.",
"source": "users/123/preferenze_utente",
"metadata": {"user_id": "123"}
}'
# ---------- Visibile a ruoli agente + supervisore ----------
curl -X POST http://127.0.0.1:12345/v1/rag/add/document \
-H 'Content-Type: application/json' \
-d '{
"text": "Procedura: per aprire ticket mail a supporto@example.com con ID cliente.",
"source": "roles/agente/procedure/apri_ticket",
"metadata": {"roles": ["agente", "supervisore"]}
}'
# ---------- Visibile a TUTTI ----------
curl -X POST http://127.0.0.1:12345/v1/rag/add/document \
-H 'Content-Type: application/json' \
-d '{
"text": "Orari helpdesk: lun-ven 9-18.",
"source": "shared/orari_helpdesk",
"metadata": {"scopes": ["public"]}
}'
# ---------- Scope custom (es. un intero cliente alpha) ----------
curl -X POST http://127.0.0.1:12345/v1/rag/add/document \
-H 'Content-Type: application/json' \
-d '{
"text": "Clausola 23 del contratto Alpha: penale 5% per ritardo > 10gg.",
"source": "kb_cliente_alpha/contratti/clausola_23",
"metadata": {"customer": "alpha"}
}'
5.2 Connettere il WebSocket dichiarando lo scope
Il sistema di scope viene popolato al momento della connessione WS tramite
query-string (più semplice e standard rispetto a custom field dentro
session.update).
| Parametro WS | Alias | Esempio | Spiegazione |
|---|---|---|---|
user_id |
rag_user_id |
user_id=123 |
Id utente. Genera prefisso users/123/ e filtro metadata.user_id=="123". |
roles |
rag_roles |
roles=agente,supervisore |
Lista CSV ruoli. Genera prefissi roles/agente/, roles/supervisore/ e filtro metadata.roles ∩ [agente,supervisore] ≠ ∅. |
prefixes |
allowed_source_prefixes / rag_allowed_source_prefixes |
prefixes=shared/,kb_cliente_alpha/ |
Lista CSV prefissi custom AGGIUNTIVI (oltre a user/ruolo/shared) che vuoi forzare visibili. |
Esempio URL WS — Utente 123, ruolo agente, deve vedere anche kb_cliente_alpha/
ws://host:12345/v1/realtime?user_id=123&roles=agente&prefixes=kb_cliente_alpha/
Cosa viene incluso AUTOMATICAMENTE da questo URL:
allowed_source_prefixes = [
"shared/", # sempre (builtin pubblico)
"public/", # sempre (builtin pubblico)
"roles/agente/", # da roles=agente
"users/123/", # da user_id=123
"kb_cliente_alpha/" # da prefixes=
]
metadata_filter = {
"roles_in": ["agente"], # da roles=agente
"user_id": "123" # da user_id=123
}
I due filtri vengono passati al search(). Risultato: il retrieval può solo
pescare chunk autorizzati.
5.3 Test via HTTP /v1/rag/search
Per debuggare senza aprire un WS, usa l'endpoint HTTP passando esplicitamente i filtri:
# Cerca come se fossi "utente 123 con ruolo agente"
curl -sS -X POST http://127.0.0.1:12345/v1/rag/search \
-H 'Content-Type: application/json' \
-d '{
"query": "come apro un ticket",
"top_k": 5,
"threshold": 0.05,
"allowed_source_prefixes": ["shared/", "users/123/", "roles/agente/"],
"metadata_filter": {
"user_id": "123",
"roles_in": ["agente", "supervisore"]
}
}' | python3 -m json.tool
Risposta (struttura invariata, solo il pool di chunk su cui viene cercato cambia):
{
"query": "come apro un ticket",
"count": 1,
"results": [
{
"text": "Procedura: per aprire ticket mail a supporto@example.com...",
"source": "roles/agente/procedure/apri_ticket",
"chunk_index": 0,
"score": 0.71,
"metadata": {"roles": ["agente", "supervisore"]}
}
]
}
5.4 Cleanup
Rimuovere tutti i chunk di un utente:
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}'
Rimuovere tutti i chunk di un ruolo:
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}'
Ricaricare l'indice dopo modifiche a file in kb/:
curl -X POST http://127.0.0.1:12345/v1/rag/reload \
-H 'Content-Type: application/json' \
-d '{"force_rebuild": true}'
6. Logging & Debug
Cosa cercare nei log PM2 della pipeline:
| Momento | Livello | Cosa vedrai |
|---|---|---|
| WS connessione con scope | INFO | RAG scope caricato da WS — user_id=X roles=Y prefixes=Z |
| Scope parsing fallisce | WARNING | errore applicazione scope RAG da query-string: ... |
| Nessun chunk trovato per lo scope | DEBUG | nessun chunk rilevante per turn=... scope_prefixes=... scope_meta=... |
| Retrieval OK (inject nel prompt) | INFO | iniettati N chunk per turn=... (scope_prefixes=..., scope_meta=...) |
Comodo per vedere gli scope attuali in esecuzione:
pm2 logs start_pipeline_rag --lines 200 | grep -i "RAG scope"
7. Esempio end-to-end completo
Scenario: Un CRM esterno apre 2 sessioni WS: una per l'utente 123 (con email privata nel RAG) e una per utente 456.
# 1) Popolo KB
curl -X POST http://127.0.0.1:12345/v1/rag/upsert/document -H 'Content-Type: application/json' \
-d '{"text":"Utenza 123: mario.rossi@secretmail.example","source":"users/123/contatti","metadata":{"user_id":"123"}}'
curl -X POST http://127.0.0.1:12345/v1/rag/upsert/document -H 'Content-Type: application/json' \
-d '{"text":"Utenza 456: lucia.bianchi@altro.example","source":"users/456/contatti","metadata":{"user_id":"456"}}'
curl -X POST http://127.0.0.1:12345/v1/rag/upsert/document -H 'Content-Type: application/json' \
-d '{"text":"Orari segreteria 9-18","source":"shared/orari"}'
# 2) Utente 123 si connette
ws://127.0.0.1:12345/v1/realtime?user_id=123
Utente 123 dice a voce: "Qual è la mia email?" → RAG restituisce la sua, non restituisce quella di 456 (filtrata).
# 3) Utente 456 si connette
ws://127.0.0.1:12345/v1/realtime?user_id=456
Stessa domanda → RAG restituisce la mail di 456, non vede 123.
Entrambe vedono shared/orari perché viene incluso di default.
8. Backward compatibility
- Senza parametri WS: continua a funzionare come prima — nessun filtro viene applicato, tutta la KB è visibile. Utile per scenari legacy.
- API
/v1/rag/*esistenti: invariata la loro firma; i nuovi campi sono opzionali (Optional[...]), quindi script pre-esistenti non si rompono. - File persistiti su disco: nessun cambiamento a
_dynamic.jsonl,_chunks.jsonl,_index.npz— formato identico.
9. Limiti attuali & possibili evoluzioni
9.1 Scope non modificabile durante la sessione
Lo scope oggi viene impostato solo al ws.accept() tramite query-string.
Una volta connesso il WS, non puoi cambiare user_id/roles con session.update
— dovresti disconnettere e riconnettere.
Perché: session.update è tipizzato RealtimeSessionCreateRequest di
openai-python, quindi non vogliamo rompere la compatibilità con gli SDK
attaccando campi custom non ufficiali.
Come sbloccarlo (opzione futura): creare un evento custom
session.scope_update (non standard) in RealtimeService.parse_client_event
che aggiornarebbe ConnState.runtime_config.rag_*.
9.2 Isolamento soft (logico), non hard
Questa è la "Soluzione A": un solo indice condiviso, con filtro vettorizzato Python/NumPy. È sufficiente per il 90% dei casi. Non protegge da bug che saltano il filtro (es. una patch futura che dimentica di passarlo).
Se servisse l'isolamento a livello di processo/indice (es. dati sanitari o
finanziari), serve la "Soluzione B": registry di RAGRetriever multipli,
uno per tenant, con indici NPZ separati.
9.3 Ruoli multipli e permessi gerarchici
Oggi il filtro roles_in è un OR piatto. Non esiste la nozione di gerarchia
(es. supervisore eredita tutti i permessi di agente).
Workaround: quando popoli metadata.roles scrivi tutti i ruoli che
possono vedere il chunk, non solo il ruolo "base". Es.:
{"roles": ["agente", "supervisore", "admin"]} # invece di solo ["agente"]
9.4 Cache embeddings
La mask viene calcolata ad ogni search(). È estremamente veloce per
n < 500.000 chunk; oltre, si potrebbe precalcolare una bitmask per scope
e riutilizzarla per sessioni dello stesso tipo.
10. Riferimenti codice
- Core filtro: RAGRetriever.search()
- Modello richiesta HTTP: SearchRequest
- Campi RuntimeConfig: RuntimeConfig.rag_*
- Query-string WS handler: realtime_endpoint scope parsing
- Hook LLM con injection: _inject_rag_context