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