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

29 KiB
Raw Permalink Blame History

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

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:

{"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 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.150.25 → recall alto, recupera casi todo
0.350.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í"

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

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

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/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.

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

  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:

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