# 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 sulle `source` dei chunk) - **Metadati arbitrari** (`metadata_filter` con match esatto e `*_in` per 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// # ← solo l'utente vede questi chunk roles// # ← solo utenti con ruolo shared/ # ← tutti vedono (equivalente a public/) public/ # ← tutti vedono / # ← 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:** ```json {"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): ```bash # ---------- 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: ```bash # 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): ```json { "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: ```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}' ``` Rimuovere tutti i chunk di un ruolo: ```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}' ``` Ricaricare l'indice dopo modifiche a file in `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 & 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: ```bash 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. ```bash # 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.: ```json {"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()](file:///home/azurian/speech-to-speech/src/speech_to_speech/RAG/retriever.py#L380-L489) - Modello richiesta HTTP: [SearchRequest](file:///home/azurian/speech-to-speech/src/speech_to_speech/RAG/router.py#L71-L82) - Campi RuntimeConfig: [RuntimeConfig.rag_*](file:///home/azurian/speech-to-speech/src/speech_to_speech/api/openai_realtime/runtime_config.py#L40-L78) - Query-string WS handler: [realtime_endpoint scope parsing](file:///home/azurian/speech-to-speech/src/speech_to_speech/api/openai_realtime/websocket_router.py#L626-L667) - Hook LLM con injection: [_inject_rag_context](file:///home/azurian/speech-to-speech/src/speech_to_speech/LLM/base_openai_compatible_language_model.py#L292-L389)