Saltar al contenido
# 🗄️ Caché semántica — klaude-proxy ## 🤔 ¿Qué hago? ¿Cómo lo hago? ¿Y para qué lo hago? **¿Qué hago?** Almaceno en Qdrant el par (embedding, respuesta) de conversaciones seleccionadas explícitamente por el usuario. Ante nuevas peticiones, busco por similitud semántica en lugar de por igualdad exacta. **¿Cómo lo hago?** Usando Fastembed local (`nomic-ai/nomic-embed-text-v1.5`) para generar embeddings de 768 dimensiones y Qdrant con distancia coseno para la búsqueda de vecinos más próximos. Sin API externa — el modelo corre dentro del contenedor Docker. El almacenamiento en caché es **manual**: el usuario escribe `kache` tras obtener una respuesta satisfactoria. **¿Y para qué lo hago?** Para que preguntas formuladas de forma diferente pero con el mismo significado encuentren la misma respuesta cacheada — reduciendo coste y latencia. El trigger manual garantiza que solo respuestas finales y validadas por el usuario se almacenan, eliminando la contaminación por llamadas internas de herramientas. --- ## 🧬 Estrategia de embedding ### ¿Qué se embede? La serialización completa de la conversación: ```text model:claude-sonnet-4-6 system:<system prompt> user:<primer mensaje de usuario> assistant:<respuesta anterior si es multi-turn> user:<último mensaje> ``` Incluir el modelo y el system prompt garantiza que contextos diferentes (aunque con la misma pregunta final) generen vectores distintos. ### Modelo: `nomic-ai/nomic-embed-text-v1.5` | Propiedad | Valor | | --- | --- | | Dimensiones | 768 | | Idiomas | Multilingüe (inglés + español + código) | | RAM en contenedor | ~270 MB | | MTEB Score | 62.4 | | Despliegue | Local — sin API externa, sin coste por token | ### Prefijos internos de nomic El modelo usa prefijos internos para diferenciar búsqueda de indexación: | Operación | Método fastembed | Prefijo interno | | --- | --- | --- | | Búsqueda en caché (query) | `model.query_embed()` | `search_query:` | | Almacenamiento (document) | `model.embed()` | `search_document:` | Fastembed gestiona estos prefijos automáticamente — el proxy no necesita añadirlos manualmente. --- ## 🎮 Trigger manual `kache` ### ¿Por qué manual? Claude Code realiza múltiples llamadas internas a Anthropic por cada interacción del usuario (tool_use calls, thinking blocks, compactaciones de contexto). El auto-caching almacenaba todas esas llamadas intermedias, contaminando `klaude_cache` con ruido que nunca sería un cache hit útil. Con el trigger manual, **solo se cachea lo que el usuario valida explícitamente**. ### ¿Por qué `kache` y no `/cache`? Claude Code CLI intercepta cualquier mensaje con prefijo `/` antes de enviarlo al proxy — los trata como posibles comandos internos. El trigger usa `kache` (sin slash) para evitar esta interceptación. El mensaje llega al proxy como un user message normal y el proxy lo detecta antes de reenviarlo a Anthropic. ### ¿Cómo funciona? 1. 💬 El usuario recibe una respuesta satisfactoria de Anthropic 2. ⌨️ El usuario escribe `kache` como siguiente mensaje 3. 🔍 El proxy **intercepta** el mensaje antes de reenviarlo a Anthropic 4. 📦 Extrae el par Q&A del historial de conversación: - Pregunta: último mensaje `user` antes de la respuesta del asistente, **con bloques inyectados eliminados** (`<system-reminder>`, `<ide_selection>`, etc.) y truncada a 2000 chars — idéntico a lo que hace `serialize_request()` en el lookup - Respuesta: último mensaje `assistant` 5. 🧬 Embebe la pregunta con Fastembed → almacena en Qdrant 6. ✅ Devuelve respuesta sintética confirmando el almacenamiento > 💡 **Por qué funciona sin estado externo:** Claude Code envía el historial completo de conversación en cada request. Cuando el usuario escribe `kache`, el proxy recibe ese mensaje con todo el historial previo — la Q&A a cachear ya está en el payload. > > ⚠️ **Invariante crítico (GH-88):** la clave de almacenamiento (`_extract_manual_cache_pair`) y la clave de búsqueda (`serialize_request`) deben aplicar exactamente las mismas transformaciones al texto de la pregunta. Claude Code conserva los bloques `<system-reminder>` del sistema en el historial de conversación — si el store no los elimina pero el lookup sí, el vector almacenado diverge del vector de consulta y el HIT nunca ocurre en sesiones nuevas. ### 📋 Respuestas posibles | `X-Cache` header | Significado | | --- | --- | | `MANUAL-STORE` | Par Q&A almacenado correctamente (o ya existía) | | `MANUAL-ERROR` | `kache` enviado sin historial Q&A previo | --- ## 📐 Diagrama de decisión de caché ```mermaid flowchart TD A[Request /v1/messages] --> B{Último mensaje\nes 'kache'?} B -->|SÍ| C[Extraer Q&A\ndel historial] C --> D{Q&A\nencontrado?} D -->|NO| E[MANUAL-ERROR\nrespuesta sintética] D -->|SÍ| F[Fastembed doc embedding\npara la pregunta] F --> G[Qdrant upsert\nklaude_cache] G --> H[MANUAL-STORE\nrespuesta sintética ✅] B -->|NO| I[Serializar conversación] I --> J[Fastembed query embedding] J --> K{Qdrant search\nscore ≥ 0.87?} K -->|score ≥ 0.98\nDIRECT HIT| L[Devolver\ncached response\nX-Cache: HIT] K -->|0.87 ≤ score < 0.98\nCACHE RAG| RAG[Inyectar en\nsystem prompt] RAG --> M[Forward Anthropic\nX-Cache: CACHE-RAG] K -->|score < 0.87\nMISS| M2[Forward Anthropic\nX-Cache: MISS*] M --> N[Devolver respuesta] M2 --> N ``` --- ## 📦 Payload almacenado en Qdrant ```json { "request_model": "claude-sonnet-4-6", "request_hash": "sha256-hex", "request_text_preview": "model:claude-sonnet-4-6\nsystem:...\nuser:...", "response_json": "{\"id\":\"msg_...\",\"content\":[...]}", "created_at": "2026-07-10T10:30:00+00:00", "hit_count": 3 } ``` El `hit_count` se incrementa en cada cache hit para facilitar análisis de uso. --- ## ⚙️ Colección Qdrant | Parámetro | Valor | | --- | --- | | Nombre | `klaude_cache` (configurable) | | Vector size | 768 | | Distancia | Cosine | | Almacenamiento | Volumen Podman persistente | --- ## 🎯 Umbral de similitud — dual threshold El caché tiene dos umbrales configurables que trabajan en cascada: | Variable | Default | Comportamiento | | --- | --- | --- | | `CACHE_RAG_DIRECT_THRESHOLD` | `0.98` | score ≥ 0.98 → retorno directo sin LLM (`X-Cache: HIT`) | | `SIMILARITY_THRESHOLD` | `0.87` | score 0.87–0.97 → inyectar como RAG context y llamar al LLM (`X-Cache: CACHE-RAG`) | Con el dual threshold, un match muy cercano (≥ 0.98) devuelve la respuesta cacheada tal cual — coste cero. Un match parcial (0.87–0.97) inyecta la respuesta previa como contexto y deja que el LLM la adapte a la pregunta actual — reduciendo coste (el LLM recibe contexto relevante) sin sacrificar precisión. Si notas: - **Demasiados falsos positivos** en HIT directo: sube `CACHE_RAG_DIRECT_THRESHOLD` a `0.99` - **Demasiados misses** en preguntas similares: baja `SIMILARITY_THRESHOLD` a `0.82–0.85` Ambas variables se configuran en `.env`. --- ## 🧪 Testing — Funciones Async Cobertura completa de funciones async en `proxy/cache.py` con **29 tests** usando AsyncMock y circuit breaker mocking. ### Estructura de Tests | Clase | Tests | Funciones Cubiertas | | --- | --- | --- | | `TestEnsureCollection` | 3 | `ensure_collection()` | | `TestSearch` | 5 | `search()` con/sin hits, thresholds, circuit breaker | | `TestStore` | 5 | `store()` con/sin meta-responses, thinking blocks, truncate | | `TestClear` | 3 | `clear()` soft-wipe de Qdrant | | `TestFlush` | 2 | `flush()` full-wipe con recreación | | `TestGetStats` | 3 | `get_stats()` estadísticas de colección | | `TestHelperFunctions` | 5 | `_sanitize_response_for_cache()`, `_is_meta_response()` | | `TestEdgeCases` | 3 | Hit count increment fallback, timestamps, None handling | ### Fixtures Principales **`mock_settings`** — Parchea `config.settings` con valores reales (`qdrant_collection="cache"`, `similarity_threshold=0.5`, etc.) **`mock_circuit_breaker`** — Parchea `CircuitBreaker.call_async()` para ejecutar funciones async sin race conditions ### Cobertura Esperada Mejora estimada: **28.91% → ~43-44%** (+15 puntos) Archivo: `tests/test_cache_async_integration.py` | Rama: `GH-218-cache-async-coverage` | PR: `#219` --- ## 🔗 Documentos relacionados - [architecture.md](architecture.md) — Visión general y decisiones de diseño - [proxy.md](proxy.md) — API del proxy y variables de entorno - [setup.md](setup.md) — Instalación