# 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]`.
• `0.15`–`0.25` → recall alto, recupera casi todo
• `0.35`–`0.45` → precisión alta, solo coincidencias seguras
• `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:
• `system` → concatena a las instrucciones system (**recomendado**)
• `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"
│ ├─► (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:
```
---
## 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)