<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0"><channel><title><![CDATA[The AI Cartographer]]></title><description><![CDATA[Exploring AI, machine learning, data engineering, and intelligent systems through clear explanations, practical architectures, and real-world code.]]></description><link>https://theaicartographer.hashnode.dev</link><image><url>https://cdn.hashnode.com/uploads/logos/6a295063c3438f966b60f738/b0bd44ea-b521-4334-a6fb-75faddfb3af1.jpg</url><title>The AI Cartographer</title><link>https://theaicartographer.hashnode.dev</link></image><generator>RSS for Node</generator><lastBuildDate>Sun, 20 Sep 2026 23:18:32 GMT</lastBuildDate><atom:link href="https://theaicartographer.hashnode.dev/rss.xml" rel="self" type="application/rss+xml"/><language><![CDATA[en]]></language><ttl>60</ttl><item><title><![CDATA[How to Use Semantic Caching with Qdrant to Optimize Token Costs in Customer Support]]></title><description><![CDATA[Most people remember the story because it was funny.
Someone asked McDonald’s AI-powered drive-through assistant to write Python code instead of ordering food. The internet laughed. Engineers noticed ]]></description><link>https://theaicartographer.hashnode.dev/how-to-use-semantic-caching-with-qdrant-to-optimize-token-costs-in-customer-support</link><guid isPermaLink="true">https://theaicartographer.hashnode.dev/how-to-use-semantic-caching-with-qdrant-to-optimize-token-costs-in-customer-support</guid><category><![CDATA[AI]]></category><category><![CDATA[llm]]></category><category><![CDATA[qdrant]]></category><category><![CDATA[System Design]]></category><dc:creator><![CDATA[Tina Sharma]]></dc:creator><pubDate>Tue, 07 Jul 2026 15:07:22 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/6a295063c3438f966b60f738/86e71de5-2676-4fd5-9d2a-c621361348b0.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>Most people remember the story because it was funny.</p>
<p>Someone asked McDonald’s AI-powered drive-through assistant to write Python code instead of ordering food. The internet laughed. Engineers noticed something else.</p>
<p>Every prompt triggered another LLM call, consuming tokens, increasing latency, and adding to the bill. LLMs don’t care whether a request is necessary — if it reaches the model, you pay for it.</p>
<p>The same pattern exists in almost every LLM-powered customer support system.</p>
<img src="https://substackcdn.com/image/fetch/$s_!n_-X!,w_1456,c_limit,f_auto,q_auto:good,fl_lossy/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fa9a8a602-8625-497e-be6a-b6ad64555ef6_800x450.gif" alt="Animated illustration showing multiple user queries processed through repeated LLM calls, with token usage and API cost increasing over time." style="display:block;margin:0 auto" />

<p>One customer asks, “<em>Where is my order?</em>” Another says, “<em>Has my package been shipped?</em>” Someone else asks, “<em>Can I track my delivery?</em>” The wording changes, but the intent is almost identical. Yet most applications still send each request to the LLM independently, paying for three generations that produce nearly the same answer.</p>
<p>At a small scale, this isn’t a problem you’ll even notice. With a few hundred conversations a day, the extra cost is negligible. But as traffic grows into hundreds of thousands of conversations every month, repeated questions start becoming one of the biggest contributors to <strong>LLM inference costs</strong>. The larger the user base, the more often the same questions are asked in different ways. Industry estimates suggest that nearly 40% of customer support queries are semantic duplicates. The wording changes, but the intent stays the same. Yet every one of those requests still triggers another expensive LLM call.</p>
<p>This is exactly the problem <strong>semantic caching</strong> is designed to solve.</p>
<img src="https://cdn.hashnode.com/uploads/covers/6a295063c3438f966b60f738/bafd8da5-eddd-4072-9948-1e0c83ce1c79.png" alt="" style="display:block;margin:0 auto" />

<p>Instead of asking the LLM to regenerate an answer every time, we first check whether a semantically similar question has already been answered. If it has, we can return the existing response in milliseconds without consuming additional tokens. Building that kind of cache requires understanding meaning rather than exact text matching, which is where a <strong>vector database</strong> like <strong>Qdrant</strong> becomes essential.</p>
<img src="https://cdn.hashnode.com/uploads/covers/6a295063c3438f966b60f738/fde2ddfa-0b0a-4bb1-b0b8-3da89f626db3.png" alt="" style="display:block;margin:0 auto" />

<p>In this article, I’ll walk you through the complete engineering journey of building a semantic cache with <strong>Qdrant</strong> — from the first prototype to debugging, benchmarking, and comparing single-vector and multi-vector retrieval. Every graph, latency measurement, cache hit rate, and cost figure comes from real benchmark runs, not theoretical estimates.</p>
<h3>Why Traditional Caching Cannot Help Here</h3>
<p>The first instinct when you notice the same answer being generated repeatedly is to add a cache. Store the question as the key, the response as the value, and return the cached answer whenever the same request appears again. It’s the same idea web browsers use to cache images and CDNs’ use for static assets. When requests are identical, this approach works extremely well.</p>
<p>Natural language, however, rarely behaves that way.</p>
<p>Customers almost never ask the same question using the exact same words. A small typo like “<em>wher is my order</em>” is enough to break an exact-match cache. So is replacing one word with a synonym, forgetting a question mark, or typing everything in lowercase from a phone. To a traditional cache, each variation looks like a completely new request, even though the customer is asking exactly the same thing.</p>
<p>Consider how different customers might ask for the status of the same order:</p>
<p><strong><em>“Where is my order?</em>”</strong></p>
<img src="https://cdn.hashnode.com/uploads/covers/6a295063c3438f966b60f738/21f5467e-5085-41ae-8bf4-f44802cbe517.png" alt="" style="display:block;margin:0 auto" />

<p>A traditional cache treats each query as a completely different key because it compares characters, not meaning. That’s the real limitation. A user asking <strong>“<em>Track my package</em>”</strong> and another asking **“<em>Where is my order?</em>”** are looking for the same answer, yet a string-based cache sees them as unrelated because the words don’t match.</p>
<img src="https://cdn.hashnode.com/uploads/covers/6a295063c3438f966b60f738/8f013f44-619d-4b3a-a758-a89b4b7e48c2.png" alt="" style="display:block;margin:0 auto" />

<p>Before introducing a more advanced solution, it’s worth asking whether simpler techniques can bridge this gap. Converting text to lowercase and removing punctuation handles formatting differences, but it cannot recognize <em>“Track my package”</em> and <em>“Where is my order?”</em> express the same intent. Stemming helps by reducing words to their root form, so <em>“tracking”</em> and <em>“track”</em> become comparable, but it still fails when two sentences use entirely different vocabulary. Even edit distance, which measures the number of character changes needed to transform one string into another, considers these two queries almost completely different, despite any human immediately recognizing that they are asking the same question.</p>
<p>These techniques compare the surface form of text, not its meaning. As soon as two semantically identical queries are phrased differently, they break down just like a traditional string-based cache.</p>
<h3>Turning Language into Numbers</h3>
<p>To compare the meaning of two sentences instead of just matching their characters, we first need a way to represent meaning mathematically. That’s exactly what an embedding model does. It converts a sentence into a high-dimensional numerical vector — a list of numbers that captures the semantic meaning of the text.</p>
<img src="https://substackcdn.com/image/fetch/$s_!5-wy!,w_1456,c_limit,f_auto,q_auto:good,fl_lossy/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F05b02bb8-b9a4-454a-aae4-cc4fa1336f36_800x450.gif" alt="Alt Text: Animated illustration showing cached responses serving repeated requests instantly, minimizing LLM calls, latency, and API costs." style="display:block;margin:0 auto" />

<p>The individual numbers don’t mean much by themselves. What matters is where the vector sits relative to other vectors. Sentences with similar intent naturally end up close together in the embedding space, while unrelated sentences are placed farther apart.</p>
<img src="https://cdn.hashnode.com/uploads/covers/6a295063c3438f966b60f738/9e19b148-ec16-48f1-95fb-c9f737de3256.png" alt="" style="display:block;margin:0 auto" />

<p>For example, <em>“Where is my order?”</em> and <em>“Track my package”</em> produce nearby vectors using the <strong>BAAI/bge-small-en-v1.5</strong> embedding model because they express the same intent. On the other hand, <em>“Reset my password”</em> is mapped to a completely different region. This geometric relationship is what makes <strong>semantic similarity search</strong> possible.</p>
<p>In this project, embeddings are generated locally using the <strong>fastembed</strong> library. The process takes roughly <strong>2 milliseconds</strong> and doesn’t require any API calls, making it both fast and free. Generating an embedding is relatively inexpensive. The real challenge is finding the most similar embedding among thousands already stored.</p>
<p>A naive approach compares the new vector against every cached vector using cosine similarity. The first version of this semantic cache did exactly that, storing (vector, answer) pairs in a Python list and performing a full scan for every query.</p>
<p>This works well for a small cache, but it doesn’t scale. A cache with 50,000 embeddings requires 50,000 similarity calculations per request, regardless of whether the query is a cache hit or miss.</p>
<p>Traditional database indexes like B-trees can’t solve this problem because high-dimensional vectors have no natural ordering. Instead, semantic search relies on specialized nearest-neighbor indexes that can quickly identify the most similar vectors without scanning the entire collection.</p>
<p>This is exactly what a vector database provides. The embedding model converts meaning into numbers, while the vector database makes those numbers searchable at scale.</p>
<p>If you’ve used Retrieval-Augmented Generation (RAG), this pipeline may look familiar. Both use embeddings and vector search, but they solve different problems. RAG retrieves relevant documents to improve the LLM’s context, whereas semantic caching retrieves a previously generated answer. When a close match exists, the system can return it immediately and skip the LLM entirely. These approaches complement each other rather than compete.</p>
<p>In a production AI system, a semantic cache typically sits in front of the entire retrieval pipeline. Every request checks the cache first. A cache hit returns the stored response in milliseconds without invoking the LLM. Only when no suitable match is found does the request continue through the normal RAG pipeline for retrieval and generation.</p>
<h3>The Dual-Path Execution Flow</h3>
<p>With the concept established, it helps to see the actual shape of the request path this creates.</p>
<p>The architecture keeps each responsibility separate. Every query is first converted into an embedding locally, so there’s no network overhead at this stage. That embedding is then searched against Qdrant, which typically adds only a few milliseconds of latency.</p>
<img src="https://cdn.hashnode.com/uploads/covers/6a295063c3438f966b60f738/65d96b48-9606-4bc8-9358-0bea85acdb1b.png" alt="" style="display:block;margin:0 auto" />

<p>If the similarity score is above the configured threshold, the semantic cache returns the stored response immediately. Since the answer is already available — often as Markdown ready to render in a chat interface — there’s no LLM call, no token consumption, and no extra inference cost. In practice, the entire lookup usually completes in under 30 milliseconds.</p>
<p>If no sufficiently similar match is found, the application simply follows the normal path and sends the request to the LLM. Once the model generates a response, it’s stored in Qdrant for future queries. That cache write doesn’t have to block the user either. It can happen asynchronously because it only benefits future requests, not the one currently being served.</p>
<p>The first customer asks a question the semantic cache has never seen before, so the request goes to the LLM and the response is stored. Later, another customer asks the same thing using different wording. This time, vector search recognizes the shared meaning, finds the cached response, and returns it instantly — without ever calling the LLM.</p>
<img src="https://cdn.hashnode.com/uploads/covers/6a295063c3438f966b60f738/6afafd52-643a-4e0b-8954-abf19e7f23b5.png" alt="" style="display:block;margin:0 auto" />

<p>It doesn’t replace any part of your existing application. Instead, it adds an early checkpoint that runs before the expensive LLM call. If a matching response is found, the request ends there. If not, the rest of the pipeline continues exactly as it always has.</p>
<p>If your application already has a request pipeline with RAG retrieval, prompt assembly, and an LLM call, adding semantic caching is usually a small change rather than a major architectural overhaul. In most cases, you only need a cache lookup at the beginning of the request flow and a cache write after a new response is generated. If there’s a cache miss, the rest of the pipeline continues exactly as it always has, making semantic caching easy to integrate without changing your existing RAG workflow.</p>
<h3>Where Semantic Caching Works, and Where It Does Not</h3>
<p>Semantic caching works best when people ask the same question in different ways and the answer remains consistent across users and over time. That’s why it’s a natural fit for customer support, internal knowledge assistants, FAQ systems, IT help desks, and HR chatbots. These applications deal with a predictable set of recurring questions where only the wording changes, making them ideal candidates for semantic cache hits.</p>
<img src="https://cdn.hashnode.com/uploads/covers/6a295063c3438f966b60f738/d3a3f0fc-3602-49cb-9ebe-56742253eb64.png" alt="" style="display:block;margin:0 auto" />

<p>It becomes less effective when responses depend on user-specific or real-time data. Questions like “What is my account balance?” require a fresh answer for each user, so serving a cached response could be inaccurate or even misleading. The same applies to queries about live inventory, current prices, order status, or real-time conditions, where the underlying information can change at any moment. In these scenarios, generating a fresh response is usually the safer and more reliable choice.</p>
<p>Guidelines</p>
<p>Consider caching responses that are stable across users and over time. Avoid caching</p>
<p>responses that depend on who is asking, or on information that changes frequently.</p>
<p>If semantic caching makes sense for your application, the next challenge is making it scale. Comparing every new query against every cached embedding works when you have a handful of entries, but it quickly becomes too slow for a production workload. At some point, you need a system that can perform fast nearest-neighbor searches across thousands or even millions of vectors without scanning them one by one.</p>
<p>What kind of database is actually designed for this kind of vector search?</p>
<p>Choosing a Vector Database</p>
<p>The previous section established the real requirement: efficiently finding the nearest neighbors of a query within a growing collection of embeddings without comparing it against every stored vector. That need, rather than a preference for any particular technology, is what determined the choice of storage layer. The most sensible way to evaluate the options is the same way an engineer would: by looking at what each solution was designed to do.</p>
<p>A Python list is the simplest place to start and works perfectly well for a small cache with only a few dozen entries. Once the cache grows, though, every lookup still requires comparing the incoming embedding with every stored embedding. It’s easy to implement, but it doesn’t scale.</p>
<p>The next logical option is SQLite. It requires almost no setup, is already available in many applications, and can store embeddings alongside the cached response. You can compute cosine similarity during queries, making it suitable for small datasets. However, SQLite doesn’t provide built-in approximate nearest neighbor (ANN) indexes, so every lookup remains a full scan. As the cache grows into the thousands of vectors, lookup latency increases for both cache hits and misses, recreating the exact scaling problem we were trying to solve.</p>
<p>For teams already using PostgreSQL, adding the pgvector extension is often the most practical upgrade. It brings ANN indexes such as IVFFlat and HNSW directly into an existing relational database, allowing vector search without introducing another piece of infrastructure. If semantic caching is just one feature in a larger application backed by PostgreSQL, this is an attractive option because operational workflows remain unchanged.</p>
<p>Redis offers a similar advantage. Many production systems already rely on it for session storage, rate limiting, and traditional caching. With RedisSearch, it also supports approximate vector search.</p>
<p>Chroma takes a different approach. It’s designed to make similarity search easy to prototype, with a lightweight API and minimal setup. For experiments, proofs of concept, or smaller applications, it’s a straightforward way to get semantic search working quickly.</p>
<p>Why I Chose Qdrant</p>
<p>There are many excellent vector databases available today, and each one has strengths for different use cases. I chose Qdrant because its capabilities aligned closely with the requirements of this semantic caching project.</p>
<ol>
<li>Native Support for Multiple Vectors</li>
</ol>
<p>One of the goals of this project was to experiment with multi-vector semantic caching, where each cached response stores several embeddings instead of just one. Qdrant supports multiple named vectors for a single record, making this architecture straightforward to implement without introducing unnecessary complexity.</p>
<ol>
<li>Flexible Metadata Filtering</li>
</ol>
<p>Every cached response contains additional metadata such as its category and creation time, not just the embedding itself. Qdrant makes this metadata easy to query and filter, allowing the cache to invalidate only the responses that become outdated while leaving unrelated entries untouched.</p>
<ol>
<li>Simple Development, Ready for Production</li>
</ol>
<p>I wanted a solution that was easy to develop locally without sacrificing production readiness later. Qdrant’s in-memory mode allowed me to build, debug, and benchmark the entire semantic cache on my own machine, while the same application can later switch to persistent storage with minimal changes.</p>
<img src="https://cdn.hashnode.com/uploads/covers/6a295063c3438f966b60f738/64a312bf-c484-438c-b8e6-e4d3eeedd953.png" alt="" style="display:block;margin:0 auto" />

<p>Building the Cache</p>
<p>Embedding Each Query</p>
<p>Every query passes through an embedding model before touching the database. The BAAI/bge-small-en-v1.5 model from fastembed produces 384-dimensional vectors, runs entirely on the local CPU, and requires no API credentials. The model loads once and is reused across all subsequent calls:</p>
<pre><code class="language-plaintext">from fastembed import TextEmbedding

_EMBED_MODEL_NAME = “BAAI/bge-small-en-v1.5”
_embedding_model = None

def get_embedding_model():
    global _embedding_model
    if _embedding_model is None:
        _embedding_model = TextEmbedding(model_name=_EMBED_MODEL_NAME)
    return _embedding_model

def embed(text: str) -&gt; list[float]:
    model = get_embedding_model()
    vectors = list(model.embed([text]))
    return vectors[0].tolist()
</code></pre>
<p>Creating the Qdrant Collection</p>
<pre><code class="language-plaintext">from qdrant_client import QdrantClient
from qdrant_client.http import models as qdrant_models

client = QdrantClient(location=”:memory:”)  # in-memory for development

client.create_collection(
    collection_name=”support_cache”,
    vectors_config=qdrant_models.VectorParams(
        size=384,
        distance=qdrant_models.Distance.COSINE,
    ),
)
</code></pre>
<p>Checking for a Cache Hit</p>
<pre><code class="language-plaintext">def check_cache(query: str, threshold: float = 0.75) -&gt; str | None:
    vector = embed(query)
    response = client.query_points(
        collection_name=”support_cache”,
        query=vector,
        limit=1,
        score_threshold=threshold,
        with_payload=True,
    )
    if response.points:
        return response.points[0].payload.get(”cached_response”)
    return None
</code></pre>
<p>Storing a New Answer</p>
<pre><code class="language-plaintext">import uuid
from datetime import datetime, timezone

def update_cache(query: str, response: str, category: str = “general”):
    vector = embed(query)
    client.upsert(
        collection_name=”support_cache”,
        points=[
            qdrant_models.PointStruct(
                id=str(uuid.uuid4()),
                vector=vector,
                payload={
                    “original_prompt”: query,
                    “cached_response”: response,
                    “category”:        category,
                    “timestamp”:       datetime.now(timezone.utc).isoformat(),
                },
            )
        ],
    )
</code></pre>
<p>The Main Query Function</p>
<pre><code class="language-plaintext">def query(user_query: str, category: str = “general”) -&gt; dict:
    cached = check_cache(user_query)
    if cached:
        return {”answer”: cached, “cache_hit”: True, “total_tokens”: 0}

    llm_result = call_llm(user_query)
    update_cache(user_query, llm_result[”text”], category=category)

    return {
        “answer”:       llm_result[”text”],
        “cache_hit”:    False,
        “total_tokens”: llm_result[”total_tokens”],
    }
</code></pre>
<p>The logic here stays simple. We check the cache first, return immediately if there’s a hit, and otherwise call the LLM and store the result for future queries. The entire implementation is contained in a single SemanticSupportCache class in the accompanying repository.</p>
<p>Measuring What Actually Happens</p>
<p>It’s easy to say that semantic caching reduces costs. Proving it is the important part. I benchmarked the implementation using the same model, the same pricing, and a realistic mix of customer support queries to measure the impact.</p>
<p>The benchmark consisted of 21 test queries based on common customer support interactions. These naturally fell into three categories.</p>
<img src="https://cdn.hashnode.com/uploads/covers/6a295063c3438f966b60f738/6fb58723-d865-40bc-80a4-4296fabeb62f.png" alt="" style="display:block;margin:0 auto" />

<p>Two complete passes ran over the test queries. Run A sent every query directly to the LLM with no cache, establishing the baseline cost. Run B queried the warm cache first, falling back to the LLM only on misses.</p>
<h3>A Design Mistake Worth Sharing</h3>
<p>The first version of this benchmark ran Run A with caching disabled, so nothing was stored, and then ran Run B against an empty cache. The result was a 0% hit rate, not because the cache itself was wrong, but because it had never been populated in the first place. The fix was to seed the cache separately, before either run, using the 8 seed questions. Benchmark design deserves the same care as the system it’s measuring.</p>
<h3>The Results</h3>
<img src="https://cdn.hashnode.com/uploads/covers/6a295063c3438f966b60f738/056f83d8-a4bd-429f-a685-48badb0514d5.png" alt="" style="display:block;margin:0 auto" />

<p>A few numbers are worth paying attention to. A <strong>57.1% cache hit rate</strong> means more than half of the requests were answered directly from the semantic cache without invoking the LLM. Those cache hits averaged <strong>15 ms</strong>, compared to <strong>2,575 ms</strong> for cache misses — roughly a <strong>171× reduction in response time</strong> for requests served from the cache. The benchmark also reports <strong>pricing lookup: exact match</strong>, confirming that the model name matched a verified pricing entry instead of falling back to a default estimate, making the cost calculations more reliable.</p>
<p>For completeness, the benchmark output below shows the raw terminal results from an actual run.</p>
<img src="https://cdn.hashnode.com/uploads/covers/6a295063c3438f966b60f738/97236eb3-1e56-4601-a3f1-f87bda6e37a8.png" alt="" style="display:block;margin:0 auto" />

<h3>The Threshold That Disabled the Cache</h3>
<p>I initially set the similarity threshold to <strong>0.92</strong> because it seemed like a safe, conservative choice. The first benchmark quickly showed that it was far too high: the cache recorded a <strong>0% hit rate</strong>. After digging into the embeddings, the reason became obvious.</p>
<pre><code class="language-plaintext">v1 = embed(”Where is my order?”)
v2 = embed(”Track my package”)
# cosine similarity: 0.7307
</code></pre>
<p>Although both queries express the same customer intent, their cosine similarity was only <strong>0.73</strong> — well below the configured threshold. The semantic cache wasn’t failing, and neither was Qdrant. The threshold simply hadn’t been calibrated for the embedding model’s similarity distribution. That benchmark turned an educated guess into a measured configuration.</p>
<img src="https://cdn.hashnode.com/uploads/covers/6a295063c3438f966b60f738/e2030b60-4449-48eb-8b74-bdaf003779d7.png" alt="" style="display:block;margin:0 auto" />

<p>A cache hit isn’t automatically a good thing. Every time you lower the similarity threshold, you increase the chance of a <strong>false positive</strong> — a cached response that is similar enough to match but not similar enough to be correct.</p>
<p>For example, at a threshold of <strong>0.75</strong>, questions like <em>“How do I cancel my order?”</em> and <em>“How do I cancel my subscription?”</em> can end up close enough in embedding space that, without additional safeguards such as category-aware filtering, the semantic cache may occasionally treat them as the same request. If the topic is shipping times, a mismatch like that is usually just an inconvenience. If it’s about billing, account access, or legal policies, serving the wrong cached response can become a much more serious problem because the customer has no indication that the answer came from a near match rather than a fresh LLM response.</p>
<p>Across many embedding models, the trade-off tends to look similar. A threshold around <strong>0.80</strong> is often too permissive, increasing false positives enough to hurt answer quality. A threshold of around <strong>0.98</strong> sits at the opposite extreme, where the cache rarely finds a match and provides little benefit. My initial value of <strong>0.92</strong> fell into that second category for this embedding model, producing almost no useful cache hits.</p>
<p>In practice, there isn’t a single threshold that’s right for every application. Different categories carry different levels of risk. Questions about billing or account security may justify a stricter threshold, while lower-risk topics such as order tracking or shipping times can tolerate a more relaxed one.</p>
<p><strong>Key takeaway:</strong> The right similarity threshold depends on both the embedding model and the distribution of queries in your application. Treat it as a value to benchmark and calibrate with real data — not one to choose by intuition.</p>
<p>Keeping Cached Answers Current</p>
<p>A semantic cache is only useful as long as its answers stay accurate. Once the underlying information changes — whether it’s a return policy, shipping partner, or pricing — the cached response can quickly become outdated.</p>
<p>There are two common ways to handle cache invalidation.</p>
<p>The first is time-to-live (TTL). Each cached response stores a timestamp in its payload, and a background job periodically removes entries older than a defined age.</p>
<pre><code class="language-plaintext">def invalidate_by_ttl(max_age_seconds: int):
    cutoff = datetime.now(timezone.utc).timestamp() - max_age_seconds
    for point in scroll_all_points():
        ts = datetime.fromisoformat(point.payload[”timestamp”]).timestamp()
        if ts &lt; cutoff:
            client.delete(collection_name, ids=[point.id])
</code></pre>
<p>TTL works well when information naturally expires over time, but sometimes waiting isn’t an option. If a return policy changes today, you probably don’t want outdated responses lingering in the cache until their TTL expires.</p>
<p>That’s where the second approach comes in: category-based invalidation. Qdrant’s payload filters let you delete every cached response belonging to a specific category the moment the underlying data changes.</p>
<pre><code class="language-plaintext">def invalidate_by_category(category: str):
    client.delete(
        collection_name=”support_cache”,
        points_selector=FilterSelector(
            filter=Filter(must=[FieldCondition(
                key=”category”,
                match=MatchValue(value=category)
            )])
        ),
    )
</code></pre>
<p>For example, if the return policy changes, calling <code>invalidate_by_category(“return_policy”)</code> immediately removes every cached response in that category while leaving unrelated entries — such as order tracking, shipping, and account support — untouched.</p>
<p>In practice, most production systems combine both strategies. TTL prevents stale entries from accumulating over time, while category-based invalidation provides an immediate way to clear affected responses whenever policies or business data change.</p>
<p><strong>Key takeaway:</strong> Cache invalidation isn’t just a maintenance task; it’s what keeps a semantic cache trustworthy. The best strategy depends on how often your underlying data changes and how quickly outdated responses need to disappear.</p>
<h3>Going Further: Multi-Vector Retrieval</h3>
<p>Single-vector semantic caching works well for most queries, but it has an important limitation. Some requests express more than one idea at the same time.</p>
<p>Take the query:</p>
<p>“<em>I need to cancel my damaged subscription.</em>”</p>
<p>This sentence contains several distinct pieces of information:</p>
<ul>
<li><p>the <strong>intent</strong> (<em>cancel</em>)</p>
</li>
<li><p>the <strong>condition</strong> (<em>damaged</em>)</p>
</li>
<li><p>the <strong>subject</strong> (<em>subscription</em>)</p>
</li>
</ul>
<p>A traditional embedding model compresses all of that into a single <strong>384-dimensional vector</strong>. In doing so, it produces one semantic representation that averages every aspect of the query into a single point in the embedding space.</p>
<p>Most of the time, that’s exactly what you want. But when a query contains multiple signals, one of them may dominate the embedding. The resulting vector might end up closer to cached responses about <strong>subscription cancellation</strong>, or it might drift toward responses about <strong>damaged products</strong>, even though neither captures the complete meaning of the request.</p>
<p>The limitation isn’t the embedding model — it’s asking one vector to represent several different semantic facets simultaneously.</p>
<p>One way to address this is <strong>multi-vector semantic caching</strong>.</p>
<p>We generate several embeddings from the same query, with each one capturing a different aspect of its meaning.</p>
<ul>
<li><p><strong>Intent vector</strong> — embeds the complete query, preserving its overall meaning.</p>
</li>
<li><p><strong>Keywords vector</strong> — embeds only the important content words after removing stop words, strengthening the topical signal.</p>
</li>
<li><p><strong>Question vector</strong> — embeds a normalized question so that statements and questions expressing the same intent produce similar embeddings.</p>
</li>
</ul>
<p>The implementation generates all three representations before storing the cache entry.</p>
<pre><code class="language-plaintext">def _extract_named_vectors(query: str) -&gt; dict[str, list[float]]:
    return {
        “intent”:   embed(query),
        “keywords”: embed(_extract_keywords(query)),
        “question”: embed(_extract_question(query)),
    }

# “I forgot my password”  → “How do I reset my password?”
# “track my package”      → “How do I track my package?”
# “Where is my order?”    → “Where is my order?”  (already a question)
</code></pre>
<p>During retrieval, each vector is searched independently against its corresponding vector space. Rather than trusting the strongest match alone, the system combines all three similarity scores into a single confidence score.</p>
<pre><code class="language-plaintext">final_score = 0.6 × best_score + 0.4 × average_score
</code></pre>
<p>This makes cache hits more reliable. A query can’t trigger a cache hit simply because one embedding happens to match well while the other representations disagree. Instead, the different semantic views reinforce one another before the cached response is returned.</p>
<p>Supporting this approach requires the storage layer to associate <strong>multiple embeddings with a single cached entry</strong>. This is where <strong>Qdrant’s named vectors</strong> become valuable.</p>
<p>Qdrant allows multiple independently searchable vector fields to be attached to the same record. In this implementation, each cached response stores an <strong>intent</strong>, <strong>keywords</strong>, and <strong>question</strong> vector. At query time, the application searches each vector space separately using the using parameter before combining the similarity scores into the final decision.</p>
<img src="https://cdn.hashnode.com/uploads/covers/6a295063c3438f966b60f738/a412d3fb-5741-41f8-8d04-03137cec10c2.png" alt="" style="display:block;margin:0 auto" />

<p>On this dataset, which consists primarily of short, focused customer support queries, <strong>single-vector semantic caching</strong> proved to be the more practical choice. Both approaches achieved the same cache hit rate, but single-vector caching delivered lower retrieval latency and greater overall cost savings. Although <strong>multi-vector indexing</strong> was significantly faster during ingestion (302 ms versus 904 ms), that advantage mattered only when writing new cache entries. During retrieval — the operation performed for every incoming query — the additional overhead of searching multiple vector spaces increased cache-hit latency from <strong>15 ms</strong> to <strong>42 ms</strong>.</p>
<p><strong>Key takeaway:</strong> Multi-vector retrieval is most valuable for long, compound queries where a single embedding may blur multiple intents into one representation. For short, focused customer support questions, single-vector semantic caching often delivers the same retrieval quality with lower latency, making it the more efficient choice.</p>
<h3>What These Numbers Mean in Production</h3>
<p>The benchmark used <strong>21 customer support queries</strong>, which was sufficient to validate the semantic cache implementation and measure relative performance. While this isn’t large enough to represent production traffic, the cost savings scale almost linearly as query volume increases.</p>
<img src="https://cdn.hashnode.com/uploads/covers/6a295063c3438f966b60f738/2b699e43-a0ce-4c40-af22-32932271467a.png" alt="" style="display:block;margin:0 auto" />

<p>These estimates are based on the benchmark’s <strong>57.1% cache hit rate</strong> and <strong>Claude Haiku 4.5</strong> pricing. In practice, production systems often perform even better. As the cache fills with frequently asked questions, repeated requests are increasingly served from the semantic cache instead of the LLM, naturally improving the hit rate over time.</p>
<p>For a mature customer support system with recurring user questions, a <strong>65–75% cache hit rate</strong> is a realistic expectation. That means lower inference costs, fewer LLM requests, and faster response times without changing the application logic.</p>
<h3>Considerations for a Production Deployment</h3>
<p>The benchmark used an <strong>in-memory Qdrant</strong> instance, which is ideal for testing but doesn’t persist data across restarts. In production, the client would connect to a persistent <strong>Qdrant</strong> deployment — either self-hosted or <a href="https://qdrant.tech/cloud/"><strong>Qdrant Cloud</strong></a> — allowing the semantic cache to survive deployments, accumulate historical queries, and improve its cache hit rate over time.</p>
<p>As the cache grows, <strong>cache hit rate</strong> becomes one of the most useful metrics to monitor. Breaking it down by question category can reveal patterns that overall metrics hide. A consistently low hit rate may indicate that the similarity threshold is too strict for that category or that the questions naturally exhibit greater linguistic variation. Once these metrics are available, <strong>per-category threshold tuning</strong> becomes a practical way to improve retrieval performance.</p>
<p>For applications serving multiple languages, the architecture requires very little change. Replacing <strong>BAAI/bge-small-en-v1.5</strong> with a multilingual embedding model such as <strong>paraphrase-multilingual-MiniLM-L12-v2</strong> is usually sufficient. The semantic caching pipeline remains the same — the only changes are the embedding model and the corresponding vector dimensionality.</p>
<p>Finally, semantic caches aren’t entirely “<em>set and forget.</em>” As products, documentation, and customer behavior evolve, the relationships between queries and cached responses can gradually change, a phenomenon known as <strong>embedding drift</strong>. Periodically reviewing similarity score distributions helps detect these shifts early, allowing thresholds or embeddings to be updated before cache quality begins to decline.</p>
<h3>What This Project Taught Me</h3>
<p>The biggest lesson was simple: <strong>measure before making claims.</strong> It would have been easy to say that semantic caching reduces costs by some impressive percentage, but those numbers wouldn’t have meant much without evidence. Building the benchmark took considerably more time, yet it produced results based on the actual embedding model, LLM, and customer support queries used in this project.</p>
<p>I also learned that <strong>similarity threshold tuning is one of the most important parts of semantic caching</strong>. My initial threshold of <strong>0.92</strong> sounded safely conservative, but it resulted in a <strong>0% cache hit rate</strong>. After experimenting with different values, <strong>0.75</strong> proved to be a much better fit for <strong>BAAI/bge-small-en-v1.5</strong> on this dataset. That doesn’t make 0.75 the “<em>correct</em>” threshold — every embedding model and application has its own sweet spot, which is why calibration should always be based on real data rather than intuition.</p>
<p>Another takeaway was that <strong>more sophisticated architectures aren’t always better</strong>. I expected <strong>multi-vector retrieval</strong> to improve cache performance, but on this dataset it produced the same hit rate as the simpler single-vector approach while increasing retrieval latency and implementation complexity. The experience reinforced an engineering principle I’ll carry into future projects: choose the simplest solution that the measurements support, and only introduce additional complexity when the data justifies it.</p>
<p>One practical lesson had nothing to do with vector databases at all. <strong>LLM pricing changes surprisingly quickly.</strong> Between the first version of this benchmark and the final draft of this article, several pricing entries had already been updated or deprecated. Verifying costs against the official API documentation — and recording the pricing source alongside benchmark results — turned out to be a simple habit that helps keep cost analyses accurate over time.</p>
<p>Finally, I came away with a different perspective on <strong>cache invalidation</strong>. It’s easy to think of it as a purely technical problem, but in practice it reflects how frequently the underlying business information changes. A company that updates its return policy every few months needs a different invalidation strategy than one whose policies remain stable for years. Designing an effective semantic cache ultimately means understanding both the system and the content it serves.</p>
<h3>What This Adds Up To</h3>
<p>Stepping back from the benchmark results, the value of <strong>semantic caching</strong> comes from two complementary benefits.</p>
<p>The first is a dramatic reduction in <strong>time to the first token (TTFT)</strong> — the delay before a user sees the beginning of a response. In this benchmark, a cache hit returned in <strong>15 ms</strong>, compared with an average of <strong>2,575 ms</strong> for an LLM call. That’s the difference between a response that feels instantaneous and one that makes users wait, and every cache hit delivers that improvement regardless of which LLM is sitting behind the application.</p>
<p>The second benefit is lower <strong>LLM inference cost</strong>. Across the 21 benchmark queries, semantic caching avoided generating <strong>1,975 tokens</strong>, reducing token consumption by <strong>55.7%</strong>. As traffic grows, those savings scale almost linearly because every repeated question answered from the cache is one less request sent to the LLM.</p>
<p>If I were measuring the success of a production rollout, these are the two metrics I’d watch first: <strong>TTFT</strong> and <strong>token consumption</strong>. A successful deployment should push both downward as the cache fills with frequently asked questions.</p>
<p>There’s also a third benefit that’s harder to capture in a benchmark but often matters just as much in production: <strong>resilience</strong>.</p>
<p>Every cache hit bypasses the LLM entirely. If the upstream provider experiences higher latency, rate limiting, or a temporary outage, cached requests continue returning in milliseconds while only cache misses depend on the LLM. The same behavior reduces pressure on API rate limits during traffic spikes because a significant portion of requests never reaches the provider in the first place.</p>
<p>That resilience won’t appear in a cost report, but it’s often what separates a support system that continues serving customers under load from one that slows down when it’s needed most.</p>
<img src="https://cdn.hashnode.com/uploads/covers/6a295063c3438f966b60f738/d74d7be9-20f0-4dc4-acb7-a15d1e333040.png" alt="" style="display:block;margin:0 auto" />

<p>Semantic caching doesn’t make an LLM more intelligent. What it does is make the system around the model more efficient. It recognizes when a previous answer is still relevant and reuses it. That reduces latency, lowers inference costs, and improves the user experience — all without changing the model itself.</p>
<p>As LLM applications grow, optimizing inference becomes just as important as choosing the right model. Sometimes the biggest performance gain doesn’t come from upgrading to a larger model. It comes from recognizing when the model doesn’t need to run in the first place.</p>
<p>If you want to explore the implementation, benchmark it with your own data, or experiment with different embedding models and similarity thresholds, the complete project is available on GitHub. It includes the semantic cache implementation, benchmark scripts, automated tests, and the code used to generate the charts shown throughout this article.</p>
<p>Want to see how schematic cache works in practice?</p>
<p>Explore the complete project, source code, and architecture here:</p>
<p>GitHub - itinasharma/semantic-cache-qdrant Contribute to itinasharma/semantic-cache-qdrant development by creating an account on</p>
<p>Try It Yourself</p>
<p>Clone the repository, point it at your preferred LLM and embedding model, and run the benchmark against your own query distribution. The most useful similarity threshold isn’t something you can copy from someone else’s project — it’s the one your own data reveals.</p>
<p>References</p>
<ol>
<li><p>Qdrant Documentation — Semantic Search: <a href="https://qdrant.tech/documentation/">https://qdrant.tech/documentation/</a></p>
</li>
<li><p>OpenAI. Embeddings Guide. <a href="https://platform.openai.com/docs/guides/embeddings">https://platform.openai.com/docs/guides/embeddings</a></p>
</li>
<li><p>Qdrant Documentation — Collections &amp; Search: <a href="https://qdrant.tech/documentation/concepts/collections/">https://qdrant.tech/documentation/concepts/collections/</a></p>
</li>
</ol>
]]></content:encoded></item></channel></rss>