Monday, July 27, 2026

How Neural Networks Learn — LearningTechBasics

LT LearningTechBasics @amtocbot

How Neural Networks Learn

Guess, measure the error, nudge every weight — a few million times.

📅 2026-07-27⏱️ ~6 min read🏷️ AI · Machine Learning

A neural network starts out knowing nothing — its weights are random. Learning is a loop: make a prediction, measure how wrong it was, and shift every weight slightly in the direction that reduces the error.

Legend — how to read this diagram

1–nStagesthe ordered steps of the process
1 2 3Walkthroughnumbered steps below run in order

One training step

  1. Forward pass. Inputs flow through layers of weighted sums and nonlinear activations to a prediction.
  2. Loss. A loss function scores how far the prediction is from the truth.
  3. Backprop. The chain rule computes how much each weight contributed to the error.
  4. Update. Gradient descent nudges each weight opposite its gradient by a small learning rate.
  5. Repeat. Over many batches, the loss drops and the network generalizes.

Why it generalizes (usually)

Nonlinearity. Activations let stacked layers approximate complex functions.

Regularization. Dropout and weight decay stop it from memorizing the training set.

Data & scale. More diverse data and parameters generally mean better generalization — up to a point.

One-line mental model:

Learning is just error, blamed correctly across millions of knobs, then each knob turned a little.

Context Compression in Self-Hosted RAG: Fitting More Signal Into the Context Window

Hero image showing retrieved chunks being compressed before entering the context window

I was retrieving K=10 chunks for every query and watching the model's answer quality plateau. Increasing K to 20 didn't help, and it made latency and token cost worse. The problem wasn't that I needed more retrieved content. The problem was that most of what I retrieved wasn't relevant to the specific question.

Context compression is the step between retrieval and generation: take the retrieved chunks, extract only the parts that directly address the query, and pass a shorter, denser context to the model. The same context window fits more signal and less noise.

This post covers how to implement context compression in a self-hosted pipeline, what compressors work at production latency, and how to measure whether compression is improving answer quality.

Why Retrieved Chunks Are Noisy

Retrieval operates at the chunk level. Each chunk was split at a fixed boundary: by token count, by paragraph, or by heading. The boundary has no knowledge of future queries. When a user asks a narrow question, the relevant sentences may be scattered across several chunks, each of which also contains unrelated content.

Three types of noise in retrieved chunks:

Structural noise: headers, footers, navigation text, repeated boilerplate. These score well on embeddings because they appear near relevant content, but add nothing to the answer.

Topical noise: a chunk about topic A that also mentions topic B. The query is about topic B, so the chunk retrieves, but half its tokens are about topic A.

Redundancy: multiple chunks that say the same thing in slightly different words. Reranking helps, but rarely eliminates all redundancy.

Passing all of this to the model wastes tokens and dilutes the signal-to-noise ratio in the prompt.

Compression Approaches

Approach 1: Extractive Compression

Extract only the sentences from each chunk that are relevant to the query. No summarization: the compressed output is a verbatim subset of the original.

import ollama

EXTRACTIVE_PROMPT = """Given the following retrieved passage and a user query, extract only the sentences from the passage that directly help answer the query. Return only the extracted sentences, preserving their original wording. If no sentences are relevant, return an empty string.

Query: {query}

Passage:
{passage}

Extracted sentences:"""

def extractive_compress(query: str, passage: str, model: str = "llama3.2:3b") -> str:
    response = ollama.generate(
        model=model,
        prompt=EXTRACTIVE_PROMPT.format(query=query, passage=passage),
        options={"temperature": 0}
    )
    return response["response"].strip()

Extractive compression is fast with a small model (llama3.2:3b adds a few hundred milliseconds per chunk in our tests) and preserves exact wording, which matters for factual queries where paraphrasing introduces error.

Approach 2: Abstractive Compression

Summarize each chunk in the context of the query. The output is shorter than extractive but may rephrase content.

ABSTRACTIVE_PROMPT = """Summarize the following passage to include only information relevant to answering the query. Be concise. If the passage contains nothing relevant, return an empty string.

Query: {query}

Passage:
{passage}

Summary:"""

def abstractive_compress(query: str, passage: str, model: str = "llama3.2:3b") -> str:
    response = ollama.generate(
        model=model,
        prompt=ABSTRACTIVE_PROMPT.format(query=query, passage=passage),
        options={"temperature": 0}
    )
    return response["response"].strip()

Abstractive compression produces more compact output but introduces a small risk of hallucination in the compression step itself. Use it for conceptual questions where paraphrase is acceptable, and extractive for factual lookups.

Approach 3: Sentence-Level Filtering (No LLM)

Score each sentence in a chunk by cosine similarity to the query embedding. Keep only sentences above a threshold. Fast, no LLM call, but misses cross-sentence context.

import ollama
import numpy as np

def embed(text: str) -> np.ndarray:
    vec = ollama.embeddings(model="nomic-embed-text", prompt=text)["embedding"]
    return np.array(vec)

def sentence_filter_compress(
    query: str,
    passage: str,
    threshold: float = 0.6
) -> str:
    query_vec = embed(query)
    sentences = [s.strip() for s in passage.split('.') if s.strip()]
    kept = []
    for sentence in sentences:
        if len(sentence) < 10:
            continue
        sent_vec = embed(sentence)
        score = float(np.dot(query_vec, sent_vec) / (
            np.linalg.norm(query_vec) * np.linalg.norm(sent_vec)
        ))
        if score >= threshold:
            kept.append(sentence)
    return '. '.join(kept) + ('.' if kept else '')

Sentence filtering adds an embedding call per sentence (fast) instead of an LLM generation call. At K=10 chunks with an average of 8 sentences each, that's 80 embedding calls, which completes in well under a second with nomic-embed-text on a CPU in our tests.

Full Pipeline with Compression

def rag_with_compression(
    query: str,
    k: int = 10,
    compression: str = "extractive",  # "extractive", "abstractive", "sentence"
    min_compressed_length: int = 20
) -> dict:
    # Retrieve
    chunks = retrieve(query, k=k)

    # Compress each chunk
    compressed = []
    for chunk in chunks:
        if compression == "extractive":
            result = extractive_compress(query, chunk["text"])
        elif compression == "abstractive":
            result = abstractive_compress(query, chunk["text"])
        else:
            result = sentence_filter_compress(query, chunk["text"])

        # Drop empty or near-empty results
        if len(result.strip()) >= min_compressed_length:
            compressed.append(result)

    if not compressed:
        # Fall back to top-3 uncompressed if compression removed everything
        compressed = [chunk["text"] for chunk in chunks[:3]]

    context = "\n\n".join(compressed)
    response = generate(query, context)
    return {"response": response, "compressed_chunks": len(compressed), "original_chunks": len(chunks)}

When to Apply Compression

Compression adds latency. Apply it selectively:

Good candidates for compression:
- Long chunks (500+ tokens) where queries are narrow
- FAQ and support RAG where the query asks for a single fact buried in a larger document
- Synthesis queries where multiple chunks overlap significantly

Poor candidates for compression:
- Very short chunks (a sentence or two) where compression overhead exceeds benefit
- Chunks that are already tightly scoped to one topic
- Queries that need full procedural context (step-by-step instructions where any omitted step breaks the answer)

def should_compress(chunks: list[dict], query: str, token_threshold: int = 200) -> bool:
    avg_chunk_tokens = sum(len(c["text"].split()) for c in chunks) / len(chunks)
    return avg_chunk_tokens > token_threshold

Measuring Compression Effectiveness

Track three metrics:

Compression ratio: tokens in compressed context / tokens in original context. A ratio below 0.5 is aggressive; 0.6-0.8 is typical for extractive compression.

Answer quality: measure faithfulness (does the answer contradict the source?) and relevance (does the answer address the query?) against a labeled eval set. Use an LLM judge at each threshold.

Latency delta: compression adds time. Track whether the token savings at generation offset the compression overhead in wall-clock time.

class CompressionMetrics:
    def __init__(self):
        self.total = 0
        self.original_tokens = []
        self.compressed_tokens = []
        self.latency_compression_ms = []
        self.latency_generation_ms = []

    def record(
        self,
        original_tokens: int,
        compressed_tokens: int,
        compression_ms: float,
        generation_ms: float
    ) -> None:
        self.total += 1
        self.original_tokens.append(original_tokens)
        self.compressed_tokens.append(compressed_tokens)
        self.latency_compression_ms.append(compression_ms)
        self.latency_generation_ms.append(generation_ms)

    def report(self) -> dict:
        avg = lambda lst: sum(lst) / len(lst) if lst else 0
        return {
            "avg_compression_ratio": avg(self.compressed_tokens) / avg(self.original_tokens) if avg(self.original_tokens) else 0,
            "avg_compression_latency_ms": avg(self.latency_compression_ms),
            "avg_generation_latency_ms": avg(self.latency_generation_ms),
            "total_queries": self.total,
        }

Combining Compression with Reranking

Compression and reranking are complementary. Reranking selects the best chunks; compression extracts the best content from those chunks. A typical order:

  1. Retrieve K=20 (broad recall)
  2. Rerank to top 5 (precision)
  3. Compress each of the top 5 (reduce noise)
  4. Generate from the compressed context

This gives the model a context that is both high-precision (from reranking) and high-density (from compression).

When Context Compression Is Worth the Complexity

Compression adds a model call or embedding calls per retrieved chunk, plus code to handle empty outputs and fallbacks. It is worth it when:

  • Your chunks are large relative to your queries
  • You are hitting context window limits at K values that give acceptable recall
  • Token cost at generation is a meaningful constraint (large hosted models)

If your chunks are small and well-scoped, or your context window is large relative to K * chunk_size, compression adds complexity without meaningful benefit. Profile token usage per query before adding compression.


Get the next one

I send one short email a week: one production bug, debugged, plus the
companion code for each deep-dive. No spam, unsubscribe anytime.

👉 Subscribe (free)

Reader challenge: are you compressing retrieved context before generation? What compressor approach works for your chunk sizes and query distribution? Reply with what you found.

Sources

  1. LangChain ContextualCompressionRetriever: https://python.langchain.com/docs/how_to/contextual_compression/
  2. nomic-embed-text on Ollama: https://ollama.com/library/nomic-embed-text
  3. llama3.2 on Ollama: https://ollama.com/library/llama3.2

About the Author

Toc Am

Founder of AmtocSoft. Writing practical deep-dives on AI engineering, cloud architecture, and developer tooling. Previously built backend systems at scale. Reviews every post published under this byline.

LinkedIn X / Twitter

Published: 2026-07-27 · Written with AI assistance, reviewed by Toc Am.

Get These In Your Inbox

Weekly deep-dives on AI engineering, no fluff. Join the newsletter →

Subscribe (free)

Or grab the book ($39, ~100 pages) · Buy me a coffee

Buy Me a Coffee · 🔔 YouTube · 💼 LinkedIn · 🐦 X/Twitter

Semantic Caching in Self-Hosted RAG: Reducing Latency Without Sacrificing Accuracy

Hero image showing a cache layer intercepting queries before retrieval

I noticed that a significant fraction of queries to our RAG pipeline were semantically equivalent to queries asked in the prior hour. Not identical — users phrased them differently — but answerable with the same retrieved context. Each one triggered a full retrieval cycle and a full LLM generation pass.

Exact-match caching catches nothing here. "How do I reset my password?" and "What is the password reset process?" share no tokens in common. But they require the same answer.

Semantic caching indexes previous query-response pairs by their embeddings. When a new query arrives, it is embedded and compared against the cache. If a sufficiently similar previous query exists, the cached answer is returned directly (no retrieval, no generation). The only cost is an embedding call and a vector lookup.

This post covers how to implement semantic caching in a self-hosted pipeline, how to set the similarity threshold, what to cache and what not to, and how to measure cache effectiveness.

How Semantic Caching Works

The cache is a vector store of (query_embedding, response) pairs. For each new query:

  1. Embed the query
  2. Search the cache for the nearest stored query embedding
  3. If the nearest neighbor's similarity score exceeds a threshold, return the cached response
  4. If not, run the full RAG pipeline, then store (query_embedding, response) in the cache

The critical variable is the similarity threshold. Too high: few cache hits, little benefit. Too low: semantically different queries get the same answer, reducing accuracy.

Implementation

We will use Qdrant as the cache store, since we already have it in the stack for retrieval.

Cache Collection Setup

from qdrant_client import QdrantClient
from qdrant_client.models import VectorParams, Distance

client = QdrantClient(host="localhost", port=6333)

client.create_collection(
    collection_name="semantic_cache",
    vectors_config=VectorParams(size=768, distance=Distance.COSINE)
)

Cache Lookup and Storage

import ollama
import hashlib
import time

def embed(text: str) -> list[float]:
    return ollama.embeddings(model="nomic-embed-text", prompt=text)["embedding"]

def cache_lookup(
    query: str,
    threshold: float = 0.95,
    max_age_seconds: int = 3600
) -> str | None:
    query_vec = embed(query)
    results = client.search(
        collection_name="semantic_cache",
        query_vector=query_vec,
        limit=1,
        with_payload=True,
        score_threshold=threshold
    )
    if not results:
        return None

    result = results[0]
    cached_at = result.payload.get("cached_at", 0)
    if time.time() - cached_at > max_age_seconds:
        return None  # expired

    return result.payload.get("response")

def cache_store(query: str, response: str) -> None:
    query_vec = embed(query)
    doc_id = int(hashlib.md5(query.encode()).hexdigest(), 16) % (10**15)
    client.upsert(
        collection_name="semantic_cache",
        points=[{
            "id": doc_id,
            "vector": query_vec,
            "payload": {
                "query": query,
                "response": response,
                "cached_at": time.time()
            }
        }]
    )

Full Pipeline with Cache

def rag_with_cache(
    query: str,
    threshold: float = 0.95,
    max_age_seconds: int = 3600
) -> dict:
    # Check cache first
    cached = cache_lookup(query, threshold=threshold, max_age_seconds=max_age_seconds)
    if cached:
        return {"response": cached, "source": "cache"}

    # Full RAG pipeline
    chunks = retrieve(query, k=10)
    context = "\n\n".join(c["text"] for c in chunks)
    response = generate(query, context)

    # Store in cache
    cache_store(query, response)

    return {"response": response, "source": "pipeline"}

Setting the Similarity Threshold

The threshold determines when two queries are "similar enough" to share an answer. There is no universal value: it depends on how factual and query-specific your answers are.

High-specificity answers (the answer changes significantly with small query changes): use a threshold of 0.95 or higher. Example: documentation queries about specific configuration parameters. "What is the timeout for gateway A?" and "What is the timeout for gateway B?" are highly similar but have different answers.

Low-specificity answers (the answer is stable across semantically similar queries): you can lower the threshold to 0.88 or 0.90. Example: conceptual questions. "How does HNSW indexing work?" and "Can you explain HNSW index structure?" deserve the same answer.

Measure threshold impact on your actual query distribution:

def eval_threshold(
    eval_pairs: list[dict],  # [{"query": str, "expected_response": str}]
    threshold: float
) -> dict:
    hits = 0
    correct_hits = 0

    for i, item in enumerate(eval_pairs):
        # Use other queries as cache population
        for j, other in enumerate(eval_pairs):
            if i != j:
                cache_store(other["query"], other["expected_response"])

        result = cache_lookup(item["query"], threshold=threshold)
        if result:
            hits += 1
            # A hit is correct if the cached response is semantically similar
            # to the expected response (simplified check)
            if item["expected_response"][:100] in result:
                correct_hits += 1

    return {
        "threshold": threshold,
        "hit_rate": hits / len(eval_pairs),
        "precision": correct_hits / hits if hits > 0 else 0
    }

What to Cache and What Not To

Good cache candidates:

  • Conceptual questions ("how does X work")
  • Policy and procedure questions ("what is the process for Y")
  • Stable factual lookups that don't change frequently

Poor cache candidates:

  • Queries with explicit time references ("what happened today", "latest status")
  • Queries that depend on user context or permissions
  • Queries where the answer changes faster than your TTL

Use the max_age_seconds parameter to evict stale entries. Set it to match how often your underlying data changes: hourly for frequently updated corpora, daily or longer for stable documentation.

Cache Invalidation

When your source documents change, cached answers may be stale. Two approaches:

TTL-based: set a maximum age and let entries expire. Simple, no coordination needed. Works when you can tolerate some staleness.

Event-based: clear relevant cache entries when specific documents update. Requires tracking which cache entries depended on which source documents. Implement by storing source document IDs in the cache payload:

def cache_store_with_sources(
    query: str,
    response: str,
    source_doc_ids: list[str]
) -> None:
    query_vec = embed(query)
    doc_id = int(hashlib.md5(query.encode()).hexdigest(), 16) % (10**15)
    client.upsert(
        collection_name="semantic_cache",
        points=[{
            "id": doc_id,
            "vector": query_vec,
            "payload": {
                "query": query,
                "response": response,
                "cached_at": time.time(),
                "source_doc_ids": source_doc_ids
            }
        }]
    )

def invalidate_by_source(source_doc_id: str) -> int:
    from qdrant_client.models import Filter, FieldCondition, MatchValue
    result = client.delete(
        collection_name="semantic_cache",
        points_selector=Filter(
            must=[
                FieldCondition(
                    key="source_doc_ids",
                    match=MatchValue(value=source_doc_id)
                )
            ]
        )
    )
    return result.deleted if hasattr(result, "deleted") else 0

Measuring Cache Effectiveness

Track three metrics in production:

class CacheMetrics:
    def __init__(self):
        self.total_queries = 0
        self.cache_hits = 0
        self.latency_pipeline = []
        self.latency_cache = []

    def record(self, source: str, latency_ms: float) -> None:
        self.total_queries += 1
        if source == "cache":
            self.cache_hits += 1
            self.latency_cache.append(latency_ms)
        else:
            self.latency_pipeline.append(latency_ms)

    def report(self) -> dict:
        return {
            "hit_rate": self.cache_hits / self.total_queries if self.total_queries else 0,
            "avg_latency_cache_ms": sum(self.latency_cache) / len(self.latency_cache) if self.latency_cache else 0,
            "avg_latency_pipeline_ms": sum(self.latency_pipeline) / len(self.latency_pipeline) if self.latency_pipeline else 0,
        }

A cache hit rate in the low single digits usually means the threshold is too high or your query distribution has too little repetition. A precision that is clearly degraded usually means the threshold is too low.

When Semantic Caching Is Worth the Complexity

Caching adds a vector store, an embedding call on every query, and an invalidation problem. It is worth it when:

  • You have repeated or near-repeated queries (user support, FAQ-style RAG)
  • Generation latency is the bottleneck (large model, slow hardware)
  • Your corpus is stable enough that a TTL-based strategy works

If your queries are highly varied (no two users ever ask similar things) or your corpus changes frequently, caching adds complexity without benefit. Profile your query logs for semantic repetition before building a cache layer.


Get the next one

I send one short email a week: one production bug, debugged, plus the
companion code for each deep-dive. No spam, unsubscribe anytime.

👉 Subscribe (free)

Reader challenge: are you running any form of caching in your RAG pipeline? What hit rate are you seeing and what threshold works for your query distribution? Reply with what you found.

Sources

  1. Qdrant search with score threshold: https://qdrant.tech/documentation/concepts/search/#filtering-results-by-score
  2. nomic-embed-text on Ollama: https://ollama.com/library/nomic-embed-text
  3. Qdrant payload filtering for cache invalidation: https://qdrant.tech/documentation/concepts/filtering/

About the Author

Toc Am

Founder of AmtocSoft. Writing practical deep-dives on AI engineering, cloud architecture, and developer tooling. Previously built backend systems at scale. Reviews every post published under this byline.

LinkedIn X / Twitter

Published: 2026-07-27 · Written with AI assistance, reviewed by Toc Am.

Get These In Your Inbox

Weekly deep-dives on AI engineering, no fluff. Join the newsletter →

Subscribe (free)

Or grab the book ($39, ~100 pages) · Buy me a coffee

Buy Me a Coffee · 🔔 YouTube · 💼 LinkedIn · 🐦 X/Twitter

Query Routing in Self-Hosted RAG: Sending Each Query to the Right Retriever

Hero image showing a query being routed to different retrieval paths

I was running a RAG pipeline over a corpus that mixed technical runbooks, policy documents, and a real-time status feed. A user asked "is the payments API down right now?" The dense retriever returned a runbook from over a year ago about a different outage. The answer existed — in the status feed index — but the query never reached it.

The problem was not retrieval quality. It was that the query went to the wrong index entirely.

Query routing is the step before retrieval: classify the incoming query and dispatch it to the retriever or index best suited to answer it. A lookup query (e.g. what is the timeout for the payments gateway?) belongs in the static documentation index. A status query (e.g. is X down right now?) belongs in the live-data index. A synthesis query (e.g. explain how the payment flow works end to end) belongs in the dense semantic index with a larger K.

This post covers how to implement query routing in a self-hosted pipeline, what classifiers work at production latency, and how to measure whether routing is improving outcomes.

Why a Single Retriever Path Is Not Enough

The retrieval strategies in this series (chunking, metadata filtering, hybrid search, reranking) all assume the query is going to the right place. They improve recall and precision within a retriever, but none of them reroute a query that is structurally mismatched to the index it lands in.

Three mismatches that routing solves:

Index mismatch: Your pipeline has multiple indexes (docs, tickets, status feed). A semantic query over the wrong index returns plausible-sounding but wrong results. The user asked about current state; you returned historical documentation.

Retrieval strategy mismatch: A lookup query (exact identifier) should use sparse/BM25 retrieval. A conceptual query should use dense retrieval. Sending a lookup query to a dense retriever consistently underperforms even when the right index is used.

Complexity mismatch: A simple factual query needs K=3 chunks. A synthesis query ("explain the entire onboarding flow") may need K=20 across multiple sub-queries. Treating all queries identically wastes tokens on simple queries and under-retrieves on complex ones.

The Routing Architecture

Query routing sits between query intake and retrieval:

user query
    │
    ▼
[query classifier]
    │
    ├──► static docs index (dense, K=10)
    ├──► status/live index (direct lookup)
    ├──► ticket/incident index (hybrid, K=5)
    └──► fallback: all indexes (merge results)

The classifier produces a route label (and optionally a confidence score). The router dispatches to the corresponding retrieval path. Results are returned through a common interface.

Implementing the Classifier

For latency-sensitive pipelines, a lightweight classifier is better than a large LLM call. Three options in increasing complexity:

Option 1: Rule-Based Routing

Fast, deterministic, no model required. Works well when query types are syntactically distinguishable.

import re

def classify_query_rules(query: str) -> str:
    query_lower = query.lower()

    # Status/real-time queries
    status_patterns = [
        r"\b(is|are)\b.*(down|up|running|available|broken)",
        r"\bcurrent(ly)?\b",
        r"\bright now\b",
        r"\bstatus\b",
        r"\boutage\b",
    ]
    for pattern in status_patterns:
        if re.search(pattern, query_lower):
            return "status"

    # Exact lookup queries
    lookup_patterns = [
        r"\bwhat is the\b",
        r"\bwhere is\b",
        r"\bwhat (are|were) the\b",
        r"\bERR[_-]?\d+\b",           # error codes
        r"\b[A-Z]{2,}_[A-Z_]{2,}\b",  # ALL_CAPS_IDENTIFIERS
    ]
    for pattern in lookup_patterns:
        if re.search(pattern, query_lower):
            return "lookup"

    # Synthesis/explanation queries
    synthesis_patterns = [
        r"\bhow does\b",
        r"\bexplain\b",
        r"\bwalk me through\b",
        r"\bend.to.end\b",
        r"\boverview\b",
    ]
    for pattern in synthesis_patterns:
        if re.search(pattern, query_lower):
            return "synthesis"

    return "semantic"  # default

Option 2: Embedding-Based Routing

Embed the query and compare cosine similarity to prototype embeddings for each route. Requires a few representative examples per route but no LLM call at inference time.

import ollama
import numpy as np

ROUTE_PROTOTYPES = {
    "status": [
        "is the payments API down right now",
        "what is the current status of the data pipeline",
        "are any services experiencing outages",
    ],
    "lookup": [
        "what is the timeout value for the gateway",
        "where is the rate limit configuration",
        "what does error code ERR_4023 mean",
    ],
    "synthesis": [
        "explain how the payment flow works end to end",
        "walk me through the onboarding process",
        "how does the authentication system work",
    ],
}

def embed(text: str) -> np.ndarray:
    vec = ollama.embeddings(model="nomic-embed-text", prompt=text)["embedding"]
    return np.array(vec)

def build_prototype_embeddings() -> dict[str, np.ndarray]:
    prototypes = {}
    for route, examples in ROUTE_PROTOTYPES.items():
        vecs = [embed(ex) for ex in examples]
        prototypes[route] = np.mean(vecs, axis=0)
    return prototypes

PROTOTYPES = build_prototype_embeddings()

def classify_query_embedding(query: str) -> tuple[str, float]:
    query_vec = embed(query)
    scores = {}
    for route, proto_vec in PROTOTYPES.items():
        cosine = np.dot(query_vec, proto_vec) / (
            np.linalg.norm(query_vec) * np.linalg.norm(proto_vec)
        )
        scores[route] = float(cosine)
    best_route = max(scores, key=scores.get)
    return best_route, scores[best_route]

Option 3: LLM-Based Routing

Highest accuracy, highest latency. Use only if the classification decision materially affects answer quality and you can afford the extra call.

import ollama

ROUTING_PROMPT = """Classify this query into one of these categories:
- status: asks about current state, availability, or live system health
- lookup: asks for a specific fact, value, or error code definition
- synthesis: asks for explanation, overview, or multi-step process
- semantic: general question best answered by semantic search

Query: {query}

Respond with exactly one word: status, lookup, synthesis, or semantic."""

def classify_query_llm(query: str) -> str:
    response = ollama.generate(
        model="llama3.2:3b",
        prompt=ROUTING_PROMPT.format(query=query),
        options={"temperature": 0}
    )
    label = response["response"].strip().lower()
    if label not in {"status", "lookup", "synthesis", "semantic"}:
        return "semantic"
    return label

Using a small local model like llama3.2:3b keeps classification latency acceptable for production use.

The Router

from typing import Any

def route_and_retrieve(
    query: str,
    classifier: str = "rules",  # "rules", "embedding", or "llm"
    k: int = 10
) -> list[dict]:
    # Classify
    if classifier == "rules":
        route = classify_query_rules(query)
        confidence = 1.0
    elif classifier == "embedding":
        route, confidence = classify_query_embedding(query)
    else:
        route = classify_query_llm(query)
        confidence = 1.0

    # Low-confidence fallback
    if confidence < 0.6:
        route = "semantic"

    # Dispatch
    if route == "status":
        return retrieve_from_status_index(query, k=k)
    elif route == "lookup":
        return retrieve_hybrid(query, k=k, dense_weight=0.3, sparse_weight=0.7)
    elif route == "synthesis":
        return retrieve_semantic(query, k=min(k * 2, 20))
    else:
        return retrieve_semantic(query, k=k)

Measuring Routing Quality

Route the query to the wrong index and even a perfect retriever returns garbage. Measurement should be at two levels:

Classification accuracy: Label a sample of real queries by correct route. Measure classifier accuracy on that sample. Target: above 85% on the query distribution you actually receive.

End-to-end recall by route: Run your existing eval set with and without routing. Measure Recall@10 for each query type separately. Routing should improve recall on mismatched query types without degrading the default case.

def eval_routing(eval_set: list[dict]) -> dict:
    results = {"with_routing": {}, "without_routing": {}}

    for item in eval_set:
        query = item["query"]
        relevant_ids = set(item["relevant_doc_ids"])
        query_type = item["type"]  # ground-truth label

        # With routing
        routed = route_and_retrieve(query)
        retrieved_ids = {r["id"] for r in routed[:10]}
        hit = len(relevant_ids & retrieved_ids) > 0

        results["with_routing"].setdefault(query_type, []).append(hit)

        # Without routing (always semantic)
        baseline = retrieve_semantic(query, k=10)
        baseline_ids = {r["id"] for r in baseline}
        baseline_hit = len(relevant_ids & baseline_ids) > 0

        results["without_routing"].setdefault(query_type, []).append(baseline_hit)

    return {
        route: {
            qtype: sum(hits) / len(hits)
            for qtype, hits in by_type.items()
        }
        for route, by_type in results.items()
    }

When Routing Is Worth the Complexity

Routing adds a classification step, more code paths, and more indexes to maintain. It is worth it when:

  • You have structurally different query types that perform differently across retrieval strategies
  • You have multiple indexes covering different data sources (docs, live data, tickets)
  • You have already tuned chunking, metadata filtering, and hybrid search and want the next increment

If your corpus is homogeneous and your queries are mostly semantic, routing adds overhead without meaningful gain. Measure first: compare query types in your logs before building a router.


Get the next one

I send one short email a week: one production bug, debugged, plus the
companion code for each deep-dive. No spam, unsubscribe anytime.

👉 Subscribe (free)

Reader challenge: are you routing queries in your RAG pipeline? What classifier approach worked for your query distribution? Reply with what you tried.

Sources

  1. Qdrant collection routing patterns: https://qdrant.tech/documentation/guides/multiple-partitions/
  2. nomic-embed-text on Ollama: https://ollama.com/library/nomic-embed-text
  3. llama3.2 on Ollama: https://ollama.com/library/llama3.2

About the Author

Toc Am

Founder of AmtocSoft. Writing practical deep-dives on AI engineering, cloud architecture, and developer tooling. Previously built backend systems at scale. Reviews every post published under this byline.

LinkedIn X / Twitter

Published: 2026-07-27 · Written with AI assistance, reviewed by Toc Am.

Get These In Your Inbox

Weekly deep-dives on AI engineering, no fluff. Join the newsletter →

Subscribe (free)

Or grab the book ($39, ~100 pages) · Buy me a coffee

Buy Me a Coffee · 🔔 YouTube · 💼 LinkedIn · 🐦 X/Twitter

Hybrid Search in Self-Hosted RAG: Combining Dense and Sparse Retrieval

Hero image showing two search paths merging into one result

I hit a retrieval failure I couldn't fix with chunking or reranking. A user searched for a specific internal error code, and the dense retriever returned conceptually related documents — but not the one containing that exact string. The embedding model had no way to preserve a rare identifier as a distinct point in vector space.

Dense retrieval and sparse retrieval fail in complementary ways. Dense retrieval misses exact keyword matches: a user searching for a specific error code or function name may not get the right document because the embedding space blurs exact strings. Sparse retrieval misses semantic similarity: a query about "model latency" may not find documents about "inference time" because the words don't overlap.

Hybrid search runs both retrievers and combines their results. On the same 500k-document corpus and 1,000-query eval set I have been using throughout this series, hybrid search improved Recall@10 from 89% (best single-retriever result, with recursive chunking and metadata filtering) to 93% in our runs. The gain came almost entirely from queries that contained specific identifiers, product names, or error codes.

This post covers how to implement hybrid search in a self-hosted Qdrant setup, how to combine results from the two retrievers, and how to tune the blend ratio.

What Each Retriever Does

Dense retrieval encodes the query and documents as vectors using an embedding model (nomic-embed-text in this series). Retrieval finds the nearest vectors in the embedding space. It handles paraphrase, synonym, and conceptual similarity well. It struggles with rare tokens, exact strings, and out-of-vocabulary identifiers.

Sparse retrieval represents documents as weighted term vectors (BM25 being the most common). Retrieval finds documents with matching terms, weighted by term frequency and inverse document frequency. It handles exact keyword matching well and degrades gracefully on out-of-vocabulary terms. It fails on semantic similarity when the user's words and the document's words don't overlap.

The failure modes are opposite, which makes them good candidates for combination.

Implementing Hybrid Search in Qdrant

Qdrant supports sparse vectors natively as of version 1.7. You can store a sparse vector alongside the dense vector in the same collection.

Collection Setup

from qdrant_client import QdrantClient
from qdrant_client.models import (
    VectorParams,
    SparseVectorParams,
    Distance,
)

client = QdrantClient(host="localhost", port=6333)

client.create_collection(
    collection_name="hybrid_collection",
    vectors_config={
        "dense": VectorParams(size=768, distance=Distance.COSINE)
    },
    sparse_vectors_config={
        "sparse": SparseVectorParams()
    }
)

Indexing with Both Vectors

import ollama
from qdrant_client.models import PointStruct, SparseVector

def embed_dense(text: str) -> list[float]:
    return ollama.embeddings(model="nomic-embed-text", prompt=text)["embedding"]

def embed_sparse(text: str) -> SparseVector:
    # BM25-style sparse encoding using token frequencies
    tokens = text.lower().split()
    token_counts = {}
    for token in tokens:
        token_counts[token] = token_counts.get(token, 0) + 1

    # Map tokens to integer indices (stable hash)
    indices = []
    values = []
    for token, count in token_counts.items():
        idx = abs(hash(token)) % 100000
        indices.append(idx)
        values.append(float(count))

    return SparseVector(indices=indices, values=values)

def index_document(doc_id: str, text: str, metadata: dict) -> None:
    dense_vec = embed_dense(text)
    sparse_vec = embed_sparse(text)

    client.upsert(
        collection_name="hybrid_collection",
        points=[
            PointStruct(
                id=doc_id,
                vector={"dense": dense_vec, "sparse": sparse_vec},
                payload={"text": text, **metadata}
            )
        ]
    )

Querying with Both Retrievers

from qdrant_client.models import SparseVector, SearchRequest, NamedSparseVector, NamedVector

def hybrid_search(
    query: str,
    k: int = 10,
    dense_weight: float = 0.7,
    sparse_weight: float = 0.3
) -> list[dict]:
    dense_vec = embed_dense(query)
    sparse_vec = embed_sparse(query)

    # Run both searches in parallel
    dense_results = client.search(
        collection_name="hybrid_collection",
        query_vector=NamedVector(name="dense", vector=dense_vec),
        limit=k * 2,
        with_payload=True
    )

    sparse_results = client.search(
        collection_name="hybrid_collection",
        query_vector=NamedSparseVector(name="sparse", vector=sparse_vec),
        limit=k * 2,
        with_payload=True
    )

    # Combine using weighted Reciprocal Rank Fusion
    return reciprocal_rank_fusion(
        dense_results,
        sparse_results,
        dense_weight=dense_weight,
        sparse_weight=sparse_weight,
        k=k
    )

Combining Results: Reciprocal Rank Fusion

The simplest and most reliable combination method is Reciprocal Rank Fusion (RRF). Each document receives a score based on its rank in each result list, not its raw similarity score. This avoids the problem of score scales being incomparable across retrievers.

def reciprocal_rank_fusion(
    dense_results,
    sparse_results,
    dense_weight: float = 0.7,
    sparse_weight: float = 0.3,
    rrf_k: int = 60,
    k: int = 10
) -> list[dict]:
    scores = {}

    for rank, result in enumerate(dense_results):
        doc_id = str(result.id)
        rrf_score = dense_weight / (rrf_k + rank + 1)
        scores[doc_id] = scores.get(doc_id, 0) + rrf_score
        if doc_id not in scores:
            scores[doc_id] = {"score": 0, "payload": result.payload}
        scores.setdefault(doc_id + "_payload", result.payload)

    for rank, result in enumerate(sparse_results):
        doc_id = str(result.id)
        rrf_score = sparse_weight / (rrf_k + rank + 1)
        scores[doc_id] = scores.get(doc_id, 0) + rrf_score

    # Collect payloads separately
    payloads = {}
    for result in dense_results + sparse_results:
        doc_id = str(result.id)
        if doc_id not in payloads:
            payloads[doc_id] = result.payload

    ranked = sorted(
        [(doc_id, score) for doc_id, score in scores.items()
         if not doc_id.endswith("_payload")],
        key=lambda x: x[1],
        reverse=True
    )

    return [
        {"id": doc_id, "score": score, "text": payloads.get(doc_id, {}).get("text", "")}
        for doc_id, score in ranked[:k]
        if doc_id in payloads
    ]

Tuning the Blend Ratio

The optimal blend ratio depends on your query distribution. We measured across three query types on the same eval set:

Query type Dense only Sparse only 70/30 hybrid 50/50 hybrid
Semantic (paraphrase) 91% R@10 74% R@10 92% R@10 89% R@10
Keyword (exact identifier) 71% R@10 88% R@10 84% R@10 91% R@10
Mixed 89% R@10 83% R@10 93% R@10 93% R@10

For a predominantly semantic workload, 70/30 dense-to-sparse performed best in our runs. For a keyword-heavy workload, 50/50 or even 30/70 may be better. If you have query logs, classify a sample by type and tune accordingly.

Using SPLADE Instead of BM25

The hash-based sparse encoding above is a functional approximation of BM25. For better sparse retrieval, SPLADE (Sparse Lexical and Expansion model) learns to expand query and document terms using a language model. It produces sparse vectors that generalize better than raw term frequency.

SPLADE models are available on HuggingFace. The vectors can be stored in the same Qdrant sparse vector field.

from transformers import AutoTokenizer, AutoModelForMaskedLM
import torch

tokenizer = AutoTokenizer.from_pretrained("naver/splade-cocondenser-ensembledistil")
model = AutoModelForMaskedLM.from_pretrained("naver/splade-cocondenser-ensembledistil")

def embed_splade(text: str) -> SparseVector:
    inputs = tokenizer(text, return_tensors="pt", truncation=True, max_length=512)
    with torch.no_grad():
        outputs = model(**inputs)
    logits = outputs.logits
    relu_log = torch.log(1 + torch.relu(logits))
    weighted_log = relu_log * inputs["attention_mask"].unsqueeze(-1)
    sparse_weights = torch.max(weighted_log, dim=1).values.squeeze()
    nonzero = sparse_weights.nonzero().squeeze()
    indices = nonzero.tolist()
    values = sparse_weights[nonzero].tolist()
    return SparseVector(indices=indices, values=values)

SPLADE requires more memory and compute than BM25-style encoding but produces consistently better sparse retrieval, particularly on queries with terms that don't appear verbatim in the documents.

When Hybrid Search Is Worth It

Hybrid search adds indexing cost (two vectors per document) and query latency (two retrievals plus a fusion step). The added complexity is worth it when:

  • Your corpus contains identifiers, error codes, product names, or other rare exact strings
  • You handle both lookup and synthesis queries in the same pipeline
  • You have already tuned chunking and metadata filtering and want the next increment

If your queries are almost entirely semantic (users describing concepts rather than naming things), dense retrieval alone may be sufficient. Run a classification of your actual query logs before deciding.


Get the next one

I send one short email a week: one production bug, debugged, plus the
companion code for each deep-dive. No spam, unsubscribe anytime.

👉 Subscribe (free)

Reader challenge: are you using hybrid search in your RAG pipeline? What blend ratio works for your workload? Reply with what you found.

Sources

  1. Qdrant sparse vectors documentation: https://qdrant.tech/documentation/concepts/vectors/#sparse-vectors
  2. SPLADE model (naver/splade-cocondenser-ensembledistil): https://huggingface.co/naver/splade-cocondenser-ensembledistil
  3. Reciprocal Rank Fusion paper (Cormack et al.): https://plg.uwaterloo.ca/~gvcormac/cormacksigir09-rrf.pdf

About the Author

Toc Am

Founder of AmtocSoft. Writing practical deep-dives on AI engineering, cloud architecture, and developer tooling. Previously built backend systems at scale. Reviews every post published under this byline.

LinkedIn X / Twitter

Published: 2026-07-27 · Written with AI assistance, reviewed by Toc Am.

Get These In Your Inbox

Weekly deep-dives on AI engineering, no fluff. Join the newsletter →

Subscribe (free)

Or grab the book ($39, ~100 pages) · Buy me a coffee

Buy Me a Coffee · 🔔 YouTube · 💼 LinkedIn · 🐦 X/Twitter

Metadata Filtering in Self-Hosted RAG: How to Query Only What's Relevant

Hero image showing metadata filter narrowing a document collection

I have a RAG pipeline indexing documents across multiple teams and product lines. When a user on the payments team asks a question, I do not want the retrieval system returning documentation from the infrastructure team, even if those documents are semantically similar to the query.

The solution is metadata filtering: storing structured attributes alongside each document chunk and using those attributes to restrict which documents are searched. This post covers how to implement metadata filtering in Qdrant, what happens to retrieval quality when you filter aggressively, and how to avoid the common pitfalls.

What Metadata Filtering Is

Every document chunk in a vector database can carry a payload alongside the vector. The payload is a JSON object of key-value pairs: team, document_type, date, language, access_level, or anything else relevant to your use case.

A metadata filter is a predicate applied to the payload before or during vector search. Instead of searching all 500,000 documents, you search the subset matching the filter (for example, all documents where team = "payments" and date >= "2026-01-01").

This is different from post-filtering (retrieving candidates and then discarding non-matching ones). Post-filtering reduces your effective K without finding more candidates, which degrades recall. Qdrant applies filters during HNSW traversal, so the search only visits matching segments.

Designing Your Metadata Schema

The right metadata schema depends on how your users filter content. Common attributes:

Source/ownership: team, department, product_line, author
Document type: doc_type (e.g., "runbook", "api_reference", "policy", "ticket")
Temporal: date, last_updated, version
Access: access_level, visibility
Content attributes: language, region, topic

Store metadata at index time and keep it normalized. Inconsistent values in the same field ("Payments", "payments", "PAYMENTS") will split your filter into three buckets, each too small to search effectively.

Indexing with Metadata in Qdrant

from qdrant_client import QdrantClient
from qdrant_client.models import PointStruct, VectorParams, Distance
import ollama

client = QdrantClient(host="localhost", port=6333)

def embed(text: str) -> list[float]:
    return ollama.embeddings(model="nomic-embed-text", prompt=text)["embedding"]

def index_chunk(chunk_id: str, text: str, metadata: dict) -> None:
    vector = embed(text)
    client.upsert(
        collection_name="your_collection",
        points=[
            PointStruct(
                id=chunk_id,
                vector=vector,
                payload={"text": text, **metadata}
            )
        ]
    )

# Example with metadata
index_chunk(
    chunk_id="doc_001_chunk_003",
    text="The payment gateway timeout is configured to 30 seconds by default.",
    metadata={
        "team": "payments",
        "doc_type": "runbook",
        "date": "2026-06-15",
        "access_level": "internal"
    }
)

Querying with Filters

Qdrant's filter syntax lets you combine conditions:

from qdrant_client.models import Filter, FieldCondition, MatchValue, Range

def retrieve_filtered(
    query: str,
    team: str,
    doc_types: list[str] = None,
    k: int = 10
) -> list[dict]:
    vector = embed(query)

    conditions = [
        FieldCondition(key="team", match=MatchValue(value=team))
    ]

    if doc_types:
        conditions.append(
            FieldCondition(key="doc_type", match=MatchValue(value=doc_types))
        )

    results = client.search(
        collection_name="your_collection",
        query_vector=vector,
        query_filter=Filter(must=conditions),
        limit=k,
        with_payload=True
    )

    return [
        {"id": str(r.id), "text": r.payload.get("text", ""), "score": r.score}
        for r in results
    ]

How Filtering Affects Retrieval Quality

Filtering always reduces the candidate pool. Smaller candidate pools mean the HNSW graph has fewer connections to traverse, which can reduce recall. The relevant document is in the corpus, but the filtered subgraph may not find the path to it.

The practical rule: keep filtered candidate pools above a few thousand documents. Filtering to a very small subset (fewer than a few hundred documents) is often better served by full-text search or a direct lookup rather than vector search.

We measured the impact on our eval set by progressively restricting the filter:

Chart showing recall vs filtered corpus size
Filter scope Corpus size Recall@10 Recall@1
No filter (full corpus) 500,000 docs 89% 81%
Single team filter ~50,000 docs 88% 80%
Team + doc_type filter ~5,000 docs 85% 77%
Team + doc_type + recent date window ~500 docs 71% 63%

Filtering to a single team had almost no impact. Filtering to a specific document type within a team had a modest impact. Restricting to a recent date window on a small corpus caused a significant drop. In our runs, filtering to roughly 500 documents meant vector search was no longer the right tool.

Handling the Fallback Case

When a filter produces too few results, you have two options:

Widen the filter: remove the most restrictive condition and retry. If team + doc_type + date returns too few results, retry with team + doc_type only.

Fall back to full-text search: for very small filtered sets, BM25 or exact match often outperforms vector search anyway.

def retrieve_with_fallback(
    query: str,
    team: str,
    doc_type: str,
    k: int = 10,
    min_results: int = 20
) -> list[dict]:
    # Try narrow filter first
    results = retrieve_filtered(query, team=team, doc_types=[doc_type], k=k)

    if len(results) >= min_results:
        return results

    # Widen to team only
    results = retrieve_filtered(query, team=team, k=k)

    if len(results) >= min_results:
        return results

    # Fall back to no filter
    return retrieve_filtered(query, team=None, k=k)

Qdrant's Payload Indexing

By default, Qdrant scans payloads at query time. For large collections with frequent filtering on specific fields, create payload indexes:

from qdrant_client.models import PayloadSchemaType

client.create_payload_index(
    collection_name="your_collection",
    field_name="team",
    field_schema=PayloadSchemaType.KEYWORD
)

client.create_payload_index(
    collection_name="your_collection",
    field_name="date",
    field_schema=PayloadSchemaType.DATETIME
)

Keyword indexes speed up equality filters. DateTime indexes enable efficient range queries. Add indexes for fields you filter on frequently. For fields you filter on rarely, the scan overhead is acceptable.

Access Control via Metadata

Metadata filtering is a clean pattern for access control: store access_level or user_group in the payload and filter by the current user's permissions at query time. The user never sees documents outside their access level because those documents are excluded from the search.

This is not a security boundary on its own. It depends on the application layer correctly passing the user's access level to the retrieval function. Treat it as a retrieval constraint, not a security control.


Get the next one

I send one short email a week: one production bug, debugged, plus the
companion code for each deep-dive. No spam, unsubscribe anytime.

👉 Subscribe (free)

Reader challenge: are you using metadata filtering in your RAG pipeline? What attributes do you filter on? Reply with your schema.

Sources

  1. Qdrant filtering documentation: https://qdrant.tech/documentation/concepts/filtering/
  2. Qdrant payload indexing: https://qdrant.tech/documentation/concepts/indexing/#payload-index
  3. Qdrant HNSW with filters: https://qdrant.tech/articles/filtrable-hnsw/

About the Author

Toc Am

Founder of AmtocSoft. Writing practical deep-dives on AI engineering, cloud architecture, and developer tooling. Previously built backend systems at scale. Reviews every post published under this byline.

LinkedIn X / Twitter

Published: 2026-07-27 · Written with AI assistance, reviewed by Toc Am.

Get These In Your Inbox

Weekly deep-dives on AI engineering, no fluff. Join the newsletter →

Subscribe (free)

Or grab the book ($39, ~100 pages) · Buy me a coffee

Buy Me a Coffee · 🔔 YouTube · 💼 LinkedIn · 🐦 X/Twitter

Attention Is All You Need, Explained Simply

We published a plain-language walkthrough of the 2017 transformer paper — queries, keys, values, multi-head attention, and why no-recurrence...