29 KiB
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 PROMPTincluye 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):
cd /home/azurian/speech-to-speech
uv pip install -e ".[rag]"
Contenido del grupo:
sentence-transformers>=3.0.0(arrastra automáticamentetorch,numpy,transformersque 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í:
- Lectura en UTF-8 (con fallback latin-1 y sustitución de errores)
- División automática en chunk:
- tamaño de chunk por defecto:
512caracteres (parámetro--rag_chunk_size) - overlap por defecto:
64caracteres (parámetro--rag_chunk_overlap) - Algoritmo: splitter recursivo con separadores
\n\n → \n → . ? ! ; , → espacio → carácter
- tamaño de chunk por defecto:
- 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:
{"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:
- Flag CLI: añade
--rag_force_rebuildal arranque (recomendado) - Manual: borra
kb/_index.npzykb/_chunks.jsonl
5. Configuración (CLI / JSON)
Todos los parámetros están definidos en 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 las líneas siguientes (al final del comando speech-to-speech):
#!/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):
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 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, 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í"
- Revisa los logs — ¿qué mensaje aparece?
inyectados Xoningún chunk? - Si es
ningún chunk→ baja el umbral:--rag_threshold 0.20o incluso0.15. - Sube top_k:
--rag_top_k 5. - Reconstruye el índice: añade
--rag_force_rebuild(quizás modificaste archivos después del primer arranque). - 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"
- Reduce el batch size de embedding:
--rag_embedding_batch_size 4(por defecto 32). - 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"
- Refuerza las
instructionsde 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'."
- Prueba
--rag_inject_as user(algunos modelos respetan más los bloques inyectados dentro de los mensajes de usuario). - Sube el umbral (reduce falsos positivos):
--rag_threshold 0.40. - 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_enabledse haya pasado realmente (¿aparece el logRAG: Activoen 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 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 | Exports públicos (singleton + dataclass) |
| RAG/retriever.py | Core: chunking, embedding, persistencia NPZ, search |
| arguments_classes/rag_arguments.py | Dataclass HfArgumentParser 12 parámetros |
s2s_pipeline.py (main(), líneas 1029-1059) |
Setup singleton global + build index |
| 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 | Grupo optional-deps rag |
| kb/README.md | Scaffold inicial de la KB |
| kb/01_faq_producto.md | Ejemplo FAQ español |
| kb/02_politicas_internas.md | Ejemplo políticas español |
| start_pipeline.sh | Añadir flags --rag_* al comando (véase §6) |
| start_pipeline_rag.sh | Script preconfigurado (variables de entorno + todos los flags RAG) |
| RAG/router.py | NUEVO — Router FastAPI /v1/rag/* (11 endpoints HTTP) para administración dinámica |
| api/openai_realtime/websocket_router.py | 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/documentcontext: ""o/removeconexact: 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)
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:
{
"added": 1,
"sources": ["crm/cliente_456_nota_20260826"],
"total_after": 10
}
💡
persist: true→ el chunk también se escribe enkb/_dynamic.jsonl, por lo tanto en el próximo arranque de la pipeline seguirá presente. Ponlo enfalsepara chunk temporales (solo la sesión actual).
📤 Añadir N chunk preformateados (ej. desde DB)
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)
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:
{
"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
curl -sS http://127.0.0.1:12345/v1/rag/status | jq
🗑️ Eliminar todos los chunk de un cliente
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)
# 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)
# Todas las fuentes del índice, con conteo de chunk
curl -sS http://127.0.0.1:12345/v1/rag/sources | jq
Ejemplo salida:
{
"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
# 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:
{
"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/updatescore:nullsin 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.
# 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:
{
"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_FOUND409 AMBIGUOUS_SOURCE→ pasastesourcesola pero existen N chunk con la misma source; añadechunk_indexo usaindex
🔄 Upsert documento (CRUD recomendado)
Éste es el endpoint que usarás en el 90% de los casos.
Idempotente sobre el campo source:
- Si la
sourceno existe → CREATE - Si la
sourceexiste → DELETE de todos los chunk antiguos + split del nuevo texto + INSERT - Si mandas
text: ""→ DELETE atómico (sin re-insert)
# 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:
{
"removed": 2,
"added": 1,
"sources": ["crm/cliente_456/perfil"],
"total_after": 10,
"mode": "upsert"
}
DELETE vía upsert:
# 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)
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:
- Se reconstruye el índice estático desde los archivos md/txt/jsonl
- 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:
- ✅ Varias llamadas
/searchpueden ejecutarse en paralelo durante la conversación - ✅ Una
add_document/removebloquea 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
pypdfopdfplumberen 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-completionsyresponses-api - Watchdog KB: auto-rebuild del índice cuando cambian los archivos (inotify / watchdog)