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

679 lines
29 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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