vocero-s2s/docs/RAG_SERVER_SIDE.md
valenti b5f82fb48c
Some checks are pending
CI / ruff (push) Waiting to run
CI / mypy (push) Waiting to run
CI / pytest (push) Waiting to run
CI / package (push) Waiting to run
CI / Install smoke (${{ matrix.label }}) (linux, ubuntu-latest) (push) Blocked by required conditions
CI / Install smoke (${{ matrix.label }}) (macos-arm64, macos-14) (push) Blocked by required conditions
first git
2026-08-26 11:30:14 +00:00

28 KiB
Raw Blame History

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):

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:

{"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 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].
0.150.25 → recall alto, recuperi quasi tutto
0.350.45 → precisione alta, solo match certi
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:
system → concatenato alle istruzioni system (consigliato)
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 le righe seguenti (alla fine del comando speech-to-speech):

#!/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):

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 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, 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 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 Export pubblici (singleton + dataclass)
RAG/retriever.py Core: chunking, embedding, persistenza NPZ, search
arguments_classes/rag_arguments.py Dataclass HfArgumentParser 12 parametri
s2s_pipeline.py (main(), righe 1029-1059) Setup singleton globale + build index
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 Gruppo optional-deps rag
kb/README.md Scaffold iniziale KB
kb/01_faq_producto.md Esempio FAQ spagnolo
kb/02_politicas_internas.md Esempio politiche spagnolo
start_pipeline.sh Aggiungere flag --rag_* al comando (vedi §6)
start_pipeline_rag.sh Script preconfigurato (variabili d'ambiente + tutti i flag RAG)
RAG/router.py NUOVO — Router FastAPI /v1/rag/* (7 endpoint HTTP) per amministrazione dinamica
api/openai_realtime/websocket_router.py 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)

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:

{
  "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)

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)

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:

{
  "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

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

🗑️ Eliminare tutti i chunk di un cliente

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)

# 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)

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

Esempio output:

{
  "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

# 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:

{
  "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.

# 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:

{
  "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)
# 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:

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

DELETE tramite upsert:

# 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)

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:

  • 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)