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