# RAG Server-Side — Guida Ufficiale

> **Feature**: Retrieval Augmented Generation integrato nella pipeline speech-to-speech.
> **Approccio**: 2 — Injection trasparente lato server (nessuna modifica lato client).
> **Versione minima speech-to-speech**: `0.2.11`

---

## 1. Panoramica

L'RAG server-side arricchisce **automaticamente** ogni risposta dell'LLM con brani rilevanti estratti da una knowledge base locale. L'intero ciclo avviene in modo invisibile al client Realtime:

```
Utente parla → STT → 🟡 RAG RETRIEVAL (hook interno) → LLM → TTS → Audio all'utente
                      ↓
                  kb/*.md, kb/*.txt, kb/*.jsonl
                      ↓
                  top-k chunk iniettati nel prompt
```

### Vantaggi
- ✅ Zero modifiche lato client — funziona con qualunque SDK Realtime
- ✅ Riuso del singleton globale (1 sola copia degli embeddings per N pipeline parallele)
- ✅ Persistenza dell'indice NPZ — ricostruzione solo se i documenti cambiano
- ✅ 100% compatibile con i log già esistenti (`LLM REQUEST PROMPT` include i chunk iniettati)
- ✅ Supporto multilingue (IT/ES/EN configurabile)

---

## 2. Installazione dipendenze

Le dipendenze RAG sono opzionali (gruppo `rag` in [pyproject.toml](file:///home/azurian/speech-to-speech/pyproject.toml#L92-L94)):

```bash
cd /home/azurian/speech-to-speech
uv pip install -e ".[rag]"
```

**Contenuto del gruppo**:
- `sentence-transformers>=3.0.0` (porta automaticamente `torch`, `numpy`, `transformers` già presenti)

> ✅ **Su DGX Spark GB10**: il modello embedding viene caricato nativamente su CUDA (detected da `--rag_device auto`, default).

---

## 3. Struttura Knowledge Base

La KB risiede nella cartella configurata dal parametro `--rag_kb_path` (default: `./kb`).

```
kb/
├── README.md                              ← istruzioni scaffold (auto-generato)
├── 01_faq_producto.md                     ← esempio spagnolo incluso
├── 02_politicas_internas.md               ← esempio spagnolo incluso
│
├── manual/                                ← sotto-cartelle supportate
│   ├── 01_instalacion.md
│   └── 02_facturacion.txt
│
├── datos/
│   └── clientes.jsonl                     ← formato pre-chunkizzato
│
├── _index.npz                             ← ⚙️ indice generato (NON modificare)
└── _chunks.jsonl                          ← ⚙️ catalogo chunk (NON modificare)
```

### Formati supportati

#### A. File Markdown / TXT (raccomandato, zero sforzo)

Qualsiasi `*.md` o `*.txt` nella cartella o sottocartelle viene:
1. Letto in UTF-8 (fallback latin-1 con sostituzione errori)
2. Splittato automaticamente in chunk:
   - **chunk size** default: `512` caratteri (parametro `--rag_chunk_size`)
   - **overlap** default: `64` caratteri (parametro `--rag_chunk_overlap`)
   - Algoritmo: splitter ricorsivo per separatori `\n\n → \n → . ? ! ; , → spazio → carattere`
3. Ogni chunk riceve `source = path_relativo#chunk_index`

#### B. JSONL pre-chunkizzato (controllo fine)

Se preferisci gestire chunk e metadata manualmente (es. da estrazione PDF strutturata), crea `*.jsonl` con **una riga per chunk**:

```jsonl
{"text": "Horario soporte lun-vier 09 a 18h", "source": "faq_horarios", "chunk_index": 0, "metadata": {"categoria": "soporte", "pagina": 12}}
{"text": "Devolución 14 días naturales", "source": "faq_compras", "chunk_index": 0, "metadata": {"categoria": "ventas"}}
```

Campi supportati:
| Campo | Obbligatorio | Note |
|---|---|---|
| `text` | ✅ | Corpo del chunk (stringa) |
| `source` | ❌ | Default: `nome_file.jsonl#rigaN` |
| `chunk_index` | ❌ | Default: numero riga 0-based |
| qualsiasi altro | ❌ | Salvato in `chunk.metadata` e riportato nei log |

---

## 4. Persistenza indice NPZ

Per evitare di ricalcolare centinaia/migliaia di embeddings ad ogni avvio:

### Primo avvio
```
file md/txt/jsonl → chunking → embedding → salva:
  kb/_index.npz   (matrice numpy float32 N × embedding_dim)
  kb/_chunks.jsonl (testo, source, metadata per ogni riga)
```
Tempo stimato: ~500 chunk/s su GB10 con MiniLM-L12-v2.

### Avvii successivi
```
Se esiste _index.npz E _chunks.jsonl E le dimensioni coincidono:
  → carica direttamente da disco (<1 secondo)
Altrimenti:
  → ricostruisce da zero
```

### Forzare ricostruzione
Usa **uno** di questi metodi quando aggiungi/modifichi documenti:
1. **Flag CLI**: aggiungi `--rag_force_rebuild` all'avvio (consigliato)
2. **Manuale**: cancella `kb/_index.npz` e `kb/_chunks.jsonl`

---

## 5. Configurazione (CLI / JSON)

Tutti i parametri sono definiti in [rag_arguments.py](file:///home/azurian/speech-to-speech/src/speech_to_speech/arguments_classes/rag_arguments.py) e sono accessibili:
- Via **flag CLI** nel comando `speech-to-speech ... --rag_enabled`
- Via **file JSON** passato come unico argomento
- Via `HfArgumentParser` (pattern standard del progetto)

### Tabella parametri

| Flag CLI | Default | Descrizione |
|---|---|---|
| `--rag_enabled` | `False` | **Master switch**. Senza questo flag, tutto il resto viene ignorato. |
| `--rag_kb_path` | `./kb` | Path assoluto/relativo della cartella KB. |
| `--rag_embedding_model` | `paraphrase-multilingual-MiniLM-L12-v2` | Modello HuggingFace `sentence-transformers`. *Multilingue*, ottimo compromesso qualità/velocità. |
| `--rag_device` | `auto` | `auto` / `cuda` / `cpu` / `mps`. |
| `--rag_top_k` | `3` | Numero massimo di chunk iniettati per turno. **Non superare 5-6 se TTS output lungo** (crescono i token di contesto). |
| `--rag_threshold` | `0.25` | Soglia minima similarità coseno. Range `[0,1]`.<br>• `0.15`–`0.25` → recall alto, recuperi quasi tutto<br>• `0.35`–`0.45` → precisione alta, solo match certi<br>• `0.5+` → molto restrittivo |
| `--rag_language` | `es` | Lingua header injection: `es`, `it`, `en`. Cambia l'intestazione del blocco RAG passato all'LLM. |
| `--rag_inject_as` | `system` | Dove iniettare i risultati:<br>• `system` → concatenato alle istruzioni system (**consigliato**)<br>• `user` → accodato all'ultimo messaggio utente |
| `--rag_chunk_size` | `512` | Caratteri massimi per chunk (solo md/txt). |
| `--rag_chunk_overlap` | `64` | Overlap caratteri tra chunk adiacenti. |
| `--rag_embedding_batch_size` | `32` | Batch size embedding durante build indice. |
| `--rag_force_rebuild` | `False` | Se `True`, ignora l'indice NPZ e rigenera tutto. |

### Modelli embedding consigliati

| Modello | Lingue | Dim embedding | Velocità GB10 | Migliore per |
|---|---|---|---|---|
| `paraphrase-multilingual-MiniLM-L12-v2` (default) | 50+ | 384 | ⚡⚡⚡ | IT/ES/EN mix, KB generiche |
| `hiiamsid/sentence_similarity_spanish_es` | Solo ES | 768 | ⚡⚡ | KB solo spagnola |
| `BAAI/bge-m3` | Multilingue | 1024 | ⚡ | KB grandi, query complesse |
| `intfloat/multilingual-e5-large-instruct` | Multilingue | 1024 | ⚡ | Query complesse, istruzioni |

---

## 6. Integrazione in start_pipeline.sh

Aggiungi al tuo [start_pipeline.sh](file:///home/azurian/speech-to-speech/start_pipeline.sh) le righe seguenti (alla fine del comando `speech-to-speech`):

```bash
#!/bin/bash
# ... (flags esistenti: llm_backend, model_name, base_url, ecc.)

# ========== RAG SERVER-SIDE ==========
  --rag_enabled \
  --rag_kb_path ./kb \
  --rag_top_k 3 \
  --rag_threshold 0.30 \
  --rag_language es \
  --rag_inject_as system \
  --rag_device auto
#  --rag_force_rebuild  # ⚠️ COMMENTA dopo aver ricostruito l'indice!
```

**Esempio start completo** (con i parametri che già usi):
```bash
uv run speech-to-speech \
  --llm_backend chat-completions \
  --chat_completions_handler_base_url "$LLM_BASE_URL" \
  --chat_completions_handler_model_name "$LLM_MODEL" \
  --chat_completions_handler_api_key "$LLM_API_KEY" \
  --mode realtime \
  --realtime_host 0.0.0.0 --realtime_port 8000 \
  --tts qwen3 --qwen3_tts_model_name "$TTS_MODEL" \
  --stt whisper --whisper_stt_model_name "$STT_MODEL" \
  \
  --rag_enabled \
  --rag_kb_path ./kb \
  --rag_top_k 3 \
  --rag_threshold 0.30 \
  --rag_language es \
  --rag_inject_as system
```

---

## 7. Architettura interna — Sequenza operazioni

### 7.1 Setup (una tantum ad avvio)
Avviene in [s2s_pipeline.py](file:///home/azurian/speech-to-speech/src/speech_to_speech/s2s_pipeline.py#L1029-L1059) dentro `main()`:

```
main() 
  ├─► if rag_enabled == True:
  │     ├─► RAGRetriever(...)  → carica modello sentence-transformers
  │     ├─► .build_index()     → NPZ o rebuild da zero
  │     │     ├─► _load_index()       se esiste → carica immediato
  │     │     └─► _load_text_files() + _load_jsonl() + encode() + _save_index()
  │     └─► set_global_rag(rag)  → registra singleton globale
  └─► build_pipeline(...)
        └─► ogni LLM handler, nel suo setup():  self.rag = get_global_rag()
```

### 7.2 Per ogni turno utente (runtime)
Hook in [base_openai_compatible_language_model.py](file:///home/azurian/speech-to-speech/src/speech_to_speech/LLM/base_openai_compatible_language_model.py#L609-L613), riga **610**:

```
process(request: LLMIn)
  ├─► build active_chat
  ├─► _apply_config(instructions, wants_audio)   → system message con le istruzioni
  ├─► _inject_rag_context(active_chat, turn_id, turn_revision)   ← 🔴 NOSTRO HOOK
  │     ├─► (1) query = estrai ultimo testo utente da .buffer
  │     ├─► (2) results = rag.search(query, top_k, threshold)
  │     │         ├─► embedding query (1 vettore)
  │     │         ├─► @ (matrice embeddings @ query.T)  → coseno similarity
  │     │         ├─► argpartition top-k + argsort score
  │     │         └─► filtro per threshold
  │     ├─► (3) se results vuoti → log debug, NIENTE injection
  │     ├─► (4) altrimenti formatta in blocco testuale:
  │     │        "Fragmentos relevantes recuperados..."
  │     │        "[1] (source=xxx.md, score=0.672)\n<testo>"
  │     ├─► (5a) inject_as=system → aggiungi come system message
  │     └─► (5b) inject_as=user   → aggiungi come user message finale
  ├─► resolve_auto_language(lang prompt)
  ├─► _generate() →
  │     ├─► LOG: LLM REQUEST PROMPT  ← include i chunk RAG!
  │     ├─► stream LLM
  │     └─► LOG: LLM RESPONSE  ← risposta finale generata col contesto
  └─► yield chunks → TTS
```

---

## 8. Messaggi di log

Tutti i log RAG usano lo stesso format della pipeline (prefix `pipeline X`, livello INFO o DEBUG).

### Setup OK
```
RAG: Inizializzazione modello embedding=paraphrase-multilingual-MiniLM-L12-v2 device=cuda su kb_path=/home/kb
RAG: Indice caricato da disco: 27 chunk (shape=(27, 384)).
RAG: Attivo. kb=/home/kb top_k=3 soglia=0.300 inject_as=system lingua=es chunk=512
RAG hook attivo in BaseOpenAICompatibleHandler (inject_as=system, lingua=es)
```

### Setup: nessun documento
```
RAG: Nessun documento trovato in /home/kb. Indice vuoto (retrieval non inietterà niente).
```

### Retrieval riuscito
```
RAG: iniettati 2 chunk per turn=turn_91a75 rev=1 — 01_faq_producto.md#c1(0.67); 02_politicas_internas.md#c0(0.52)
```

### Nessun match
```
RAG: nessun chunk rilevante per turn=turn_91a75 (query='Hola, ¿cómo te llamas?' soglia=0.300)
```

### Fallimento search (non blocca la pipeline!)
```
RAG search fallita per turn=turn_91a75 rev=1: <eccezione>
```

---

## 9. Debug & troubleshooting

### Sintomo 1: "RAG non inietta niente, anche se so che il documento c'è"
1. Controlla i log — che messaggio compare? `iniettati X` o `nessun chunk`?
2. Se `nessun chunk` → abbassa la soglia: `--rag_threshold 0.20` o anche `0.15`.
3. Aumenta top_k: `--rag_top_k 5`.
4. Ricostruisci indice: aggiungi `--rag_force_rebuild` (magari hai modificato i file dopo il primo avvio).
5. Testa direttamente: confronta il testo della domanda vs il testo del chunk — i modelli MiniLM possono avere scarsi risultati con frasi troppo colloquiali. **Soluzione**: aumenta overlap o scrivi chunk con domande+risposte esplicite.

### Sintomo 2: "Primo avvio lentissimo / crash CUDA OOM"
1. Riduci batch size embedding: `--rag_embedding_batch_size 4` (default 32).
2. Se hai migliaia di chunk, prendi in considerazione `paraphrase-multilingual-MiniLM-L12-v2` (384 dim, metà memoria di bge-m3).

### Sintomo 3: "L'LLM ignora i chunk e inventa fatti"
1. Rinforza le `instructions` di sistema (via session.update) con un prompt tipo:
   > *"Responde SOLO con información contenuta nei Fragmentos relevantes recuperados. Si no hay información, di 'No tengo información sobre eso'."*
2. Passa a `--rag_inject_as user` (alcuni modelli rispettano di più i blocchi injection dentro i messaggi utente).
3. Aumenta la **soglia** (riduci falsi positivi): `--rag_threshold 0.40`.
4. Verifica che i chunk abbiano senso (elimina intestazioni Markdown, metadati non pertinenti).

### Sintomo 4: "Il log LLM REQUEST PROMPT non mostra i chunk"
Verifica:
- `--rag_enabled` è effettivamente passato (log `RAG: Attivo` in avvio?)
- L'handler LLM è una sottoclasse di `BaseOpenAICompatibleHandler` (chat-completions e responses-api lo sono; mlx-lm locale e transformers no — usano [language_model_handler.py](file:///home/azurian/speech-to-speech/src/speech_to_speech/LLM/language_model_handler.py) che NON condivide la base; per questi ultimi il RAG al momento non è agganciato).

---

## 10. Esempi di sessioni reali

### Caso: FAQ prodotto in spagnolo
- **Utente audio**: *"¿Cuánto tiempo tengo para solicitar un reembolso?"*
- **STT**: *"¿Cuánto tiempo tengo para solicitar un reembolso?"*
- **RAG retrieval**:
  ```
  Score 0.71 → 01_faq_producto.md#c3: "¿Ofrecéis reembolsos? Sí. Todos los planes tienen un periodo de devolución de 14 días naturales..."
  Score 0.23 → 02_politicas_internas.md#c1: "Retención de registros: datos usuario dado de baja 6 meses..."  ← scartato da threshold=0.30
  ```
- **Testo iniettato nel system**:
  ```
  Fragmentos relevantes recuperados de la base de conocimientos:
  [1] (source=01_faq_producto.md, score=0.712)
  ¿Ofrecéis reembolsos?
  
  Sí. Todos los planes tienen un periodo de devolución de **14 días naturales**
  desde la compra, sin necesidad de justificación. Pasado ese plazo, los reembolsos
  se evalúan caso por caso por el equipo de soporte.
  ```
- **Risposta LLM**: *"Tienes 14 días naturales desde la compra para solicitar un reembolso sin necesidad de dar justificaciones. Pasado ese plazo, el equipo de soporte lo evalúa caso por caso."*
- **TTS**: l'audio viene generato e spedito al client.

---

## 11. File del progetto coinvolti

| File | Ruolo |
|---|---|
| [RAG/\_\_init\_\_.py](file:///home/azurian/speech-to-speech/src/speech_to_speech/RAG/__init__.py) | Export pubblici (singleton + dataclass) |
| [RAG/retriever.py](file:///home/azurian/speech-to-speech/src/speech_to_speech/RAG/retriever.py) | Core: chunking, embedding, persistenza NPZ, search |
| [arguments_classes/rag_arguments.py](file:///home/azurian/speech-to-speech/src/speech_to_speech/arguments_classes/rag_arguments.py) | Dataclass HfArgumentParser 12 parametri |
| [s2s_pipeline.py](file:///home/azurian/speech-to-speech/src/speech_to_speech/s2s_pipeline.py) (`main()`, righe 1029-1059) | Setup singleton globale + build index |
| [base_openai_compatible_language_model.py](file:///home/azurian/speech-to-speech/src/speech_to_speech/LLM/base_openai_compatible_language_model.py) | Import singleton (riga 41); setup hook (riga 163-166); injection (riga 610); helper `_extract_last_user_text` + `_inject_rag_context` (righe 274-347) |
| [pyproject.toml](file:///home/azurian/speech-to-speech/pyproject.toml#L92-L94) | Gruppo optional-deps `rag` |
| [kb/README.md](file:///home/azurian/speech-to-speech/kb/README.md) | Scaffold iniziale KB |
| [kb/01_faq_producto.md](file:///home/azurian/speech-to-speech/kb/01_faq_producto.md) | Esempio FAQ spagnolo |
| [kb/02_politicas_internas.md](file:///home/azurian/speech-to-speech/kb/02_politicas_internas.md) | Esempio politiche spagnolo |
| [start_pipeline.sh](file:///home/azurian/speech-to-speech/start_pipeline.sh) | Aggiungere flag --rag_* al comando (vedi §6) |
| [start_pipeline_rag.sh](file:///home/azurian/speech-to-speech/start_pipeline_rag.sh) | Script preconfigurato (variabili d'ambiente + tutti i flag RAG) |
| [RAG/router.py](file:///home/azurian/speech-to-speech/src/speech_to_speech/RAG/router.py) | **NUOVO** — Router FastAPI `/v1/rag/*` (7 endpoint HTTP) per amministrazione dinamica |
| [api/openai_realtime/websocket_router.py](file:///home/azurian/speech-to-speech/src/speech_to_speech/api/openai_realtime/websocket_router.py#L511-L526) | Montaggio condizionale del router RAG nello stesso FastAPI del websocket |

---

## 13. 🆕 API HTTP dinamiche (popola la KB a caldo)

La pipeline espone **7 endpoint JSON** sullo stesso server che serve il websocket
(`ws://host:12345` → HTTP su `http://host:12345`).
Base path comune: `/v1/rag`.

> ⚠️ Se RAG è disattivato (no `--rag_enabled`) tutti gli endpoint rispondono
> **503 Service Unavailable** con `{"code": "RAG_NOT_ENABLED", ...}`.

### 13.1 Endpoint disponibili

| Metodo | Path | Descrizione |
|---|---|---|
| `GET`  | `/v1/rag/status` | Stato: numero chunk, embedding dim, sorgenti, device, soglie |
| `GET`  | `/v1/rag/sources` | **LIST** — sorgenti uniche + chunk count per ognuna |
| `GET`  | `/v1/rag/chunks` | **LIST** con query params: filtri, paginazione, ordinamento per rilevanza |
| `POST` | `/v1/rag/chunks/list` | Stesso listing, ma body JSON (consigliato per query complesse) |
| `POST` | `/v1/rag/chunks/update` | **UPDATE** singolo chunk (testo / metadata / sorgente) |
| `POST` | `/v1/rag/upsert/document` | **UPSERT atomico** di un documento intero (delete old + insert new). ✅ Uso più comune per CRUD |
| `POST` | `/v1/rag/search` | Cerca chunk con lo stesso algoritmo usato per l'iniezione (debug/test) |
| `POST` | `/v1/rag/add/document` | INSERT puro di testo libero (chunking auto) |
| `POST` | `/v1/rag/add/chunks` | INSERT puro di chunk preformattati |
| `POST` | `/v1/rag/remove` | **REMOVE**: per prefisso o exact match |
| `POST` | `/v1/rag/reload` | Ricostruzione totale indice da file in `kb/` |

> 💡 **Pattern CRUD consigliato**:
> - CREATE → `/upsert/document` (idempotente: se la source non esiste la crea)
> - READ   → `/sources` (lista sorgenti) + `/chunks?source_exact=X` (dettaglio)
> - UPDATE → `/upsert/document` (stessa source, testo nuovo → sostituzione atomica)
> - DELETE → `/upsert/document` con `text: ""` oppure `/remove` con `exact: true`

---

### 13.2 Esempi curl

> Base URL: `http://127.0.0.1:12345/v1/rag` (cambia porta e host se hai modificato lo script).

#### 📤 Aggiungere un documento (testo libero, chunking automatico)

```bash
curl -sS -X POST http://127.0.0.1:12345/v1/rag/add/document \
  -H 'Content-Type: application/json' \
  -d '{
    "source": "crm/cliente_456_nota_20260826",
    "text": "Cliente: Javier García, id=456. Plan contratado: Premium Anual, renovación 15/02/2027. Contacto: +34 600 111 222, javier@empresa.es. Notas: prefiere que le llamen por la mañana antes de las 11h. Ha reportado un incidente con la factura número 2026-08-114 el 20/08/2026 que ya fue resuelto con un descuento del 15% aplicado en la siguiente factura.",
    "metadata": {"cliente_id": 456, "pais": "es", "categoria": "crm"},
    "persist": true,
    "dynamic": true
  }' | jq
```

**Risposta:**
```json
{
  "added": 1,
  "sources": ["crm/cliente_456_nota_20260826"],
  "total_after": 10
}
```

> 💡 **`persist: true`** → il chunk viene scritto anche in `kb/_dynamic.jsonl`,
> quindi **al prossimo riavvio** della pipeline sarà ancora presente.
> Imposta a `false` per chunk temporanei (solo sessione corrente).

#### 📤 Aggiungere N chunk pre-formattati (es. da DB)

```bash
curl -sS -X POST http://127.0.0.1:12345/v1/rag/add/chunks \
  -H 'Content-Type: application/json' \
  -d '{
    "items": [
      {"text": "Pedido 8942 — 25/08/2026. Productos: Teclado RGB Pro x1, Ratón Ergonómico x1. Estado: enviado, tracking 1Z999AA10123456784. Entrega estimada: 27/08/2026.",
       "source": "pedidos/cliente_456/8942",
       "metadata": {"pedido_id": 8942, "estado": "enviado"}},
      {"text": "Dirección de envío habitual del cliente 456: Av. Diagonal 444, puerta 3, 08013 Barcelona, España. Contacto entrega: +34 600 111 222.",
       "source": "direcciones/cliente_456/principal",
       "metadata": {"tipo": "envio", "predeterminada": true}}
    ],
    "persist": true,
    "dynamic": true
  }' | jq
```

#### 🔎 Provare il retrieval da HTTP (debug, no audio)

```bash
curl -sS -X POST http://127.0.0.1:12345/v1/rag/search \
  -H 'Content-Type: application/json' \
  -d '{
    "query": "¿Qué pedido tiene Javier García y cuándo llega?",
    "top_k": 3,
    "threshold": 0.25
  }' | jq
```

Risposta con i chunk recuperati e lo score:
```json
{
  "query": "¿Qué pedido tiene Javier García y cuándo llega?",
  "count": 2,
  "results": [
    {"text": "Pedido 8942 — 25/08/2026...",
     "source": "pedidos/cliente_456/8942", "score": 0.813},
    {"text": "Cliente: Javier García, id=456. Plan contratado: Premium Anual...",
     "source": "crm/cliente_456_nota_20260826", "score": 0.742}
  ]
}
```

#### 📊 Ver stato generale

```bash
curl -sS http://127.0.0.1:12345/v1/rag/status | jq
```

#### 🗑️ Eliminare tutti i chunk di un cliente

```bash
curl -sS -X POST http://127.0.0.1:12345/v1/rag/remove \
  -H 'Content-Type: application/json' \
  -d '{"source_prefix": "crm/cliente_456"}' | jq
```

Suggerimento: usa prefissi intelligenti per organizzare la KB:
```
source_prefix = "crm/"          → cancella tutta la rubrica
source_prefix = "pedidos/"      → cancella tutti i dati ordini
source_prefix = "dynamic#"      → cancella tutti i chunk temporanei auto-numerati
```

#### 🛡️ Remove con exact match (solo source ESATTA)

```bash
# Cancella SOLO il documento 'crm/cliente_456_nota_20260826'
# (non cancella 'crm/cliente_456_nota_20260827')
curl -sS -X POST http://127.0.0.1:12345/v1/rag/remove \
  -H 'Content-Type: application/json' \
  -d '{"source_prefix": "crm/cliente_456_nota_20260826", "exact": true}' | jq
```

#### 📋 Lista sorgenti (index inventory)

```bash
# Tutte le sorgenti nell'indice, con conteggio chunk
curl -sS http://127.0.0.1:12345/v1/rag/sources | jq
```

Esempio output:
```json
{
  "count": 5,
  "items": [
    {"source": "01_faq_producto.md", "chunk_count": 5, "has_dynamic": false},
    {"source": "crm/cliente_456_nota_20260826", "chunk_count": 1, "has_dynamic": true},
    {"source": "pedidos/cliente_456/8942", "chunk_count": 1, "has_dynamic": true}
  ]
}
```

#### 🔍 Elencare chunk con filtri e paginazione

```bash
# GET con query params: solo chunk di cliente_456, paged
curl -sS 'http://127.0.0.1:12345/v1/rag/chunks?source_prefix=crm/cliente_456&limit=10&offset=0' | jq

# POST con body JSON: ORDINA per rilevanza su una query + filtro
curl -sS -X POST http://127.0.0.1:12345/v1/rag/chunks/list \
  -H 'Content-Type: application/json' \
  -d '{
    "source_prefix": "crm/",
    "query": "descuento aplicado en factura",
    "min_score": 0.20,
    "limit": 10,
    "include_text": true
  }' | jq
```

Risposta:
```json
{
  "total": 2,
  "offset": 0,
  "limit": 10,
  "query": "descuento aplicado en factura",
  "items": [
    {
      "index": 9,
      "source": "crm/cliente_456_nota_20260826",
      "chunk_index": 1000009,
      "score": 0.731,
      "metadata": {"cliente_id": 456, "categoria": "crm"},
      "is_dynamic": true,
      "text": "Cliente: Javier García... 15% descuento..."
    }
  ]
}
```

Campi speciali:
- `index`: la posizione numerica nel vettore di chunk — passala direttamente a `/chunks/update?index=...`
- `score`: `null` senza query, float 0-1 con ordinamento per rilevanza

#### 🆙 Aggiornare un singolo chunk

Usa `/chunks/update` quando devi modificare **solo un pezzetto specifico** (es. correggere un errore di battitura, cambiare metadata). Per aggiornare **un intero documento** usa invece `/upsert/document`.

```bash
# Opzione A: aggiorna tramite `index` (da list_chunks → campo "index": 9)
curl -sS -X POST http://127.0.0.1:12345/v1/rag/chunks/update \
  -H 'Content-Type: application/json' \
  -d '{
    "index": 9,
    "new_text": "Cliente: Javier García, id=456. Plan Premium Anual, renovación 15/02/2028 (2 años). Descuento 20% acordado en la llamada del 10/08.",
    "new_metadata": {"cliente_id": 456, "categoria": "crm", "ultima_revision": "2026-08-26"}
  }' | jq

# Opzione B: aggiorna tramite (source, chunk_index)
curl -sS -X POST http://127.0.0.1:12345/v1/rag/chunks/update \
  -H 'Content-Type: application/json' \
  -d '{
    "source": "crm/cliente_456_nota_20260826",
    "chunk_index": 1000009,
    "new_source": "crm/cliente_456/nota_principal"
  }' | jq
```

Risposta successo `200`:
```json
{
  "updated": 1,
  "chunk": {
    "index": 9,
    "source": "crm/cliente_456/nota_principal",
    "chunk_index": 1000009,
    "text": "Cliente: Javier García...",
    "metadata": {"cliente_id": 456, "...": "..."},
    "is_dynamic": true
  }
}
```

Errori HTTP possibili:
- `404 CHUNK_NOT_FOUND`
- `409 AMBIGUOUS_SOURCE` → hai passato `source` da sola ma esistono N chunk con stessa source; aggiungi `chunk_index` oppure usa `index`

#### 🔄 Upsert documento (CRUD consigliato)

**Questo è l'endpoint che userai nel 90% dei casi.**
Idempotente sul campo `source`:
- Se la `source` non esiste → CREATE
- Se la `source` esiste → DELETE tutti i chunk vecchi + split del nuovo testo + INSERT
- Se mandi `text: ""` → DELETE atomico (nessun re-insert)

```bash
# CREATE/UPDATE di un documento intero (es. nota cliente)
curl -sS -X POST http://127.0.0.1:12345/v1/rag/upsert/document \
  -H 'Content-Type: application/json' \
  -d '{
    "source": "crm/cliente_456/perfil",
    "text": "Javier García — ID 456. Plan Premium Anual, renovación automática 15/02/2028. Precio: 24€/mes. Email javier@empresa.es. Tel +34 600 111 222. Dirección envío habitual: Av. Diagonal 444, puerta 3, 08013 Barcelona. Notas: siempre prefiere atención por la mañana, antes de las 11h. Caso abierto 2026-08-0124: seguimiento calidad postventa, cerrado con valoración 5/5.",
    "metadata": {"cliente_id": 456, "pais": "es", "actualizado": "2026-08-26"},
    "dynamic": true,
    "persist": true
  }' | jq
```

Risposta:
```json
{
  "removed": 2,
  "added": 1,
  "sources": ["crm/cliente_456/perfil"],
  "total_after": 10,
  "mode": "upsert"
}
```

DELETE tramite upsert:
```bash
# Elimina del tutto tutti i chunk associati a questa source (atomico)
curl -sS -X POST http://127.0.0.1:12345/v1/rag/upsert/document \
  -H 'Content-Type: application/json' \
  -d '{"source": "crm/cliente_456/perfil", "text": ""}' | jq
```


#### 🔄 Ricaricare tutta la KB da file (dopo aver copiato nuovi md/txt)

```bash
curl -sS -X POST http://127.0.0.1:12345/v1/rag/reload \
  -H 'Content-Type: application/json' \
  -d '{"force_rebuild": true}' | jq
```

---

### 13.3 Persistenza cross-riavvio: `kb/_dynamic.jsonl`

Tutti i chunk aggiunti via API con `persist: true` vengono appesi a `kb/_dynamic.jsonl` (un JSON per riga). Al prossimo avvio della pipeline:
1. Viene ricostruito l'indice statico da file md/txt/jsonl
2. **Subito dopo** vengono ricaricati e ri-embeddati anche tutti i chunk da `_dynamic.jsonl`

Quindi la conoscenza aggiunta via HTTP è **durevole** e non si perde riavviando.

---

### 13.4 Thread safety

Tutte le API usano un `threading.RLock()` all'interno di [RAGRetriever](file:///home/azurian/speech-to-speech/src/speech_to_speech/RAG/retriever.py):
- ✅ Più chiamate `/search` possono andare in parallelo durante la conversazione
- ✅ Una `add_document`/`remove` blocca brevemente l'indice solo per le operazioni di vstack/take numpy — retrieval non si perde, viene atteso
- ✅ Persistenza su disco (NPZ + JSONL) avviene fuori dal lock, quindi non rallenta la chiamata conversazionale

---

## 14. Roadmap / miglioramenti possibili

- [ ] **Supporto PDF**: aggiungere `pypdf` o `pdfplumber` in gruppo opzionale
- [ ] **MMR reranking** invece del solo top-k coseno (migliore diversità chunk)
- [ ] **HyDE** (LLM genera query ipotetica + embedding di quella, per query colloquiali)
- [ ] **Cross-encoder reranker** 2-stage per KB grandi (>10k chunk)
- [ ] **Hook su LanguageModelHandler locale** (mlx-lm / transformers) — attualmente l'RAG è attivo solo per backends `chat-completions` e `responses-api`
- [ ] **Watchdog KB**: auto-rebuild indice quando i file cambiano (inotify / watchdog)
