first git
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

This commit is contained in:
valenti
2026-08-26 11:30:14 +00:00
commit b5f82fb48c
207 changed files with 42776 additions and 0 deletions
+339
View File
@@ -0,0 +1,339 @@
# speech-to-speech — Descripción del Proyecto
> **Paquete**: `speech-to-speech`
> **Versión**: `0.2.11`
> **Autor**: Hugging Face
> **Licencia**: Apache-2.0
> **Python**: 3.10 – 3.12
> **Lema**: Pipeline Speech-to-Speech de baja latencia end-to-end para construir agentes de voz en tiempo real.
---
## 1. ¿Qué es `speech-to-speech`?
`speech-to-speech` es una **pipeline de audio modular y de baja latencia** que convierte la entrada hablada del usuario en una respuesta hablada encadenando cuatro etapas de IA:
**VAD → STT → LLM → TTS**.
De fábrica expone un **endpoint WebSocket compatible con el protocolo OpenAI Realtime** (`ws://host:port/v1/realtime`),
por lo que cualquier navegador o SDK que sepa hablar el protocolo Realtime de OpenAI puede conectarse y empezar a tener
conversaciones naturales en cuestión de segundos — sin escribir una sola línea de código en el servidor.
La pipeline está diseñada para ser **agnóstica del backend en cada etapa**: puedes intercambiar distintos modelos
de STT / LLM / TTS según el hardware disponible (NVIDIA CUDA, Apple Silicon MLX o solo CPU), los idiomas de tus usuarios
y el compromiso entre latencia y calidad que necesites.
Un subsistema opcional integrado de **RAG (Retrieval Augmented Generation) del lado servidor** permite que las
respuestas del LLM se basen en tu propia base de conocimientos privada (documentos Markdown / TXT / JSONL),
mientras permanece 100 % transparente para el cliente. Las actualizaciones dinámicas de la base de conocimientos
se exponen en el mismo servidor HTTP mediante una API REST de 11 endpoints.
---
## 2. Características principales
| Característica | Descripción |
|---|---|
| **Protocolo de voz en tiempo real** | Soporte nativo del WebSocket OpenAI `v1/realtime`: `session.update`, `response.create`, llamadas a funciones, deltas de audio, interrupciones. Compatible directamente con el SDK Realtime oficial y con los "playgrounds" web. |
| **4 modos de ejecución** | `local` (micrófono → altavoces), `socket` (IPC TCP), `websocket` (WS audio crudo), `realtime` (protocolo OpenAI — predeterminado). |
| **6 backends de STT intercambiables** | Whisper / Whisper-MLX / MLX-Audio-Whisper / Faster-Whisper / Parakeet TDT (predeterminado) / Paraformer. |
| **4 backends de LLM intercambiables** | `transformers` (local) · `mlx-lm` (Apple Silicon) · `responses-api` (endpoint OpenAI con tool-calling) · `chat-completions` (cualquier servidor `/v1/chat/completions` compatible con OpenAI). |
| **5 backends de TTS intercambiables** | ChatTTS · Facebook MMS · Pocket (muy pequeño, CPU) · Kokoro · Qwen3-TTS (predeterminado, voz personalizada de 1.7B). |
| **Multiplataforma** | NVIDIA CUDA (Linux), Apple Silicon MLX + MPS (macOS), fallback CPU en cada etapa. |
| **Pool de N pipelines aisladas** | `--num_pipelines N` ejecuta N sesiones independientes en paralelo (cada una con sus propios VAD / STT / LLM / TTS y estado de conversación). Ideal para despliegues pequeños con múltiples conexiones simultáneas. |
| **VAD e interrupción integrados** | Detección de actividad vocal con umbral configurable; finalización del STT por silencio; el LLM se puede interrumpir mientras habla y se cancela de forma limpia mediante un `CancelScope`. |
| **Detección automática de idioma** | A través de `lingua-language-detector` para el idioma de la respuesta del asistente. |
| **Transcripción parcial en vivo** | Parakeet-TDT emite transcripciones parciales cada 500 ms para que los clientes muestren el texto "el usuario está hablando…". |
| **Soporte completo de tool-calling** | Con el backend `responses-api`: entrega en streaming de argumentos de función, tool calls paralelos, voces personalizadas sobre TTS — todo conforme a la superficie de la Responses API de OpenAI. |
| **Inyección RAG opcional en el servidor** | Hook transparente de recuperación en cada turno del LLM. Embeddings con Sentence-Transformers, persistencia del índice NPZ, top-k / umbral coseno configurables, inyección como mensaje del sistema o del usuario. Multilingüe por defecto (español / italiano / inglés listos para usar). |
| **API REST para base de conocimientos dinámica** | 11 endpoints para listar, buscar, hacer upsert, actualizar, eliminar y recargar contenidos de la KB en tiempo real — con persistencia entre reinicios mediante `kb/_dynamic.jsonl`. |
| **Configuración estructurada CLI / JSON** | Cada parámetro es un argumento `@dataclass` de HfArgumentParser con valores predeterminados razonables, o un fichero JSON de configuración completo que puedes pasar como único argumento. |
| **Empaquetado y flujo de publicación PyPI** | Configurado `uv build` + `twine check` + GitHub Actions (la etiqueta `vX.Y.Z` activa la subida). |
---
## 3. Arquitectura a vista de pájaro
Un turno único de conversación en modo **realtime** se ve así:
```
┌─────────────────────────────────────────────────────────┐
│ uvicorn + FastAPI │
│ ┌───────────────────────────────────────────────────┐ │
Mic / Navegador ──► WS │ │ RealtimeService (sesión + rutas + eventos) │ │
(audio entradas + │ │ └───────────────────────────────────────────────────┘ │
eventos) │ │ │
│ │ Pool de pipelines ─► PipelineUnit #1 ─► PipelineUnit #N│
│ └─────────────────────────────────────────────────────────┘
│ │
▼ ▼
┌──────────────────────────────────────────────┐
│ Una unidad de pipeline (una por usuario) │
│ │
│ 1. VAD ──► inicio / fin voz │
│ 2. STT (5 sabores) ──► último texto usuario│
│ │ │
│ ▼ │
│ ┌────────────────────────┐ │
│ │ HOOK BÚSQUEDA RAG │ ◄─── kb/_index.npz
│ │ (inyecta solo si ≥ N) │ + md/txt/jsonl
│ └────────────────────────┘ + _dynamic.jsonl
│ │ │
│ ▼ │
│ 3. LLM (4 sabores) ──► texto + tool calls│
│ │ │
│ ▼ │
│ 4. TTS (5 sabores) ──► PCM audio streaming│
└──────────────────────────────────────────────┘
│
▼
◄── WS audio / delta events
```
Invariantes clave:
- **Cada conexión = una `PipelineUnit`** (asignada de forma atómica; colas y estado aislados; si las N unidades están ocupadas, la conexión N+1 se rechaza).
- **La etapa LLM nunca ve audio crudo**. La pipeline solo envía buffers de texto al manejador del LLM, por lo que cambiar de proveedor o de modelo sigue siendo transparente.
- **La etapa RAG es un efecto lateral puro sobre el búfer de texto**: lee el texto más reciente del usuario, ejecuta un embedding + top-k por coseno + filtro por umbral, y antepone el conocimiento relevante como un mensaje adicional del sistema (o del usuario) — con una línea de registro clara para ver siempre exactamente qué se inyectó.
- **TTS en streaming** (todos los backends modernos): los deltas de audio se emiten a medida que se sintetizan, por lo que el primer byte de una respuesta llega al altavoz mucho antes de que el LLM termine de generar el texto.
---
## 4. Modos de ejecución (`--mode`)
Configurables mediante [ModuleArguments.mode](file:///home/azurian/speech-to-speech/src/speech_to_speech/arguments_classes/module_arguments.py#L11-L16)
(predeterminado: `realtime`).
| Modo | Entrada | Salida | Ideal para |
|---|---|---|---|
| `local` | Micrófono local vía `sounddevice` / `miniaudio` | Altavoces locales | Demos de escritorio, prototipado rápido en portátil. |
| `socket` | Chunks PCM crudos en socket TCP | PCM crudos por socket TCP | Sistemas legacy / embebidos, transporte personalizado. |
| `websocket` | PCM crudo sobre endpoint WS | PCM crudo sobre WS | Frontend a medida mínimo que solo envía audio. |
| `realtime` | WS con protocolo OpenAI Realtime (`/v1/realtime`) | Mismo protocolo + API REST RAG en el mismo servidor HTTP | **Producción e integración con SDK.** Todos los clientes que soportan la Realtime API (OpenAI SDK, playgrounds web, wrappers Swift / Kotlin / JS) se conectan aquí. |
---
## 5. Backends soportados
### 5.1 STT — Speech to Text
| Nombre `--stt` | Familia de modelos | Modelo predeterminado | Ventajas hardware |
|---|---|---|---|
| `whisper` | HuggingFace Transformers Whisper | `distil-whisper/distil-large-v3` | CUDA / CPU |
| `whisper-mlx` | MLX Whisper | `mlx-community/whisper-large-v3-turbo` | Apple Silicon (macOS) |
| `mlx-audio-whisper` | Apple `mlx-audio` | `mlx-community/whisper-large-v3-turbo` | Apple Silicon |
| `faster-whisper` | Whisper cuantizada CTranslate2 | `tiny.en` | CPU con latencia muy baja |
| `parakeet-tdt` | **(predeterminado)** HuggingFace Parakeet TDT | `parakeet-tdt-1.1b` | Streaming amigable + **transcripción parcial en vivo** sobre CUDA |
| `paraformer` | Alibaba Paraformer | `paraformer-zh` | Despliegues solo chino |
### 5.2 LLM — Modelo de lenguaje
| `--llm_backend` | Descripción | Modelo predeterminado |
|---|---|---|
| `transformers` | Generación local HF Transformers en proceso | `Qwen/Qwen3-4B-Instruct-2507` |
| `mlx-lm` | Inferencia local cuantizada 4-bit / 8-bit en Apple Silicon | `mlx-community/...` |
| `responses-api` | **(predeterminado)** Endpoint remoto compatible con OpenAI Responses API (superficie completa de tool-calling + argumentos de función en streaming) | `gpt-5.4-mini` |
| `chat-completions` | Cualquier remoto `/v1/chat/completions` compatible con OpenAI — enchufa vLLM, TGI, Ollama, servidor llama.cpp, SGLang, TabbyAPI, etc. | (auto-detecta vía `base_url + /v1/models`) |
### 5.3 TTS — Text to Speech
| Nombre `--tts` | Backend | Modelo predeterminado | Puntos fuertes |
|---|---|---|---|
| `chatTTS` | ChatTTS (grupo opcional `chattts`) | — | Muy conversacional, inglés + chino, prosodia muy expresiva |
| `facebookMMS` | Facebook MMS (grupo opcional `facebook-mms`) | `facebook/mms-tts-eng` | Ultraligero, cubre más de 1.100 idiomas |
| `pocket` | PocketTTS (pequeño CPU) | — | Sin dependencias en dispositivo, ideal para embebidos / poca RAM |
| `kokoro` | Kokoro TTS (grupo opcional `kokoro`) | — | Estado del arte de TTS neural en inglés y japonés |
| `qwen3` | **(predeterminado)** Qwen3-TTS vía GGML `faster-qwen3-tts` | `Qwen/Qwen3-TTS-12Hz-1.7B-CustomVoice` | Multilingüe, soporta clonación por voz de referencia, tasa de token 12 Hz → latencia ultrabaja. |
Todos los backends de TTS se acceden a través de una interfaz de streaming común, por lo que el resto de la pipeline es agnóstica al backend.
---
## 6. RAG del lado servidor (complemento opcional)
El subsistema RAG está completamente documentado en:
- 🌍 Guía lógica IT/EN: [RAG_SERVER_SIDE.md](file:///home/azurian/speech-to-speech/docs/RAG_SERVER_SIDE.md)
- 🇪🇸 Traducción al español: [RAG_SERVER_SIDE.es.md](file:///home/azurian/speech-to-speech/docs/RAG_SERVER_SIDE.es.md)
En resumen:
```
$ ./start_pipeline_rag.sh # arranca pipeline + API REST RAG
RAG: Inizializzazione modello embedding=paraphrase-multilingual-MiniLM-L12-v2 device=cuda su kb_path=/home/.../kb
RAG: Índice cargado desde disco: 9 chunk (shape=(9, 384)).
RAG: Activo. kb=... top_k=3 umbral=0.250 inject_as=system idioma=es
RAG HTTP API montata su prefix='/v1/rag'
```
Puntos clave:
- **Cero cambios en el cliente** — la inyección ocurre en el servidor, dentro del búfer de chat del LLM, antes de cada turno.
- **Modelos de embedding intercambiables** con un modelo multilingüe predeterminado (`paraphrase-multilingual-MiniLM-L12-v2`, 384 dims) y alternativas recomendadas para español-only y para bases de conocimientos muy grandes.
- **Persistencia del índice NPZ** — se reconstruye solo si el contenido cambió. Soporte de `--rag_force_rebuild`.
- **Tres formatos de fuente**: Markdown / TXT con chunking recursivo automático (tamaño + solapamiento configurables), o JSONL para chunks artesanales.
- **Comportamiento de recuperación configurable**: `--rag_top_k`, `--rag_threshold`, `--rag_inject_as (system/user)`, `--rag_language (es/it/en)`.
- **API REST dinámica thread-safe** (11 endpoints): `/status`, `/sources`, `/chunks`, `/chunks/list`, `/chunks/update`, `/upsert/document`, `/search`, `/add/document`, `/add/chunks`, `/remove`, `/reload`.
- **Persistencia entre reinicios**: las llamadas a la API con `persist=true` se adjuntan atómicamente a `kb/_dynamic.jsonl` y se re-indexan automáticamente en el siguiente arranque.
- **Patrón CRUD idempotente**: `/upsert/document` gestiona crear / reemplazar / eliminar (`text=""`) de forma atómica para que los clientes solo necesiten una URL.
- **Falla con elegancia**: cualquier excepción de recuperación se registra pero nunca rompe el turno de conversación.
---
## 7. Arranque rápido
### 7.1 Instalación (instalación editable estilo PyPI)
```bash
git clone https://github.com/huggingface/speech-to-speech.git
cd speech-to-speech
# Paquete base (incluye STT parakeet-tdt + TTS qwen3 por defecto)
uv pip install -e .
# Opcional: añade el subsistema RAG para recuperación en la KB
uv pip install -e ".[rag]"
# Opcional: selecciona los extras de TTS / STT que quieras
uv pip install -e ".[rag,chattts,kokoro,faster-whisper]"
```
### 7.2 Arrancar en modo Realtime (predeterminado) con un LLM externo
Esta es la configuración más común: el LLM corre en un servidor remoto compatible con OpenAI
(p.ej. `http://127.0.0.1:8001/v1` con vLLM o TGI), STT + TTS corren localmente sobre CUDA / MLX.
Crea un pequeño *wrapper* de shell (véase [start_pipeline.sh](file:///home/azurian/speech-to-speech/start_pipeline.sh) para la plantilla completa):
```bash
#!/bin/bash
set -euo pipefail
LLM_BASE_URL="${LLM_BASE_URL:-http://127.0.0.1:8001/v1}"
LLM_MODEL="${LLM_MODEL:-Qwen/Qwen2.5-14B-Instruct-GPTQ-Int4}"
LLM_API_KEY="${LLM_API_KEY:-placeholder}"
TTS_MODEL="${TTS_MODEL:-Qwen/Qwen3-TTS-12Hz-1.7B-Base}"
STT_MODEL="${STT_MODEL:-distil-whisper/distil-large-v3}"
cd /home/azurian/speech-to-speech
uv run speech-to-speech \
--mode realtime \
--ws_host 0.0.0.0 \
--ws_port 12345 \
--num_pipelines 2 \
\
--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" \
\
--stt whisper \
--whisper_stt_model_name "$STT_MODEL" \
\
--tts qwen3 \
--qwen3_tts_model_name "$TTS_MODEL"
```
Ejecútalo y después conecta cualquier cliente Realtime de OpenAI a:
```
ws://<host>:12345/v1/realtime
```
### 7.3 Misma pipeline + RAG activado
Añade estos flags (véase §6 para la referencia completa y el script auxiliar [start_pipeline_rag.sh](file:///home/azurian/speech-to-speech/start_pipeline_rag.sh)):
```bash
--rag_enabled \
--rag_kb_path ./kb \
--rag_top_k 3 \
--rag_threshold 0.25 \
--rag_language es \
--rag_inject_as system
```
La API REST de RAG aparece inmediatamente en el mismo servidor HTTP:
```bash
curl http://127.0.0.1:12345/v1/rag/status | jq
```
---
## 8. Estructura del proyecto (puntos destacados)
```
speech-to-speech/
├── pyproject.toml ← metadatos del paquete + grupos de deps opcionales
├── LICENSE ← Apache-2.0
├── start_pipeline.sh ← script de arranque de referencia
├── start_pipeline_rag.sh ← script de arranque con RAG activado
│
├── src/speech_to_speech/
│ ├── s2s_pipeline.py ← punto de entrada (main), constructor de pipeline, pool realtime
│ ├── baseHandler.py
│ ├── chat.py
│ ├── pipeline/ ← tipos de cola, CancelScope, tipos de handler
│ │
│ ├── arguments_classes/ ← args HfArgumentParser @dataclass (uno por handler)
│ │ ├── module_arguments.py ← mode / stt / tts / llm_backend / num_pipelines
│ │ ├── rag_arguments.py ← 12 flags específicos de RAG
│ │ └── ... (Whisper, Qwen3, ChatTTS, VAD, …)
│ │
│ ├── STT/ ← seis manejadores STT
│ ├── TTS/ ← cinco manejadores TTS
│ ├── LLM/
│ │ ├── base_openai_compatible_language_model.py ← Hook de inyección RAG + tool-calling completo
│ │ ├── chat_completions_language_model.py
│ │ ├── responses_api_language_model.py
│ │ └── language_model.py ← manejadores locales transformers / mlx-lm
│ │
│ ├── RAG/
│ │ ├── retriever.py ← singleton, embeddings, NPZ, search, CRUD completo
│ │ └── router.py ← 11 endpoints FastAPI /v1/rag
│ │
│ └── api/openai_realtime/
│ ├── websocket_router.py ← FastAPI + Realtime + montaje condicional RAG
│ ├── service.py ← Bucle de eventos Realtime + rutas de sesión / handlers
│ └── ...
│
├── kb/
│ ├── 01_faq_producto.md ← ejemplo FAQ en español (incluido en scaffold)
│ ├── 02_politicas_internas.md ← ejemplo políticas en español
│ ├── README.md ← guía de formatos
│ └── _dynamic.jsonl ← chunks añadidos por API (auto-generado)
│
└── docs/
├── RAG_SERVER_SIDE.md ← Guía RAG en profundidad IT/EN (más de 600 líneas)
└── RAG_SERVER_SIDE.es.md ← Traducción al español
```
---
## 9. Versionado y publicación
El repositorio trae una pipeline completa de lanzamiento a PyPI en
`.github/workflows/publish.yml`:
1. Sube la `version` en [pyproject.toml](file:///home/azurian/speech-to-speech/pyproject.toml#L7)
y `__version__` en [src/speech_to_speech/__init__.py](file:///home/azurian/speech-to-speech/src/speech_to_speech/__init__.py).
2. Fusiona una PR de release que contenga solo esos dos cambios.
3. Etiqueta y sube:
```bash
git checkout main && git pull origin main
git tag -a vX.Y.Z -m "Release vX.Y.Z"
git push origin vX.Y.Z
```
4. El workflow ejecuta automáticamente `uv build` + `twine check --strict` + subida a PyPI.
Consulta [AGENTS.md](file:///home/azurian/speech-to-speech/AGENTS.md) para las reglas de release a nivel de repositorio.
---
## 10. Siguientes pasos
- Empieza con los scripts de arranque: [start_pipeline.sh](file:///home/azurian/speech-to-speech/start_pipeline.sh) (base) y [start_pipeline_rag.sh](file:///home/azurian/speech-to-speech/start_pipeline_rag.sh) (con RAG).
- Profundiza en el subsistema RAG: [RAG_SERVER_SIDE.md](file:///home/azurian/speech-to-speech/docs/RAG_SERVER_SIDE.md) / [RAG_SERVER_SIDE.es.md](file:///home/azurian/speech-to-speech/docs/RAG_SERVER_SIDE.es.md).
- Ajusta los manejadores por etapa leyendo las clases de argumentos en [arguments_classes](file:///home/azurian/speech-to-speech/src/speech_to_speech/arguments_classes) — cada flag tiene su ayuda en línea.
- Construye tu propio cliente Realtime apoyándote en [service.py](file:///home/azurian/speech-to-speech/src/speech_to_speech/api/openai_realtime/service.py) y [websocket_router.py](file:///home/azurian/speech-to-speech/src/speech_to_speech/api/openai_realtime/websocket_router.py).
+337
View File
@@ -0,0 +1,337 @@
# speech-to-speech — Project Overview
> **Package**: `speech-to-speech`
> **Version**: `0.2.11`
> **Author**: Hugging Face
> **License**: Apache-2.0
> **Python**: 3.10 – 3.12
> **Tagline**: Low-latency end-to-end Speech-to-Speech pipeline for building realtime voice agents.
---
## 1. What is `speech-to-speech`?
`speech-to-speech` is a fully modular, **low-latency audio pipeline** that turns a user's spoken input into a spoken reply by chaining four AI stages together:
**VAD → STT → LLM → TTS**.
Out of the box it exposes an **OpenAI-compatible Realtime WebSocket endpoint** (`ws://host:port/v1/realtime`),
so any browser or SDK that knows how to speak the OpenAI Realtime Protocol can connect and start having natural voice conversations in seconds — without writing a single line of server code.
The pipeline is designed to be **backend-agnostic at every stage**: you can plug different STT / LLM / TTS models
depending on the available hardware (NVIDIA CUDA, Apple Silicon MLX, or CPU-only), the language(s) of your users,
and the latency / quality trade-off you need.
A built-in, optional **RAG (Retrieval Augmented Generation) server-side subsystem** lets the LLM answers be grounded
in your own private knowledge base (Markdown / TXT / JSONL documents), while staying 100 % transparent for the client.
Dynamic knowledge base updates are exposed over the same HTTP server via a 11-endpoint REST API.
---
## 2. Key Features
| Feature | Description |
|---|---|
| **Real-time voice protocol** | Native OpenAI `v1/realtime` WebSocket support: `session.update`, `response.create`, function calling, audio deltas, interruptions. Drop-in compatible with the official Realtime SDK and browser playgrounds. |
| **4 execution modes** | `local` (mic → speakers), `socket` (TCP IPC), `websocket` (raw audio WS), `realtime` (OpenAI protocol — default). |
| **6 pluggable STT backends** | Whisper / Whisper-MLX / MLX-Audio-Whisper / Faster-Whisper / Parakeet TDT (default) / Paraformer. |
| **4 pluggable LLM backends** | `transformers` (local) · `mlx-lm` (Apple Silicon) · `responses-api` (OpenAI tool-calling aware endpoint) · `chat-completions` (any OpenAI-compatible `/v1/chat/completions` server). |
| **5 pluggable TTS backends** | ChatTTS · Facebook MMS · Pocket (tiny CPU) · Kokoro · Qwen3-TTS (default, custom voice 1.7B). |
| **Multi-hardware ready** | NVIDIA CUDA (Linux), Apple Silicon MLX + MPS (macOS), CPU fallback for every stage. |
| **Pool of N isolated pipelines** | `--num_pipelines N` runs N independent sessions in parallel (each one with its own VAD / STT / LLM / TTS handlers and conversation state). Ideal for small multi-concurrent deployments. |
| **Built-in VAD & interruption** | Voice-activity detection with configurable threshold and streaming silence → STT finalization; the LLM can be interrupted mid-speech by the user and cancelled cleanly through a `CancelScope`. |
| **Automatic language detection** | Via `lingua-language-detector` for the assistant reply language. |
| **Live partial transcription** | Parakeet-TDT emits live partial transcriptions every 500 ms so clients can display "user is speaking …" text. |
| **Full tool-calling support** | With `responses-api` backend: streamed function argument delivery, parallel tool calls, custom voices over TTS — everything compliant with the OpenAI Responses API surface. |
| **Optional RAG server-side injection** | Transparent retrieval hook on every LLM turn. Sentence-Transformers embeddings, NPZ-index persistence, configurable top-k / cosine threshold, system or user-message injection. Multilingual by default (Spanish / Italian / English out of the box). |
| **Dynamic Knowledge Base REST API** | 11 endpoints to list, search, upsert, update, remove, and reload KB contents at runtime — including cross-restart persistence through `kb/_dynamic.jsonl`. |
| **Structured CLI / JSON configuration** | Every parameter is a `@dataclass` HfArgumentParser argument with sane defaults, or a full JSON config file you can pass in as single argument. |
| **PyPI-ready packaging & release flow** | Configured `uv build` + `twine check` + GitHub Actions publish workflow (tag `vX.Y.Z` triggers upload). |
---
## 3. Architecture at a glance
A single conversation turn in **realtime** mode looks like this:
```
┌─────────────────────────────────────────────────────────┐
│ uvicorn + FastAPI │
│ ┌───────────────────────────────────────────────────┐ │
Mic / Browser ──► WS │ │ RealtimeService (session + routing + events) │ │
(audio in + │ │ └───────────────────────────────────────────────────┘ │
events) │ │ │
│ │ Pipeline pool ──► PipelineUnit #1 ──► PipelineUnit #N│
│ └─────────────────────────────────────────────────────────┘
│ │
▼ ▼
┌──────────────────────────────────────────────┐
│ Single pipeline unit (one per user) │
│ │
│ 1. VAD ──► speech start / end │
│ 2. STT (5 flavours) ──► last user text │
│ │ │
│ ▼ │
│ ┌────────────────────────┐ │
│ │ RAG SEARCH HOOK │ ◄─── kb/_index.npz
│ │ (inject only if ≥ N) │ + md/txt/jsonl
│ └────────────────────────┘ + _dynamic.jsonl
│ │ │
│ ▼ │
│ 3. LLM (4 flavours) ──► text reply + tools│
│ │ │
│ ▼ │
│ 4. TTS (5 flavours) ──► streamed PCM audio│
└──────────────────────────────────────────────┘
│
▼
◄── WS audio / delta events
```
Key invariants:
- **Each connection = one `PipelineUnit`** (allocated atomically; queues and state are isolated; if N units are busy, the `N+1`-th connection is rejected).
- **The LLM stage never sees raw audio**. The pipeline only forwards text buffers to the LLM handler, so model vendors and backend switches remain transparent.
- **The RAG stage is a pure side-effect on the text buffer**: it reads the most recent user text, runs an embedding + cosine top-k + threshold filter, and prepends matching knowledge as an extra system (or user) message — with a clear log line so you can always see exactly what was injected.
- **TTS streams** (all modern backends): audio deltas are emitted as they are synthesised, so the first byte of a reply reaches the speaker well before the LLM has finished generating text.
---
## 4. Execution modes (`--mode`)
Configurable via [ModuleArguments.mode](file:///home/azurian/speech-to-speech/src/speech_to_speech/arguments_classes/module_arguments.py#L11-L16)
(default: `realtime`).
| Mode | Input | Output | Best for |
|---|---|---|---|
| `local` | Local microphone via `sounddevice` / `miniaudio` | Local speakers | Desktop demos, quick prototyping on laptop. |
| `socket` | Raw PCM chunks on TCP socket | Raw PCM on TCP socket | Legacy / embedded setups, custom transport. |
| `websocket` | Raw PCM over WS endpoint | Raw PCM over WS | Minimal custom frontend that just pushes audio. |
| `realtime` | OpenAI Realtime Protocol WS (`/v1/realtime`) | Same protocol + RAG REST API on the same HTTP server | **Production & SDK integration.** All clients that support the Realtime API (OpenAI SDK, web playgrounds, Swift / Kotlin / JS wrappers) connect here. |
---
## 5. Supported backends
### 5.1 STT — Speech to Text
| `--stt` name | Model family | Default model | Device highlights |
|---|---|---|---|
| `whisper` | HuggingFace Transformers Whisper | `distil-whisper/distil-large-v3` | CUDA / CPU |
| `whisper-mlx` | MLX Whisper | `mlx-community/whisper-large-v3-turbo` | Apple Silicon (macOS) |
| `mlx-audio-whisper` | Apple `mlx-audio` | `mlx-community/whisper-large-v3-turbo` | Apple Silicon |
| `faster-whisper` | CTranslate2 Quantized Whisper | `tiny.en` | Very low latency CPU |
| `parakeet-tdt` | **(default)** HuggingFace Parakeet TDT | `parakeet-tdt-1.1b` | Streaming-friendly + **live partial transcription** on CUDA |
| `paraformer` | Alibaba Paraformer | `paraformer-zh` | Chinese-only deployments |
### 5.2 LLM — Language Model
| `--llm_backend` | Description | Default model |
|---|---|---|
| `transformers` | Local, process-bound HF Transformers generation | `Qwen/Qwen3-4B-Instruct-2507` |
| `mlx-lm` | Apple Silicon local 4-bit / 8-bit quantised inference | `mlx-community/...` |
| `responses-api` | **(default)** OpenAI Responses-API compatible remote endpoint (full tool-calling surface + streamed function args) | `gpt-5.4-mini` |
| `chat-completions` | Any OpenAI-compatible `/v1/chat/completions` remote — plug in vLLM, TGI, Ollama, llama.cpp server, SGLang, TabbyAPI, etc. | (auto-detects via `base_url + /v1/models`) |
### 5.3 TTS — Text to Speech
| `--tts` name | Backend | Default model | Strengths |
|---|---|---|---|
| `chatTTS` | ChatTTS (optional group `chattts`) | — | Highly conversational, English + Chinese, very expressive prosody |
| `facebookMMS` | Facebook MMS (optional group `facebook-mms`) | `facebook/mms-tts-eng` | Ultra-lightweight, covers 1,100+ languages |
| `pocket` | PocketTTS (small CPU) | — | Zero-dependency on-device, great for embedded / low-RAM |
| `kokoro` | Kokoro TTS (optional group `kokoro`) | — | State of the art English & Japanese neural TTS |
| `qwen3` | **(default)** Qwen3-TTS via `faster-qwen3-tts` GGML | `Qwen/Qwen3-TTS-12Hz-1.7B-CustomVoice` | Multilingual, supports reference voice cloning, 12 Hz token rate → ultra-low latency. |
All TTS backends are reachable through a common streaming interface so the rest of the pipeline is backend-agnostic.
---
## 6. RAG Server-Side (optional add-on)
The RAG subsystem is fully documented in:
- 🌍 English / Spanish logic: [RAG_SERVER_SIDE.md](file:///home/azurian/speech-to-speech/docs/RAG_SERVER_SIDE.md)
- 🇪🇸 Spanish translation: [RAG_SERVER_SIDE.es.md](file:///home/azurian/speech-to-speech/docs/RAG_SERVER_SIDE.es.md)
At a glance:
```
$ ./start_pipeline_rag.sh # starts pipeline + RAG REST API
RAG: Inizializzazione modello embedding=paraphrase-multilingual-MiniLM-L12-v2 device=cuda su kb_path=/home/.../kb
RAG: Índice cargado desde disco: 9 chunk (shape=(9, 384)).
RAG: Activo. kb=... top_k=3 umbral=0.250 inject_as=system idioma=es
RAG HTTP API montata su prefix='/v1/rag'
```
Highlights:
- **Zero client changes** — injection happens server-side in the LLM chat buffer before every turn.
- **Pluggable embedding models** with a default multilingual model (`paraphrase-multilingual-MiniLM-L12-v2`, 384 dims) and recommended alternatives for Spanish-only and very-large-KB scenarios.
- **NPZ index persistence** — rebuilds only if content changed. Supports `--rag_force_rebuild`.
- **Three source formats**: Markdown / TXT with automatic recursive chunking (size + overlap configurable), or JSONL for hand-crafted chunks.
- **Configurable retrieval behaviour**: `--rag_top_k`, `--rag_threshold`, `--rag_inject_as (system/user)`, `--rag_language (es/it/en)`.
- **Thread-safe dynamic REST API** (11 endpoints): `/status`, `/sources`, `/chunks`, `/chunks/list`, `/chunks/update`, `/upsert/document`, `/search`, `/add/document`, `/add/chunks`, `/remove`, `/reload`.
- **Cross-restart persistence**: API calls with `persist=true` are appended atomically to `kb/_dynamic.jsonl` and automatically re-indexed on the next startup.
- **Idempotent CRUD pattern**: `/upsert/document` handles create / replace / delete (`text=""`) atomically so clients only need one URL.
- **Fails gracefully**: any retrieval exception is logged but never breaks the conversation turn.
---
## 7. Quick start
### 7.1 Install (PyPI-style editable install)
```bash
git clone https://github.com/huggingface/speech-to-speech.git
cd speech-to-speech
# Base package (includes default parakeet-tdt STT + qwen3 TTS)
uv pip install -e .
# Optional: add the RAG subsystem for server-side KB retrieval
uv pip install -e ".[rag]"
# Optional: select which TTS / STT extras you want to bring along
uv pip install -e ".[rag,chattts,kokoro,faster-whisper]"
```
### 7.2 Launch in Realtime mode (default) using an external LLM
This is the most common configuration: LLM runs on a remote OpenAI-compatible server
(e.g. `http://127.0.0.1:8001/v1` with vLLM or TGI), STT + TTS run locally on CUDA / MLX.
Create a small shell wrapper (see [start_pipeline.sh](file:///home/azurian/speech-to-speech/start_pipeline.sh) for the full template):
```bash
#!/bin/bash
set -euo pipefail
LLM_BASE_URL="${LLM_BASE_URL:-http://127.0.0.1:8001/v1}"
LLM_MODEL="${LLM_MODEL:-Qwen/Qwen2.5-14B-Instruct-GPTQ-Int4}"
LLM_API_KEY="${LLM_API_KEY:-placeholder}"
TTS_MODEL="${TTS_MODEL:-Qwen/Qwen3-TTS-12Hz-1.7B-Base}"
STT_MODEL="${STT_MODEL:-distil-whisper/distil-large-v3}"
cd /home/azurian/speech-to-speech
uv run speech-to-speech \
--mode realtime \
--ws_host 0.0.0.0 \
--ws_port 12345 \
--num_pipelines 2 \
\
--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" \
\
--stt whisper \
--whisper_stt_model_name "$STT_MODEL" \
\
--tts qwen3 \
--qwen3_tts_model_name "$TTS_MODEL"
```
Run it, then connect any OpenAI Realtime client to:
```
ws://<host>:12345/v1/realtime
```
### 7.3 Same pipeline + RAG enabled
Append these flags (see §6 for the full reference and the helper script [start_pipeline_rag.sh](file:///home/azurian/speech-to-speech/start_pipeline_rag.sh)):
```bash
--rag_enabled \
--rag_kb_path ./kb \
--rag_top_k 3 \
--rag_threshold 0.25 \
--rag_language es \
--rag_inject_as system
```
The RAG REST API appears immediately on the same HTTP server:
```bash
curl http://127.0.0.1:12345/v1/rag/status | jq
```
---
## 8. Project layout (highlights)
```
speech-to-speech/
├── pyproject.toml ← package metadata + optional deps groups
├── LICENSE ← Apache-2.0
├── start_pipeline.sh ← reference launch script
├── start_pipeline_rag.sh ← launch script + RAG enabled
│
├── src/speech_to_speech/
│ ├── s2s_pipeline.py ← entry point (main), pipeline builder, realtime pool
│ ├── baseHandler.py
│ ├── chat.py
│ ├── pipeline/ ← queue types, CancelScope, handler types
│ │
│ ├── arguments_classes/ ← @dataclass HfArgumentParser args (one per handler)
│ │ ├── module_arguments.py ← mode / stt / tts / llm_backend / num_pipelines
│ │ ├── rag_arguments.py ← 12 RAG-specific flags
│ │ └── ... (Whisper, Qwen3, ChatTTS, VAD, …)
│ │
│ ├── STT/ ← six STT handlers
│ ├── TTS/ ← five TTS handlers
│ ├── LLM/
│ │ ├── base_openai_compatible_language_model.py ← RAG injection hook + full tool-calling
│ │ ├── chat_completions_language_model.py
│ │ ├── responses_api_language_model.py
│ │ └── language_model.py ← local transformers / mlx-lm handlers
│ │
│ ├── RAG/
│ │ ├── retriever.py ← singleton, embeddings, NPZ, search, full CRUD
│ │ └── router.py ← 11 FastAPI /v1/rag endpoints
│ │
│ └── api/openai_realtime/
│ ├── websocket_router.py ← FastAPI + Realtime + conditional RAG mount
│ ├── service.py ← Realtime event loop + session/handler routing
│ └── ...
│
├── kb/
│ ├── 01_faq_producto.md ← example Spanish FAQ (included in scaffold)
│ ├── 02_politicas_internas.md ← example Spanish policies
│ ├── README.md ← format guide
│ └── _dynamic.jsonl ← API-added chunks (auto-generated)
│
└── docs/
├── RAG_SERVER_SIDE.md ← Italian/English in-depth RAG guide (600+ lines)
└── RAG_SERVER_SIDE.es.md ← Spanish translation
```
---
## 9. Versioning and publishing
The repository ships with a full PyPI release pipeline in
`.github/workflows/publish.yml`:
1. Bump `version` in [pyproject.toml](file:///home/azurian/speech-to-speech/pyproject.toml#L7)
and `__version__` in [src/speech_to_speech/__init__.py](file:///home/azurian/speech-to-speech/src/speech_to_speech/__init__.py).
2. Merge a release PR containing only those two changes.
3. Tag and push:
```bash
git checkout main && git pull origin main
git tag -a vX.Y.Z -m "Release vX.Y.Z"
git push origin vX.Y.Z
```
4. The workflow runs `uv build` + `twine check --strict` + PyPI upload automatically.
See [AGENTS.md](file:///home/azurian/speech-to-speech/AGENTS.md) for the repository-level release rules.
---
## 10. Where to go next
- Start with the launch scripts: [start_pipeline.sh](file:///home/azurian/speech-to-speech/start_pipeline.sh) (base) and [start_pipeline_rag.sh](file:///home/azurian/speech-to-speech/start_pipeline_rag.sh) (RAG enabled).
- Dive into the RAG subsystem: [RAG_SERVER_SIDE.md](file:///home/azurian/speech-to-speech/docs/RAG_SERVER_SIDE.md) / [RAG_SERVER_SIDE.es.md](file:///home/azurian/speech-to-speech/docs/RAG_SERVER_SIDE.es.md).
- Tune the per-stage handlers by reading the argument classes in [arguments_classes](file:///home/azurian/speech-to-speech/src/speech_to_speech/arguments_classes) — every flag has inline help.
- Build your own Realtime client against [service.py](file:///home/azurian/speech-to-speech/src/speech_to_speech/api/openai_realtime/service.py) and [websocket_router.py](file:///home/azurian/speech-to-speech/src/speech_to_speech/api/openai_realtime/websocket_router.py).
+678
View File
@@ -0,0 +1,678 @@
# RAG Server-Side — Guía Oficial
> **Funcionalidad**: Retrieval Augmented Generation integrado en la pipeline speech-to-speech.
> **Enfoque**: 2 — Inyección transparente en el lado servidor (sin cambios en el cliente).
> **Versión mínima speech-to-speech**: `0.2.11`
---
## 1. Visión general
El RAG server-side enriquece **automáticamente** cada respuesta del LLM con fragmentos relevantes extraídos de una base de conocimientos local. Todo el ciclo ocurre de forma invisible para el cliente Realtime:
```
Usuario habla → STT → 🟡 RAG RETRIEVAL (hook interno) → LLM → TTS → Audio al usuario
↓
kb/*.md, kb/*.txt, kb/*.jsonl
↓
top-k chunk inyectados en el prompt
```
### Ventajas
- ✅ **Cero cambios en el cliente** — funciona con cualquier SDK Realtime
- ✅ **Singleton global compartido** (1 sola copia de embeddings para N pipelines paralelas)
- ✅ **Persistencia del índice NPZ** — reconstrucción solo si los documentos cambian
- ✅ **100% compatible con los logs existentes** (`LLM REQUEST PROMPT` incluye los chunk inyectados)
- ✅ **Soporte multilingüe** (ES/IT/EN configurable)
---
## 2. Instalación de dependencias
Las dependencias RAG son opcionales (grupo `rag` en [pyproject.toml](file:///home/azurian/speech-to-speech/pyproject.toml#L92-L94)):
```bash
cd /home/azurian/speech-to-speech
uv pip install -e ".[rag]"
```
**Contenido del grupo**:
- `sentence-transformers>=3.0.0` (arrastra automáticamente `torch`, `numpy`, `transformers` que ya están presentes)
> ✅ **En DGX Spark GB10**: el modelo de embedding se carga nativamente sobre CUDA (detectado por `--rag_device auto`, valor por defecto).
---
## 3. Estructura de la base de conocimientos
La KB reside en la carpeta configurada mediante el parámetro `--rag_kb_path` (por defecto: `./kb`).
```
kb/
├── README.md ← instrucciones del scaffold (auto-generado)
├── 01_faq_producto.md ← ejemplo español incluido
├── 02_politicas_internas.md ← ejemplo español incluido
│
├── manual/ ← subcarpetas soportadas
│ ├── 01_instalacion.md
│ └── 02_facturacion.txt
│
├── datos/
│ └── clientes.jsonl ← formato pre-chunkizado
│
├── _index.npz ← ⚙️ índice generado (NO modificar)
├── _chunks.jsonl ← ⚙️ catálogo chunk (NO modificar)
└── _dynamic.jsonl ← ⚙️ chunk dinámicos persistidos vía API
```
### Formatos soportados
#### A. Archivos Markdown / TXT (recomendado, esfuerzo cero)
Cualquier `*.md` o `*.txt` en la carpeta o subcarpetas se procesa así:
1. Lectura en UTF-8 (con fallback latin-1 y sustitución de errores)
2. División automática en chunk:
- **tamaño de chunk** por defecto: `512` caracteres (parámetro `--rag_chunk_size`)
- **overlap** por defecto: `64` caracteres (parámetro `--rag_chunk_overlap`)
- Algoritmo: splitter recursivo con separadores `\n\n → \n → . ? ! ; , → espacio → carácter`
3. Cada chunk recibe `source = path_relativo#chunk_index`
#### B. JSONL pre-chunkizado (control total)
Si prefieres gestionar chunk y metadatos manualmente (ej. extracción PDF estructurada), crea `*.jsonl` con **una línea por chunk**:
```jsonl
{"text": "Horario soporte lun-vie 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"}}
```
Campos soportados:
| Campo | Obligatorio | Notas |
|---|---|---|
| `text` | ✅ | Cuerpo del chunk (string) |
| `source` | ❌ | Por defecto: `nombre_archivo.jsonl#lineaN` |
| `chunk_index` | ❌ | Por defecto: número de línea 0-based |
| cualquier otro | ❌ | Guardado en `chunk.metadata` y mostrado en los logs |
---
## 4. Persistencia del índice NPZ
Para evitar recalcular cientos/miles de embeddings en cada arranque:
### Primer arranque
```
archivos md/txt/jsonl → chunking → embedding → guarda:
kb/_index.npz (matriz numpy float32 N × embedding_dim)
kb/_chunks.jsonl (texto, source, metadata por cada línea)
```
Tiempo estimado: ~500 chunk/s en GB10 con MiniLM-L12-v2.
### Arranques sucesivos
```
Si existen _index.npz Y _chunks.jsonl Y las dimensiones coinciden:
→ carga directamente desde disco (<1 segundo)
Si no:
→ reconstruye desde cero
```
### Forzar reconstrucción
Usa **un solo** método cuando añadas/modifiques documentos:
1. **Flag CLI**: añade `--rag_force_rebuild` al arranque (recomendado)
2. **Manual**: borra `kb/_index.npz` y `kb/_chunks.jsonl`
---
## 5. Configuración (CLI / JSON)
Todos los parámetros están definidos en [rag_arguments.py](file:///home/azurian/speech-to-speech/src/speech_to_speech/arguments_classes/rag_arguments.py) y son accesibles:
- Vía **flag CLI** en el comando `speech-to-speech ... --rag_enabled`
- Vía **archivo JSON** pasado como único argumento
- Vía `HfArgumentParser` (patrón estándar del proyecto)
### Tabla de parámetros
| Flag CLI | Valor por defecto | Descripción |
|---|---|---|
| `--rag_enabled` | `False` | **Interruptor maestro**. Sin este flag, todo lo demás se ignora. |
| `--rag_kb_path` | `./kb` | Ruta absoluta/relativa de la carpeta KB. |
| `--rag_embedding_model` | `paraphrase-multilingual-MiniLM-L12-v2` | Modelo HuggingFace `sentence-transformers`. *Multilingüe*, buen compromiso calidad/velocidad. |
| `--rag_device` | `auto` | `auto` / `cuda` / `cpu` / `mps`. |
| `--rag_top_k` | `3` | Número máximo de chunk inyectados por turno. **No superar 5-6 si la salida del TTS es larga** (crecen los tokens de contexto). |
| `--rag_threshold` | `0.25` | Umbral mínimo de similitud coseno. Rango `[0,1]`.<br>• `0.15`–`0.25` → recall alto, recupera casi todo<br>• `0.35`–`0.45` → precisión alta, solo coincidencias seguras<br>• `0.5+` → muy restrictivo |
| `--rag_language` | `es` | Idioma del encabezado de inyección: `es`, `it`, `en`. Cambia la cabecera del bloque RAG que pasa al LLM. |
| `--rag_inject_as` | `system` | Dónde inyectar los resultados:<br>• `system` → concatena a las instrucciones system (**recomendado**)<br>• `user` → añade como último mensaje del usuario |
| `--rag_chunk_size` | `512` | Caracteres máximos por chunk (solo md/txt). |
| `--rag_chunk_overlap` | `64` | Overlap en caracteres entre chunk adyacentes. |
| `--rag_embedding_batch_size` | `32` | Batch size de embeddings durante la construcción del índice. |
| `--rag_force_rebuild` | `False` | Si `True`, ignora el índice NPZ y regenera todo. |
### Modelos de embedding recomendados
| Modelo | Idiomas | Dim embedding | Velocidad GB10 | Mejor para |
|---|---|---|---|---|
| `paraphrase-multilingual-MiniLM-L12-v2` (por defecto) | 50+ | 384 | ⚡⚡⚡ | ES/IT/EN mezclado, KB genéricas |
| `hiiamsid/sentence_similarity_spanish_es` | Solo ES | 768 | ⚡⚡ | KB en español exclusivamente |
| `BAAI/bge-m3` | Multilingüe | 1024 | ⚡ | KB grandes, consultas complejas |
| `intfloat/multilingual-e5-large-instruct` | Multilingüe | 1024 | ⚡ | Consultas complejas con instrucciones |
---
## 6. Integración en start_pipeline.sh
Añade a tu [start_pipeline.sh](file:///home/azurian/speech-to-speech/start_pipeline.sh) las líneas siguientes (al final del comando `speech-to-speech`):
```bash
#!/bin/bash
# ... (flags existentes: llm_backend, model_name, base_url, etc.)
# ========== 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 # ⚠️ COMENTA después de reconstruir el índice!
```
**Ejemplo de arranque completo** (con los parámetros que ya usas):
```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. Arquitectura interna — Secuencia de operaciones
### 7.1 Setup (una sola vez al arranque)
Ocurre en [s2s_pipeline.py](file:///home/azurian/speech-to-speech/src/speech_to_speech/s2s_pipeline.py#L1029-L1059) dentro de `main()`:
```
main()
├─► si rag_enabled == True:
│ ├─► RAGRetriever(...) → carga modelo sentence-transformers
│ ├─► .build_index() → carga NPZ o rebuild desde cero
│ │ ├─► _load_index() si existe → carga inmediata
│ │ └─► _load_text_files() + _load_jsonl() + encode() + _save_index()
│ └─► set_global_rag(rag) → registra el singleton global
└─► build_pipeline(...)
└─► cada LLM handler, en su setup(): self.rag = get_global_rag()
```
### 7.2 Por cada turno de usuario (runtime)
Hook en [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), línea **610**:
```
process(request: LLMIn)
├─► construye active_chat
├─► _apply_config(instructions, wants_audio) → system message con instrucciones
├─► _inject_rag_context(active_chat, turn_id, turn_revision) ← 🔴 NUESTRO HOOK
│ ├─► (1) query = extrae último texto de usuario desde .buffer
│ ├─► (2) results = rag.search(query, top_k, threshold)
│ │ ├─► embedding query (1 vector)
│ │ ├─► @ (matriz embeddings @ query.T) → similitud coseno
│ │ ├─► argpartition top-k + argsort score
│ │ └─► filtro por threshold
│ ├─► (3) si results vacío → log debug, NINGUNA inyección
│ ├─► (4) si no, formatea en bloque de texto:
│ │ "Fragmentos relevantes recuperados..."
│ │ "[1] (source=xxx.md, score=0.672)\n<texto>"
│ ├─► (5a) inject_as=system → añade como system message
│ └─► (5b) inject_as=user → añade como último user message
├─► resolve_auto_language(lang prompt)
├─► _generate() →
│ ├─► LOG: LLM REQUEST PROMPT ← incluye los chunk RAG!
│ ├─► stream LLM
│ └─► LOG: LLM RESPONSE ← respuesta final generada con el contexto
└─► yield chunks → TTS
```
---
## 8. Mensajes de log
Todos los logs RAG usan el mismo formato de la pipeline (prefijo `pipeline X`, nivel INFO o DEBUG).
### Setup OK
```
RAG: Inizializzazione modello embedding=paraphrase-multilingual-MiniLM-L12-v2 device=cuda su kb_path=/home/kb
RAG: Índice cargado desde disco: 27 chunk (shape=(27, 384)).
RAG: Activo. kb=/home/kb top_k=3 umbral=0.300 inject_as=system idioma=es chunk=512
RAG hook activo en BaseOpenAICompatibleHandler (inject_as=system, idioma=es)
```
### Setup: ningún documento
```
RAG: Ningún documento encontrado en /home/kb. Índice vacío (retrieval no inyectará nada).
```
### Retrieval exitoso
```
RAG: inyectados 2 chunk para turn=turn_91a75 rev=1 — 01_faq_producto.md#c1(0.67); 02_politicas_internas.md#c0(0.52)
```
### Ninguna coincidencia
```
RAG: ningún chunk relevante para turn=turn_91a75 (query='Hola, ¿cómo te llamas?' umbral=0.300)
```
### Fallo de search (¡no bloquea la pipeline!)
```
RAG search fallida para turn=turn_91a75 rev=1: <excepción>
```
---
## 9. Depuración y troubleshooting
### Síntoma 1: "RAG no inyecta nada, aunque sé que el documento está ahí"
1. Revisa los logs — ¿qué mensaje aparece? `inyectados X` o `ningún chunk`?
2. Si es `ningún chunk` → baja el umbral: `--rag_threshold 0.20` o incluso `0.15`.
3. Sube top_k: `--rag_top_k 5`.
4. Reconstruye el índice: añade `--rag_force_rebuild` (quizás modificaste archivos después del primer arranque).
5. Prueba directamente: compara el texto de la pregunta vs el texto del chunk — los modelos MiniLM pueden tener resultados escasos con frases muy coloquiales. **Solución**: aumenta el overlap o redacta chunk con preguntas+respuestas explícitas.
### Síntoma 2: "Primer arranque lentísimo / crash CUDA OOM"
1. Reduce el batch size de embedding: `--rag_embedding_batch_size 4` (por defecto 32).
2. Si tienes miles de chunk, valora `paraphrase-multilingual-MiniLM-L12-v2` (384 dim, la mitad de memoria que bge-m3).
### Síntoma 3: "El LLM ignora los chunk e inventa datos"
1. Refuerza las `instructions` de sistema (vía session.update) con un prompt como:
> *"Responde SOLO con la información contenida en los Fragmentos relevantes recuperados. Si no hay información, di 'No tengo información sobre eso'."*
2. Prueba `--rag_inject_as user` (algunos modelos respetan más los bloques inyectados dentro de los mensajes de usuario).
3. Sube el **umbral** (reduce falsos positivos): `--rag_threshold 0.40`.
4. Verifica que los chunk tengan sentido (elimina cabeceras Markdown, metadatos no pertinentes).
### Síntoma 4: "El log LLM REQUEST PROMPT no muestra los chunk"
Verifica:
- Que `--rag_enabled` se haya pasado realmente (¿aparece el log `RAG: Activo` en el arranque?).
- Que el handler LLM sea una subclase de `BaseOpenAICompatibleHandler` (chat-completions y responses-api lo son; mlx-lm local y transformers no — usan [language_model_handler.py](file:///home/azurian/speech-to-speech/src/speech_to_speech/LLM/language_model_handler.py) que NO comparte la base; para estos últimos el RAG no está enganchado todavía).
---
## 10. Ejemplos de sesiones reales
### Caso: FAQ de producto en español
- **Usuario (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..." ← descartado por threshold=0.30
```
- **Texto inyectado en el 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.
```
- **Respuesta 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**: el audio se genera y se envía al cliente.
---
## 11. Archivos del proyecto involucrados
| Archivo | Rol |
|---|---|
| [RAG/\_\_init\_\_.py](file:///home/azurian/speech-to-speech/src/speech_to_speech/RAG/__init__.py) | Exports públicos (singleton + dataclass) |
| [RAG/retriever.py](file:///home/azurian/speech-to-speech/src/speech_to_speech/RAG/retriever.py) | Core: chunking, embedding, persistencia 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 parámetros |
| [s2s_pipeline.py](file:///home/azurian/speech-to-speech/src/speech_to_speech/s2s_pipeline.py) (`main()`, líneas 1029-1059) | Setup singleton global + 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 (línea 41); setup hook (líneas 163-166); injection (línea 610); helper `_extract_last_user_text` + `_inject_rag_context` (líneas 274-347) |
| [pyproject.toml](file:///home/azurian/speech-to-speech/pyproject.toml#L92-L94) | Grupo optional-deps `rag` |
| [kb/README.md](file:///home/azurian/speech-to-speech/kb/README.md) | Scaffold inicial de la KB |
| [kb/01_faq_producto.md](file:///home/azurian/speech-to-speech/kb/01_faq_producto.md) | Ejemplo FAQ español |
| [kb/02_politicas_internas.md](file:///home/azurian/speech-to-speech/kb/02_politicas_internas.md) | Ejemplo políticas español |
| [start_pipeline.sh](file:///home/azurian/speech-to-speech/start_pipeline.sh) | Añadir flags --rag_* al comando (véase §6) |
| [start_pipeline_rag.sh](file:///home/azurian/speech-to-speech/start_pipeline_rag.sh) | Script preconfigurado (variables de entorno + todos los flags RAG) |
| [RAG/router.py](file:///home/azurian/speech-to-speech/src/speech_to_speech/RAG/router.py) | **NUEVO** — Router FastAPI `/v1/rag/*` (11 endpoints HTTP) para administración dinámica |
| [api/openai_realtime/websocket_router.py](file:///home/azurian/speech-to-speech/src/speech_to_speech/api/openai_realtime/websocket_router.py#L511-L526) | Montaje condicional del router RAG en el mismo FastAPI del websocket |
---
## 13. 🆕 API HTTP dinámicas (puebla la KB en caliente)
La pipeline expone **11 endpoints JSON** en el mismo servidor que sirve el websocket
(`ws://host:12345` → HTTP sobre `http://host:12345`).
Base path común: `/v1/rag`.
> ⚠️ Si RAG está desactivado (sin `--rag_enabled`) todos los endpoints responden
> **503 Service Unavailable** con `{"code": "RAG_NOT_ENABLED", ...}`.
### 13.1 Endpoints disponibles
| Método | Path | Descripción |
|---|---|---|
| `GET` | `/v1/rag/status` | Estado: número chunk, dimensión embedding, fuentes, dispositivo, umbrales |
| `GET` | `/v1/rag/sources` | **LIST** — fuentes únicas + contador de chunk por cada una |
| `GET` | `/v1/rag/chunks` | **LIST** con query params: filtros, paginación, ordenación por relevancia |
| `POST` | `/v1/rag/chunks/list` | Mismo listado pero con body JSON (recomendado para consultas complejas) |
| `POST` | `/v1/rag/chunks/update` | **UPDATE** de un solo chunk (texto / metadatos / fuente) |
| `POST` | `/v1/rag/upsert/document` | **UPSERT atómico** de un documento entero (borra lo viejo + inserta lo nuevo). ✅ Uso más común para CRUD |
| `POST` | `/v1/rag/search` | Busca chunk con el mismo algoritmo que se usa para la inyección (depuración/pruebas) |
| `POST` | `/v1/rag/add/document` | INSERT puro de texto libre (chunking automático) |
| `POST` | `/v1/rag/add/chunks` | INSERT puro de chunk preformateados |
| `POST` | `/v1/rag/remove` | **REMOVE**: por prefijo o coincidencia exacta |
| `POST` | `/v1/rag/reload` | Reconstrucción total del índice desde los archivos en `kb/` |
> 💡 **Patrón CRUD recomendado**:
> - CREATE → `/upsert/document` (idempotente: si la source no existe la crea)
> - READ → `/sources` (lista fuentes) + `/chunks?source_exact=X` (detalle)
> - UPDATE → `/upsert/document` (misma source, texto nuevo → sustitución atómica)
> - DELETE → `/upsert/document` con `text: ""` o `/remove` con `exact: true`
---
### 13.2 Ejemplos curl
> Base URL: `http://127.0.0.1:12345/v1/rag` (cambia puerto y host si modificaste el script).
#### 📤 Añadir un documento (texto libre, chunking automático)
```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
```
**Respuesta:**
```json
{
"added": 1,
"sources": ["crm/cliente_456_nota_20260826"],
"total_after": 10
}
```
> 💡 **`persist: true`** → el chunk también se escribe en `kb/_dynamic.jsonl`,
> por lo tanto **en el próximo arranque** de la pipeline seguirá presente.
> Ponlo en `false` para chunk temporales (solo la sesión actual).
#### 📤 Añadir N chunk preformateados (ej. desde 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
```
#### 🔎 Probar el retrieval vía HTTP (depuración, sin 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
```
Respuesta con los chunk recuperados y el 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 estado general
```bash
curl -sS http://127.0.0.1:12345/v1/rag/status | jq
```
#### 🗑️ Eliminar todos los chunk de 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
```
Sugerencia: usa prefijos inteligentes para organizar la KB:
```
source_prefix = "crm/" → borra toda la agenda
source_prefix = "pedidos/" → borra todos los datos de pedidos
source_prefix = "dynamic#" → borra todos los chunk temporales auto-numerados
```
#### 🛡️ Remove con coincidencia exacta (solo source EXACTA)
```bash
# Borra SÓLO el documento 'crm/cliente_456_nota_20260826'
# (no borra '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
```
#### 📋 Listado de fuentes (inventario del índice)
```bash
# Todas las fuentes del índice, con conteo de chunk
curl -sS http://127.0.0.1:12345/v1/rag/sources | jq
```
Ejemplo salida:
```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}
]
}
```
#### 🔍 Listar chunk con filtros y paginación
```bash
# GET con query params: solo chunk de cliente_456, paginado
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: ORDENA por relevancia sobre una consulta + 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
```
Respuesta:
```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..."
}
]
}
```
Campos especiales:
- `index`: la posición numérica en el vector de chunk — pásala directamente a `/chunks/update`
- `score`: `null` sin query, float 0-1 con ordenación por relevancia
#### 🆙 Actualizar un solo chunk
Usa `/chunks/update` cuando debas modificar **únicamente un trozo específico** (ej. corregir una errata, cambiar metadatos). Para actualizar **un documento entero** usa en su lugar `/upsert/document`.
```bash
# Opción A: actualiza por `index` (de 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
# Opción B: actualiza por (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
```
Respuesta éxito `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
}
}
```
Errores HTTP posibles:
- `404 CHUNK_NOT_FOUND`
- `409 AMBIGUOUS_SOURCE` → pasaste `source` sola pero existen N chunk con la misma source; añade `chunk_index` o usa `index`
#### 🔄 Upsert documento (CRUD recomendado)
**Éste es el endpoint que usarás en el 90% de los casos.**
Idempotente sobre el campo `source`:
- Si la `source` no existe → CREATE
- Si la `source` existe → DELETE de todos los chunk antiguos + split del nuevo texto + INSERT
- Si mandas `text: ""` → DELETE atómico (sin re-insert)
```bash
# CREATE/UPDATE de un documento entero (ej. 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
```
Respuesta:
```json
{
"removed": 2,
"added": 1,
"sources": ["crm/cliente_456/perfil"],
"total_after": 10,
"mode": "upsert"
}
```
DELETE vía upsert:
```bash
# Elimina del todo todos los chunk asociados a esta source (atómico)
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
```
#### 🔄 Recargar toda la KB desde archivos (después de copiar nuevos 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 Persistencia entre reinicios: `kb/_dynamic.jsonl`
Todos los chunk añadidos vía API con `persist: true` se añaden a `kb/_dynamic.jsonl` (un JSON por línea). En el próximo arranque de la pipeline:
1. Se reconstruye el índice estático desde los archivos md/txt/jsonl
2. **Inmediatamente después** se recargan y re-embeddan también todos los chunk de `_dynamic.jsonl`
Por lo tanto el conocimiento añadido por HTTP es **duradero** y no se pierde al reiniciar.
---
### 13.4 Seguridad en hilos (thread safety)
Todas las API usan un `threading.RLock()` dentro de [RAGRetriever](file:///home/azurian/speech-to-speech/src/speech_to_speech/RAG/retriever.py):
- ✅ Varias llamadas `/search` pueden ejecutarse en paralelo durante la conversación
- ✅ Una `add_document`/`remove` bloquea brevemente el índice solo para las operaciones numpy de vstack/take — el retrieval no se pierde, espera
- ✅ La persistencia en disco (NPZ + JSONL) ocurre fuera del lock, por lo que no ralentiza la llamada de la conversación
---
## 14. Hoja de ruta / posibles mejoras
- [ ] **Soporte PDF**: añadir `pypdf` o `pdfplumber` en un grupo opcional
- [ ] **MMR reranking** en lugar del solo top-k por coseno (mejor diversidad entre chunk)
- [ ] **HyDE** (el LLM genera una consulta hipotética + embedding de la misma, para consultas coloquiales)
- [ ] **Cross-encoder reranker** de 2 etapas para KB grandes (>10k chunk)
- [ ] **Hook sobre LanguageModelHandler local** (mlx-lm / transformers) — actualmente el RAG solo está enganchado para los backends `chat-completions` y `responses-api`
- [ ] **Watchdog KB**: auto-rebuild del índice cuando cambian los archivos (inotify / watchdog)
+676
View File
@@ -0,0 +1,676 @@
# 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)
Binary file not shown.

After

Width:  |  Height:  |  Size: 473 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 464 KiB