# 🗄️ 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