
Introduction In a previous article , I described building an agentic search framework in Go. While that architecture handled the functional requirements well, operating it at scale revealed significant cost and latency challenges. At millions of queries per month, LLM API costs, and P95 latency approached 5 seconds. This article presents the semantic caching architecture we implemented to address these issues. The system reduced LLM costs by 45-50% and improved P95 latency to under 2 seconds, while maintaining response freshness guarantees. The key insight: caching at multiple granularities within the agentic pipeline provides better results than end-to-end response caching alone. Specifically, caching the agent's planning decisions—which are deterministic and independent of result freshness—achieved a 50% hit rate even with conservative similarity thresholds. Problem Analysis Cost Structure A single query in an agentic search system involves multiple LLM calls: Planning/Tool Selection (~8,500 input tokens): Agent reads tool definitions and decides which tools to invoke Tool Execution (minimal cost): External API calls Summarization (~24,000 tokens): LLM formats tool outputs into natural language And couple of smaller models for rewriting the query, and selecting the tools based on the query, and tool responses. Why Traditional Caching Fails String-based caching provides minimal hit rates for natural language queries: "weather in san francisco" → cache key: hash_1 "what's the weather in sf" → cache key: hash_2 (miss) "san francisco weather" → cache key: hash_3 (miss) "tell me about san francisco weather"→ cache key: hash_4 (miss) These queries are semantically identical but produce different cache keys. Our initial implementation with exact string matching achieved only ~15% hit rate. The Freshness Challenge Semantic similarity matching introduces a new problem: distinguishing between queries that should produce identical responses versus those requiring fresh data. Consider: "who invented the telephone" (answer never changes) "what's the weather today" (answer changes daily) Both queries might have similar embeddings to cached entries, but only the first should retrieve cached results. This requires query classification and validation beyond pure similarity matching. Architecture Design Multi-Layer Caching Strategy Rather than caching only the final output, we implemented caching at three distinct layers in the processing pipeline: Query Input ↓ ┌─────────────────────────────┐ │ Agent Planning & Tool │ ← Layer 1: Planner Cache (50% hit rate) │ Selection │ Cache tool calls, not tool results └─────────────────────────────┘ ↓ ┌─────────────────────────────┐ │ Tool Execution │ No caching (requires fresh data) └─────────────────────────────┘ ↓ ┌─────────────────────────────┐ │ Response Generation/ │ ← Layer 2: Summarization Cache (35% hit rate) │ Summarization │ Cache only when tools used static data └─────────────────────────────┘ ↓ Final Response ← Layer 3: End-to-End Cache (3% hit rate) Each layer targets different characteristics: Layer 1 (Planning) : High hit rate, deterministic outputs, no freshness concerns Layer 2 (Summarization) : Medium hit rate, requires freshness validation Layer 3 (End-to-End) : Low hit rate but zero computation when hits occur Core Components The semantic cache system consists of: Embedding Generator : Converts queries to dense vector representations (768-dimensional) Vector Database (ANN) : Stores embeddings with approximate nearest neighbor search Key-Value Store : Stores response payloads separately from embeddings Query Classifier : Categorizes queries as evergreen vs time-sensitive Implementation Layer 1: Agent Planning Cache The agent planning step is particularly well-suited for caching because: Tool selection depends only on query intent, not result freshness Planning outputs are deterministic for semantically similar queries This is the most expensive operation (6,500+ tokens per call) type PlannerCache struct { embeddingClient EmbeddingClient vectorDB VectorDB kvStore KVStore } type CacheContext struct { ModelVersion string ToolVersions []string // sorted Locale string EmbeddingVersion string Temperature float32 } func (pc *PlannerCache) Get(query string, ctx CacheContext) (*ToolCalls, bool) { // Generate embedding embedding := pc.embeddingClient.Embed(query) // Create cache key from context contextHash := hashContext(ctx) // Search with conservative threshold candidates := pc.vectorDB.Search(VectorSearchRequest{ Embedding: embedding, Tags: contextHash, Threshold: 0.98, // Near-exact matching Limit: 10, }) // Verify context match and retrieve from KV store for _, candidate := range candidates { if candidate.ContextHash == contextHash { if toolCalls := pc.kvStore.Get(candidate.Key); toolCalls != nil { return toolCalls, true } } } return nil, false } func (pc *PlannerCache) Set(query string, ctx CacheContext, toolCalls *ToolCalls, ttl time.Duration) { embedding := pc.embeddingClient.Embed(query) contextHash := hashContext(ctx) key := generateKey(embedding, contextHash) // Store in both vector DB (for similarity search) and KV store (for retrieval) pc.vectorDB.Insert(embedding, key, contextHash) pc.kvStore.Set(key, toolCalls, ttl) } Cache Context Importance : Including model version, tool versions, and other configuration parameters in the cache key prevents serving stale plans after system updates. When tool definitions change, the context hash changes, effectively invalidating old cache entries. Layer 2: Summarization Cache with Freshness Validation The summarization cache requires additional logic to prevent serving stale responses: func shouldCacheSummarization(query Query, toolResults []ToolResult) bool { // Multi-turn queries have context dependencies if query.IsFollowUp { return false } // Check tool result freshness for _, result := range toolResults { switch result.Source { case "web_fresh", "web_daily": // Results from frequently-updated sources return false case "static_index": // Results from weekly-updated index are cacheable continue } } // Additional constraints return query.IsSimpleQuery && query.IsSingleStep && len(toolResults) == 1 } This conservative approach accepts a lower hit rate (35%) to ensure response freshness. Only queries that exclusively use static data sources are cached. Layer 3: End-to-End Cache The end-to-end cache serves as a catch-all for repeated identical queries: func (sc *SemanticCache) GetEndToEnd(query string, ctx CacheContext) (*Response, bool) { // First try exact match exactKey := md5Hash(normalize(query)) if resp := sc.kvStore.Get(exactKey); resp != nil { return resp, true } // Fall back to semantic search with high threshold embedding := sc.embeddingClient.Embed(query) candidates := sc.vectorDB.Search(VectorSearchRequest{ Embedding: embedding, Tags: hashContext(ctx), Threshold: 0.98, Limit: 5, }) for _, candidate := range candidates { if resp := sc.kvStore.Get(candidate.Key); resp != nil { return resp, true } } return nil, false } Freshness Control: Two-Stage Gating To prevent serving stale responses, we implement a two-stage freshness check: Stage 1: Query Classification A BERT-based classifier categorizes queries as evergreen (static answers) or time-sensitive (dynamic answers): type QueryClassifier struct { model BERTClassifier } func (qc *QueryClassifier) Predict(query string) (isEvergreen bool, confidence float64) { features := qc.extractFeatures(query) logits := qc.model.Forward(features) isEvergreen = logits[0] > logits[1] // [evergreen_logit, time_sensitive_logit] confidence = softmax(logits)[0] if isEvergreen else softmax(logits)[1] return isEvergreen, confidence } The classifier is trained with 95%+ precision at the cost of recall. This conservative tuning ensures we rarely cache time-sensitive queries incorrectly. Stage 2: Post-Execution Validation Even if the classifier isn't confident, we can still cache if we verify that only static data sources were used: func (sc *SemanticCache) GetOrCompute( query string, ctx CacheContext, compute func() (Response, error), ) (Response, error) { // Stage 1: Query classification isEvergreen, confidence := sc.classifier.Predict(query) if isEvergreen && confidence > 0.95 { // High confidence evergreen - try cache if cached, found := sc.Get(query, ctx); found { return cached, nil } } // Execute computation response, err := compute() if err != nil { return nil, err } // Stage 2: Validate based on execution results shouldCache := false if isEvergreen && confidence > 0.95 { shouldCache = true } else if response.OnlyUsedStaticSources() { shouldCache = true } if shouldCache && response.IsCacheable() { sc.Set(query, ctx, response, 7*24*time.Hour) } return response, nil } Embedding Generation We use a 768-dimensional embedding model (similar to BERT base) for semantic similarity: type EmbeddingClient struct { endpoint string model string } func (ec *EmbeddingClient) Embed(text string) []float32 { // Normalize text normalized := strings.ToLower(strings.TrimSpace(text)) // Call embedding service resp := ec.callEmbeddingAPI(EmbeddingRequest{ Text: normalized, Model: ec.model, }) // L2 normalize for cosine similarity return l2Normalize(resp.Embedding) } func l2Normalize(vec []float32) []float32 { var norm float32 for _, v := range vec { norm += v * v } norm = sqrt(norm) normalized := make([]float32, len(vec)) for i, v := range vec { normalized[i] = v / norm } return normalized } Latency: ~15-20ms per embedding generation call. Vector Search with ANN For efficient similarity search at scale, we use an approximate nearest neighbor (ANN) index: type VectorDB struct { index HNSWIndex // Hierarchical Navigable Small World graph metadata map[string]Metadata } func NewVectorDB(dimension int) *VectorDB { return &VectorDB{ index: NewHNSWIndex(HNSWConfig{ Dimension: dimension, M: 16, // connections per node EfConstruction: 200, // search quality during construction Metric: "cosine", }), metadata: make(map[string]Metadata), } } func (vdb *VectorDB) Search(req VectorSearchRequest) []Candidate { // ANN search returns approximate nearest neighbors neighbors := vdb.index.Search(req.Embedding, req.Limit*2) var candidates []Candidate for _, neighbor := range neighbors { // Filter by tags and threshold meta := vdb.metadata[neighbor.ID] if meta.Tags == req.Tags && neighbor.Similarity >= req.Threshold { candidates = append(candidates, Candidate{ Key: neighbor.ID, Similarity: neighbor.Similarity, ContextHash: meta.Tags, }) } if len(candidates) >= req.Limit { break } } return candidates } Latency: ~10-15ms for search across millions of vectors. Production Results After deploying the multi-layer semantic cache to production (serving 10M+ queries/month), we observed: Hit Rates by Layer Layer Hit Rate Avg Latency Saved Planner Cache 44% 380ms Summarization Cache 35% 950ms End-to-End Cache 18% 1,850ms Aggregate Impact Cost Reduction : 48% reduction Latency Improvement : P95 latency from 3.2s to 1.9s (41% reduction) Freshness : Zero incidents of stale responses since implementing two-stage gating Layer Contribution to Savings The planner cache (Layer 1) contributes disproportionately to total savings: Planner cache: ~56% of cost savings Summarization cache: ~30% of cost savings End-to-end cache: ~10% of cost savings This validates the strategy of caching deterministic intermediate computations rather than focusing solely on final outputs. Design Considerations and Trade-offs Similarity Threshold Selection We experimented with thresholds from 0.85 to 0.99: Threshold < 0.90 : Unacceptable false positive rate. Example: "weather in Seattle" matched "weather in San Francisco" Threshold 0.90-0.95 : Better hit rate but occasional semantic mismatches Threshold ≥ 0.98 : Near-exact matching, very low false positive rate We chose 0.98 as the default. This conservative approach sacrifices some hit rate for quality guarantees. TTL Strategy Cache TTLs vary by layer: Planner cache: 7 days (tool definitions change infrequently) Summarization cache: 2 days (more conservative due to potential freshness issues) End-to-end cache: 1 days Shorter TTLs provide additional freshness guarantees at the cost of reduced hit rates. Cache Context Granularity Including too many parameters in cache context reduces hit rate. Including too few causes incorrect cache hits after configuration changes. Our context includes: Model name and version Tool definitions and versions (sorted for consistency) User locale Embedding model version Temperature and top-p parameters We exclude: Request timestamp User ID Session ID Embedding Model Selection We evaluated several embedding models: BERT-base (768-dim): Baseline performance Sentence-BERT (768-dim): Similar performance to BERT-base MiniLM (384-dim): Faster but slightly lower quality Larger models (1024-dim+): Marginal improvement, significant latency cost We selected a 768-dimensional model as the best performance/latency trade-off. Operational Considerations Monitoring Critical metrics tracked in production: type CacheMetrics struct { HitRate float64 // by layer MissRate float64 // by layer LatencySaved time.Duration CostSaved float64 FalsePositiveRate float64 // classifier accuracy CacheSize int64 EvictionRate float64 } We alert on: Hit rate drops >10% week-over-week False positive rate >5% for evergreen classifier P99 cache lookup latency >100ms Cache Invalidation Beyond TTL-based expiry, we implement targeted invalidation: func (sc *SemanticCache) InvalidateByContext(ctx CacheContext) { contextHash := hashContext(ctx) // Find all cache entries with this context keys := sc.vectorDB.GetKeysByTag(contextHash) // Delete from both vector DB and KV store for _, key := range keys { sc.vectorDB.Delete(key) sc.kvStore.Delete(key) } } This allows immediate invalidation when tool definitions or models are updated, rather than waiting for TTL expiry. Storage Requirements Approximate storage per 1M cached queries: Vector DB: ~3GB (768-dim floats + metadata) KV Store: ~5GB (compressed response payloads) Total: ~8GB per 1M cache entries With 7-day TTLs and 10M queries/month, steady-state storage is approximately 50-60GB. Conclusion Multi-layer semantic caching is a critical component for production LLM systems operating at scale. By caching at multiple granularities—particularly at the agent planning layer—we achieved significant cost reduction (48%) and latency improvement (41%) while maintaining response quality and freshness. The key architectural insight is that deterministic intermediate computations (agent planning, tool selection) are more cacheable than final outputs, which depend on fresh data. This inverts the typical caching strategy and provides better hit rates where they matter most. For teams building agentic systems, the planner cache should be the first caching layer implemented. It provides the highest return on investment and requires no freshness validation. References Previous article: Building a Production-Ready Agentic Search Framework
View original source — Hacker Noon ↗



