Saltar al contenido
# 🌐 Sources — Ingestión de Fuentes Externas ## 🤔 ¿Qué hago? ¿Cómo lo hago? ¿Y para qué lo hago? **¿Qué hago?** Indexo fuentes de conocimiento externas en una colección Qdrant dedicada (`klaude_sources`), independiente de la caché semántica (`klaude_cache`) y de la knowledge base de proyecto (`klaude_knowledge`). Soporto tres tipos de fuente: páginas web, documentos Markdown y ficheros PDF. **¿Cómo lo hago?** Cada fuente pasa por un pipeline idéntico: 1. **Extracción de texto** — vía Crawl4AI (web), descarga HTTP directa (markdown URL), **pypdf + OCR automático** (PDF) o texto directo (markdown inline) - PDFs con texto nativo: pypdf - PDFs escaneados (detectados automáticamente): PaddleOCR local (sin servicios externos) 2. **Chunking** — segmentación en fragmentos de ~800 caracteres con solapamiento de 80 caracteres para preservar contexto entre chunks 3. **Embedding** — fastembed `nomic-ai/nomic-embed-text-v1.5` (768 dimensiones), ya cargado en el proxy, sin llamada a servicio externo 4. **Upsert Qdrant** — almacenamiento con ID determinista (MD5 URL+índice) para evitar duplicados en re-indexación **¿Para qué lo hago?** Amplio el corpus de conocimiento del proxy más allá del código del proyecto. Los usuarios pueden indexar documentación pública, RFCs, hojas de ruta o cualquier documento relevante. Cuando hay chunks relevantes en `klaude_sources`, el proxy los inyecta como contexto RAG en el system prompt y llama al LLM con `X-Cache: SOURCES-RAG`, enriqueciendo la respuesta con el contenido indexado. --- ## 🏗️ Arquitectura ```mermaid flowchart TD UI["🌐 Dashboard\nBotón Sources"] --> MW[Modal Sources\n3 tabs] MW --> |Web URL| EP1["POST /sources/ingest/web"] MW --> |Markdown| EP2["POST /sources/ingest/markdown"] MW --> |PDF| EP3["POST /sources/ingest/pdf"] EP1 --> C4AI["🕷️ Crawl4AI\nunclecode/crawl4ai:latest\n:11235"] EP2 --> |URL raw| HTTP["httpx.AsyncClient"] EP2 --> |texto directo| CHUNK EP3 --> PYPDF["📋 pypdf\nPdfReader"] C4AI --> MD["Markdown extraído"] HTTP --> MD PYPDF --> |¿Tiene texto?| CHECK{Detección} CHECK --> |Sí <br/> (text)| TXT["Texto pypdf"] CHECK --> |No <br/> (scanned)| OCR["🖼️ PaddleOCR\nlocal — todas las págs"] OCR --> TXT MD --> CHUNK["Chunker\n~800 chars\n80 overlap"] TXT --> CHUNK CHUNK --> EMB["fastembed\nnomic-embed-text-v1.5\n768d — en memoria"] EMB --> QDRANT["🗄️ Qdrant\nklaude_sources"] QDRANT --> STATS["GET /sources/stats"] QDRANT --> FLUSH["DELETE /sources/flush"] ``` --- ## 📡 Endpoints ### `POST /sources/ingest/web` Crawlea una URL con Crawl4AI, extrae el markdown y lo indexa. Soporta modo **deep crawl** para indexar sitios de documentación completos siguiendo links internos (BFS). **Headers:** `x-api-key: <cualquier valor no vacío>` **Body (página única):** ```json { "url": "https://docs.anthropic.com/..." } ``` **Body (deep crawl — sitio completo):** ```json { "url": "https://docs.anthropic.com/", "deep_crawl": true, "max_pages": 50 } ``` | Parámetro | Tipo | Default | Descripción | | --- | --- | --- | --- | | `url` | string | — | URL de entrada (requerida) | | `deep_crawl` | bool | `false` | Seguir links internos del mismo dominio (BFS) | | `max_pages` | int | `200` | Máximo de páginas a crawlear (sin límite servidor — el BFS para solo cuando la cola se vacía) | **Respuesta — página única (`deep_crawl: false`):** ```json { "stored": 8, "total_chunks": 8, "pages_crawled": 1, "title": "Página", "url": "https://docs.anthropic.com/..." } ``` **Respuesta — deep crawl (`deep_crawl: true`): `202 Accepted`** ```json { "job_id": "550e8400-e29b-41d4-a716-446655440000", "status": "running" } ``` El deep crawl corre en background. Consultar progreso con `GET /sources/jobs/{job_id}`. > 💡 Crawl4AI ejecuta un browser headless real — soporta páginas con JavaScript. > ⚠️ En modo deep crawl el endpoint devuelve `202` inmediatamente — el crawl puede tardar varios minutos. > ℹ️ El BFS extrae links `[text](url)` del markdown y solo sigue los del mismo dominio. Profundidad máxima: 3 niveles. Concurrencia: 3 páginas simultáneas. --- ### `POST /sources/ingest/markdown` Indexa contenido Markdown. Acepta URL a un fichero `.md` raw o texto directo. **Headers:** `x-api-key: <cualquier valor no vacío>` **Body (URL):** ```json { "url": "https://raw.githubusercontent.com/org/repo/main/README.md" } ``` **Body (texto directo):** ```json { "content": "# Mi documento\n\nContenido markdown aquí..." } ``` **Ambos campos son opcionales pero al menos uno debe estar presente.** **Respuesta:** ```json { "stored": 8, "total_chunks": 8, "title": "https://raw.github..." } ``` --- ### `POST /sources/ingest/pdf` Extrae texto de un PDF y lo indexa. Soporta **automáticamente** dos tipos de PDF: | Tipo | Detección | Método | | --- | --- | --- | | **Texto nativo** | `pypdf` extrae >100 caracteres | pypdf (instantáneo) | | **Escaneado** | `pypdf` extrae <100 caracteres | PaddleOCR local (procesa todas las págs) | Upload multipart/form-data. **Headers:** `x-api-key: <cualquier valor no vacío>` **Body:** `multipart/form-data` con campo `file` (fichero `.pdf`) **Respuesta (streaming SSE — texto):** ```json { "type": "init", "total": 25, "pages": 12, "title": "mi-documento.pdf", "extraction_method": "text" } { "type": "progress", "current": 1, "total": 25 } ... { "type": "done", "stored": 23, "total_chunks": 25, "title": "mi-documento.pdf", "pages": 12, "extraction_method": "text" } ``` **Respuesta (streaming SSE — OCR escaneado):** ```json { "type": "init", "total": 180, "pages": 150, "title": "documento-escaneado.pdf", "extraction_method": "ocr" } ... { "type": "done", "stored": 178, "total_chunks": 180, "title": "documento-escaneado.pdf", "pages": 150, "extraction_method": "ocr" } ``` **⏱️ Tiempos estimados:** | Tipo | Páginas | Tiempo | Nota | | --- | --- | --- | --- | | Texto nativo | 50 | ~5 seg | instantáneo, solo embedding | | Escaneado | 10 | ~1-2 min | OCR + embedding | | Escaneado | 100 | ~10-15 min | OCR + embedding | | Escaneado | 500+ | ~30-45 min | considera dividir el PDF | > 📌 **OCR Automático**: Detectamos automáticamente si el PDF es escaneado (sin texto extraíble). Si lo es, activamos **PaddleOCR local** que procesa todas las páginas sin contactar servicios externos. Todo sucede en el contenedor del proxy. > > 💡 **Dependencias locales**: PaddleOCR es 100% local — no requiere APIs remotas. Usa `pdf2image` + `poppler-utils` para convertir PDF→imagen→OCR. En Podman/Docker, poppler-utils está preinstalado. > > ⚠️ **Calidad OCR**: Depende de la calidad de escaneo. OCR pobre genera chunks con errores tipográficos — el embedding aún así funciona correctamente. > > 🎯 **Limitaciones**: OCR es CPU-bound. PDFs muy largos (500+ págs) pueden tardar 30-45 minutos. Considera dividir PDFs grandes en múltiples uploads. --- ### `GET /sources/stats` Estadísticas de la colección `klaude_sources`. **Sin autenticación.** **Respuesta:** ```json { "collection": "klaude_sources", "points_count": 145, "status": "Green" } ``` --- ### `DELETE /sources/flush` Vacía la colección (hard flush: borra y recrea). **Headers:** `x-api-key: <cualquier valor no vacío>` **Respuesta:** ```json { "flushed": true, "deleted_points": 145 } ``` --- ## 🧩 Módulos ### `proxy/sources.py` Módulo principal. Gestiona la colección `klaude_sources` en Qdrant. | Función | Descripción | | --- | --- | | `chunk_text(text)` | Segmenta texto en chunks de ~800 chars con overlap | | `ensure_collection()` | Crea la colección si no existe (llamado en lifespan) | | `store_chunks(chunks, vectors, source_url, source_type, title)` | Upserta chunks con metadatos | | `search(query_vector, top_k, threshold)` | Búsqueda semántica en klaude_sources | | `get_stats()` | Estadísticas de la colección | | `flush()` | Vacía y recrea la colección | ### `proxy/crawl4ai_client.py` Cliente httpx async para el servicio Crawl4AI REST. | Función | Descripción | | --- | --- | | `crawl_url(url, base, api_key)` | Crawlea URL y devuelve `{markdown, title}` | Estrategia de fallback: 1. Intenta `/crawl/sync` (síncrono, disponible en 0.9.0+) 2. Si falla, usa `/crawl` con polling de `task_id` (compatible 0.6.x) --- ## 🗄️ Colección Qdrant: `klaude_sources` | Campo payload | Tipo | Descripción | | --- | --- | --- | | `content` | string | Texto del chunk | | `source_url` | string | URL de origen (o `pdf:filename` para PDFs) | | `source_type` | string | `web`, `markdown`, `pdf` | | `title` | string | Título de la página o nombre del fichero | | `chunk_index` | int | Índice del chunk dentro de la fuente | | `indexed_at` | ISO8601 | Timestamp de indexación | **ID determinista:** `uuid.UUID(md5(f"{source_url}:{chunk_index}:{chunk[:32]}"))` — garantiza idempotencia en re-indexación de la misma fuente. --- ## 🐳 Infraestructura ### Servicio Crawl4AI en compose ```yaml crawl4ai: image: docker.io/unclecode/crawl4ai:latest container_name: klaus-crawl4ai mem_limit: 1g environment: CRAWL4AI_API_TOKEN: "${CRAWL4AI_API_TOKEN}" # requerido — sin él gunicorn escucha solo en loopback ports: - "11235:11235" ``` El proxy lo alcanza como `http://crawl4ai:11235` dentro de la red `klaus-net`. ### Variables de entorno | Variable | Default | Descripción | | --- | --- | --- | | `CRAWL4AI_API_BASE` | `http://crawl4ai:11235` | URL base del servicio | | `CRAWL4AI_API_TOKEN` | `` (vacío) | Token de auth para crawl4ai (mismo valor que el contenedor) | | `SOURCES_SIMILARITY_THRESHOLD` | `0.75` | Umbral para búsqueda en sources | --- ## 🔒 Seguridad - Todos los endpoints de escritura (`/ingest/*`, `/flush`) requieren header `x-api-key` - El campo `x-api-key` acepta cualquier valor no vacío (sin validación de secreto específico — mismo comportamiento que el resto del proxy) - Las URLs que se pasan a Crawl4AI no son validadas contra una allowlist — úsese con URLs de confianza - Los PDFs se procesan en memoria; evitar PDFs malformados o excesivamente grandes (>50MB) --- ## 🔗 Documentos relacionados - [knowledge_base.md](knowledge_base.md) — Knowledge Base de proyecto (colección `klaude_knowledge`) - [cache.md](cache.md) — Caché semántica (colección `klaude_cache`) - [architecture.md](architecture.md) — Arquitectura general del proxy - [dashboard.md](dashboard.md) — Dashboard y controles de usuario