Monday, July 27, 2026

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

Chunking Strategies for RAG: What Actually Changes Retrieval Quality

Hero image showing document chunks being split and embedded

I ran the same eval harness I built in post 288 against a set of chunking configurations and found that chunk size and split strategy had a larger impact on retrieval quality than I expected. Changing from 512-token fixed chunks to 256-token chunks with overlap improved Recall@1 on lookup queries by around 14 points on this corpus. Changing from character-split to sentence-split chunks improved Recall@10 by around 8 points.

These numbers are corpus-specific. Your documents may respond differently. But the methodology is the same: measure before you tune, and measure after.

This post covers the chunking decisions that actually matter, what to expect from each, and how to test them against your own data.

Why Chunking Matters

Your embedding model encodes a chunk as a single vector. Everything in that chunk competes for representation in the vector. If a chunk is too large, the embedding averages over too much content and loses specificity. A query about one detail in the chunk may not retrieve it. If a chunk is too small, each chunk lacks enough context for the embedding to place it meaningfully in vector space.

The right chunk size depends on what your queries look like. Lookup queries (for example, "what is the return policy?") need a short, precise answer. They benefit from smaller chunks that isolate specific facts. Synthesis queries (for example, "summarize the key risks in section 3") need more context. They benefit from larger chunks.

Most RAG pipelines use one chunk size for all queries. This is a practical starting point, but it means you are optimizing for one query type at the expense of the other.

The Main Chunking Decisions

Chunk Size

Chunk size is measured in tokens (the unit your embedding model sees) or characters. Smaller sizes suit lookup-heavy workloads and larger sizes suit synthesis-heavy ones. The LangChain documentation uses 512 as a default starting point, per its text splitter documentation.

Smaller chunks improve Recall@1 for lookup queries because the answer takes up more of the embedding space. Larger chunks improve recall for synthesis queries because they carry more context.

We measured the transition point on this corpus: below our best-performing chunk size, synthesis query recall started to degrade; above it, lookup query recall started to degrade.

Chunk Overlap

Overlap preserves context at chunk boundaries. With zero overlap, a sentence split across two chunks may be retrieved in a form that loses the beginning or end of the key fact. With overlap, the sentence appears fully in both chunks.

Common overlap settings are 10–20% of the chunk size. On this corpus, 50-token overlap on 512-token chunks improved Recall@1 by around 6 points compared to zero overlap, at essentially no additional indexing cost.

Overlap does increase index size proportionally. A 20% overlap increases the number of chunks by roughly 25%.

Split Strategy

Fixed-size splitting cuts the document at a character or token count, regardless of sentence or paragraph boundaries. It is fast and simple but can split sentences mid-phrase, creating chunks where the key fact spans two chunks.

Sentence splitting cuts at sentence boundaries. The chunks are variable in size, but each chunk contains complete sentences. Retrieval quality is generally better because embeddings of complete sentences are more stable than embeddings of sentence fragments.

Recursive splitting tries large separators first (paragraphs, then sentences, then words) and falls back to smaller ones when a chunk exceeds the target size. It is the default strategy in LangChain's RecursiveCharacterTextSplitter and produces the most semantically coherent chunks in practice.

Results on the Same Corpus

Using the same 500,000-document corpus, nomic-embed-text, and the same 1,000-query eval set from the previous posts (500 lookup queries and 500 synthesis queries):

Comparison chart showing retrieval metrics across chunking configurations
Configuration Recall@10 Recall@1 (lookup) Recall@1 (synthesis) Index size
512-token fixed, no overlap 84% 71% 64% baseline
256-token fixed, no overlap 83% 79% 57% 1.9×
512-token fixed, 50-token overlap 87% 77% 69% 1.1×
512-token recursive, 50-token overlap 89% 81% 73% 1.1×
1024-token recursive, 50-token overlap 88% 74% 78% 0.6×

The recursive 512-token configuration with 50-token overlap performed best across both query types on this corpus. Smaller chunks helped lookup queries but hurt synthesis queries. Larger chunks did the reverse.

Implementation

Fixed-Size Splitting

def chunk_fixed(text: str, chunk_size: int = 512, overlap: int = 50) -> list[str]:
    words = text.split()
    chunks = []
    start = 0
    while start < len(words):
        end = start + chunk_size
        chunks.append(" ".join(words[start:end]))
        start += chunk_size - overlap
    return chunks

Sentence Splitting

import re

def chunk_by_sentence(text: str, max_tokens: int = 512) -> list[str]:
    sentences = re.split(r'(?<=[.!?])\s+', text)
    chunks = []
    current = []
    current_len = 0

    for sentence in sentences:
        token_estimate = len(sentence.split())
        if current_len + token_estimate > max_tokens and current:
            chunks.append(" ".join(current))
            current = []
            current_len = 0
        current.append(sentence)
        current_len += token_estimate

    if current:
        chunks.append(" ".join(current))
    return chunks

Recursive Splitting (LangChain)

from langchain.text_splitter import RecursiveCharacterTextSplitter

splitter = RecursiveCharacterTextSplitter(
    chunk_size=2048,
    chunk_overlap=200,
    separators=["\n\n", "\n", ". ", " ", ""]
)

chunks = splitter.split_text(document_text)

The chunk_size here is in characters, not tokens. A rough conversion for English text is approximately 4 characters per token, so 2,048 characters maps to a few hundred tokens in practice.

How to Test Your Configuration

Run this before and after any chunking change using the eval harness from post 288:

def compare_chunking_configs(configs: list[dict], query_pairs: list[dict]) -> list[dict]:
    results = []
    for config in configs:
        # Re-index with this config
        chunks = apply_chunking_config(config)
        index = build_index(chunks)
        metrics = run_eval(query_pairs, index)
        results.append({"config": config, "metrics": metrics})
    return results

Split your query set by query type if possible. A configuration that improves lookup recall while hurting synthesis recall may still be the right choice depending on your workload distribution.

What to Try First

If you are starting from a fixed-size split with no overlap, the highest-value change is adding overlap. It costs roughly 10% more index size and typically improves Recall@1 by several points.

If you are already using overlap, switching to recursive splitting is the next experiment. It handles mixed document types (code, prose, tables) better than fixed splitting and tends to produce more consistent Recall@10.

Changing chunk size is the highest-risk experiment because it changes the character of every chunk in your index. Run it on a test index first, not your production one.


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: what chunk size and strategy are you using in your RAG pipeline? Has changing it ever surprised you? Reply with what you found.

Sources

  1. LangChain RecursiveCharacterTextSplitter documentation: https://python.langchain.com/docs/modules/data_connection/document_transformers/recursive_text_splitter
  2. Sentence Transformers chunking guidance: https://www.sbert.net/examples/applications/semantic-search/README.html
  3. Qdrant best practices for indexing: https://qdrant.tech/documentation/tutorials/bulk-upload/

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

Adding a Reranker to Your Self-Hosted RAG Pipeline: When It Helps and When It Doesn't

Hero image showing a two-stage retrieval pipeline with reranker

I ran the eval harness from the last post against my own RAG pipeline and found the same pattern every time: Recall@10 was fine, but Recall@1 was weaker than it should have been. The right document was in the top 10 results. It just was not reliably in position 1.

This is the problem reranking solves. A reranker takes the top-K results from your vector search and reorders them using a more expensive model that considers the query and document together. The result is a better-ordered list at the cost of latency.

This post covers when to add a reranker, what to expect from it on self-hosted hardware, and where it does not help.

The Two-Stage Retrieval Pattern

Standard RAG retrieval is a single stage: embed the query, search the index, return the top results. The embedding model encodes the query and documents into vectors, and relevance is approximated by vector distance.

The limitation is that vector distance is a coarse signal. Two vectors being close in embedding space means they share semantic structure. It does not mean the document precisely answers the query. For broad queries or queries where subtle distinctions matter, the top-1 result is often not the best result even when it is close in vector space.

A reranker is a second stage. After vector search returns top-K candidates, the reranker takes each (query, candidate) pair and scores them jointly using a cross-encoder model. Cross-encoders see both the query and document at the same time, which gives them much more signal than the separate embeddings used in vector search. The top-K candidates are then reordered by the reranker's scores.

The trade-off is inference cost. Cross-encoders are slower than embedding lookups because they run a full forward pass for each (query, candidate) pair. With K=10 and a moderate reranker, you are doing 10 cross-encoder inference calls per query instead of one embedding lookup.

Self-Hosted Reranker Options

Three models run well via Ollama or directly in Python without an API dependency:

ms-marco-MiniLM-L-6-v2: a 22M-parameter cross-encoder fine-tuned on the MS MARCO passage retrieval dataset. Fast, low memory, and the default starting point for most reranking setups. Available via sentence-transformers.

ms-marco-MiniLM-L-12-v2: the 12-layer variant of the same model. Slightly better quality at the cost of roughly double the inference time.

bge-reranker-v2-m3: from BAAI, the same research group behind bge-m3 embeddings. A 568M-parameter model with significantly better reranking quality, particularly on multilingual and technical content. Considerably slower on CPU but strong on GPU.

Results on the Same Corpus

Using the same 500,000-document corpus from the vector database and embedding model comparisons, nomic-embed-text as the base retrieval model, and the same 1,000-query eval set:

Comparison chart showing retrieval metrics with and without reranker
Configuration Recall@10 Recall@1 p50 latency p99 latency
nomic-embed-text only 87% 68% 8ms 31ms
+ ms-marco-MiniLM-L-6-v2 87% 79% 41ms 87ms
+ ms-marco-MiniLM-L-12-v2 87% 82% 78ms 163ms
+ bge-reranker-v2-m3 87% 86% 198ms 421ms

Recall@10 stays flat. Reranking does not change which documents are retrieved; it changes how they are ordered. Recall@1 is where the improvement shows up.

The MiniLM-L-6 reranker takes Recall@1 from 68% to 79% with modest latency. The bge-reranker-v2-m3 pushes it to 86% but adds substantial latency. For a RAG system where the LLM generates answers primarily from the top result, the difference between 68% and 86% Recall@1 is significant.

When Reranking Helps

Reranking helps most when:

Your top result matters a lot. If you pass only the top 1-3 documents to the LLM, or if the LLM's attention focuses heavily on the first context chunk, improving Recall@1 translates directly to answer quality.

You have sufficient latency budget. Adding a reranker adds latency that varies widely by model and hardware. In our runs it ranged from a few dozen milliseconds for the smallest model to several hundred for the largest. If your use case tolerates latency in the hundreds of milliseconds, the quality gain is usually worth it. For interactive applications with tight latency requirements, the MiniLM models fit; bge-reranker may not.

Your queries are specific and discriminative. Reranking helps most when the correct answer is clearly distinguishable from the alternatives given the full query and document text together. For vague or broad queries, the quality improvement is smaller.

When Reranking Does Not Help

When Recall@10 is already the bottleneck. If the right document is not in the top 10 from vector search, reranking cannot fix it. Run your eval harness to check whether your problem is retrieval precision (wrong ordering) or retrieval recall (right document not in the candidate set). Reranking addresses the former.

When latency is tightly constrained. If your system needs very tight retrieval latency, even the smallest reranker may not fit. Profile first. Profile first.

For short factual queries with clear lexical matches. Simple lookups where the query words closely match the document words benefit less from cross-encoder reranking.

Implementation

Adding a reranker with sentence-transformers:

from sentence_transformers import CrossEncoder

reranker = CrossEncoder("cross-encoder/ms-marco-MiniLM-L-6-v2")

def rerank(query: str, candidates: list[dict], top_n: int = 3) -> list[dict]:
    pairs = [(query, c["text"]) for c in candidates]
    scores = reranker.predict(pairs)
    ranked = sorted(
        zip(scores, candidates),
        key=lambda x: x[0],
        reverse=True
    )
    return [doc for _, doc in ranked[:top_n]]

The full pipeline with Qdrant:

from qdrant_client import QdrantClient
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 retrieve_and_rerank(query: str, k_retrieve: int = 20, k_return: int = 5) -> list[dict]:
    vector = embed(query)
    results = client.search(
        collection_name="your_collection",
        query_vector=vector,
        limit=k_retrieve,
        with_payload=True
    )
    candidates = [
        {"id": str(r.id), "text": r.payload.get("text", ""), "score": r.score}
        for r in results
    ]
    return rerank(query, candidates, top_n=k_return)

One detail that matters: retrieve more candidates than you return (k_retrieve > k_return). The reranker needs enough candidates to work with. Retrieving 20 and returning the reranked top 5 is a common pattern. Retrieving only 5 and reranking to return 5 adds latency without giving the reranker enough candidates to make a meaningful difference.

The Latency-Quality Curve

There is no single right point on this curve. The right trade-off depends on your use case:

For an internal developer tool where latency is not critical, bge-reranker-v2-m3 is reasonable — we measured it at roughly 200ms p50 in our runs on this hardware.

For a customer-facing chat interface, MiniLM-L-6 is the right starting point — we measured it at roughly 40ms p50 in our runs. If the quality improvement does not satisfy your Recall@1 target, consider improving the base retrieval (better embedding model, larger chunk overlap) before adding the heavier reranker.

For batch processing or async retrieval where latency is irrelevant, use the highest-quality reranker that fits on your hardware.

Profile on your actual hardware before committing. The numbers above are from a specific GPU configuration. On CPU-only hardware, bge-reranker-v2-m3 will be significantly slower.


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: have you measured the Recall@1 improvement from adding a reranker to your pipeline? Reply with the model you used and the before/after numbers.

Sources

  1. sentence-transformers CrossEncoder documentation: https://www.sbert.net/docs/cross_encoder/usage/usage.html
  2. BAAI bge-reranker-v2-m3: https://huggingface.co/BAAI/bge-reranker-v2-m3
  3. MS MARCO dataset: https://microsoft.github.io/msmarco/

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

How to Build a RAG Evaluation Harness for Your Self-Hosted Pipeline

Hero image showing a RAG evaluation pipeline diagram

Every time I share benchmark numbers in this series, I add the same caveat: run your own evaluation on your own data. This post is how to actually do that.

The benchmark numbers for embedding models, vector databases, and chunking strategies are useful for narrowing the field. They tell you which options are worth testing. They do not tell you which option works for your documents, your queries, and your latency requirements. That requires a retrieval evaluation harness built around your actual workload.

This post walks through building a minimal but honest eval harness for a self-hosted RAG pipeline.

What a Retrieval Eval Harness Does

A retrieval eval harness answers one question: given this query, did the retrieval system return the right documents?

It does this by running a set of queries against your index, comparing the returned documents against a ground-truth set of relevant documents, and computing recall metrics. The output is a number you can track over time and compare across configuration changes.

The three things you need to build one:

  1. A set of queries representative of real user questions
  2. Ground-truth labels: for each query, which documents in your corpus are relevant?
  3. A scoring function: recall, precision, or NDCG depending on your use case

The hard part is step two. Getting ground-truth labels is the main reason most teams skip building an eval harness, and the main reason they cannot tell whether their retrieval is actually working.

Building the Query Set

The best queries come from real users. If your system is in production, sample actual queries and filter to ones where you know the answer. If you are building before production, generate synthetic queries from your corpus.

Synthetic query generation using a local LLM works well enough for an initial harness. For each document chunk you want to test, generate a question that the chunk would answer:

import ollama

def generate_query_for_chunk(chunk_text: str, model: str = "llama3.1:8b") -> str:
    response = ollama.chat(
        model=model,
        messages=[{
            "role": "user",
            "content": (
                "Generate one short question that this text answers directly. "
                "Return only the question, nothing else.\n\n"
                f"Text: {chunk_text}"
            )
        }]
    )
    return response["message"]["content"].strip()

Run this over a sample of your corpus, not every chunk. A representative sample works well here. For a corpus of hundreds of thousands of chunks, a sample in the low thousands gives stable metrics.

The query-chunk pairs from this process become your ground-truth dataset: for each generated query, the source chunk is the relevant document.

Labeling Ground Truth

For each query, you need to know which documents are relevant. There are three ways to do this:

Synthetic labels from generation: the chunk you generated the query from is the relevant document. Fast, zero human effort, and good enough to catch major regressions. The weakness is that it only measures whether retrieval can find the specific chunk, not whether other chunks might also be relevant.

LLM-as-judge: for each (query, candidate document) pair, ask a local LLM to rate relevance. This is slower and adds noise but can catch cases where multiple documents are relevant to the same query.

Human labels: the most accurate, required for production evals where you care about fine-grained recall at the top of the list. For an initial harness, human labels for a few hundred queries is a good investment.

For a self-hosted pipeline getting started, synthetic labels with a few hundred query-chunk pairs get you 80% of the value at near-zero cost.

The Scoring Function

Recall@K measures whether a relevant document appears in the top K results. It is the right metric for RAG because the LLM can only use what the retrieval system returns.

def recall_at_k(retrieved_ids: list[str], relevant_ids: set[str], k: int) -> float:
    top_k = retrieved_ids[:k]
    hits = sum(1 for doc_id in top_k if doc_id in relevant_ids)
    return hits / len(relevant_ids) if relevant_ids else 0.0

Recall@1 is particularly useful for RAG: it tells you whether the single most relevant document is at the top of the list. When your LLM only reads the top result (or when the top result heavily influences the generated answer), Recall@1 matters more than Recall@10.

Mean Reciprocal Rank (MRR) is useful when you care about ranking quality rather than just presence in the top K:

def mrr(retrieved_ids: list[str], relevant_ids: set[str]) -> float:
    for rank, doc_id in enumerate(retrieved_ids, start=1):
        if doc_id in relevant_ids:
            return 1.0 / rank
    return 0.0

Putting It Together

A minimal eval loop that runs your query set against your index and computes recall:

from qdrant_client import QdrantClient
import ollama

client = QdrantClient(host="localhost", port=6333)
COLLECTION = "your_collection"
EMBED_MODEL = "nomic-embed-text"

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

def retrieve(query: str, k: int = 10) -> list[str]:
    vector = embed(query)
    results = client.search(
        collection_name=COLLECTION,
        query_vector=vector,
        limit=k
    )
    return [str(r.id) for r in results]

def run_eval(query_pairs: list[dict], k: int = 10) -> dict:
    recall_scores = []
    recall1_scores = []

    for pair in query_pairs:
        query = pair["query"]
        relevant = {pair["relevant_chunk_id"]}
        retrieved = retrieve(query, k=k)

        recall_scores.append(recall_at_k(retrieved, relevant, k))
        recall1_scores.append(recall_at_k(retrieved, relevant, 1))

    return {
        f"recall@{k}": sum(recall_scores) / len(recall_scores),
        "recall@1": sum(recall1_scores) / len(recall1_scores),
        "n_queries": len(query_pairs)
    }

Run this before and after any configuration change. Embedding model swap, chunk size adjustment, index parameter tuning: all of these should go through the eval before you deploy.

What to Track Over Time

The numbers are only useful if you track them. Store eval results with enough context to understand what changed:

import json
from datetime import datetime

def save_eval_result(metrics: dict, config: dict, path: str = "eval_results.jsonl"):
    record = {
        "timestamp": datetime.utcnow().isoformat(),
        "config": config,
        "metrics": metrics
    }
    with open(path, "a") as f:
        f.write(json.dumps(record) + "\n")

# Example config dict
config = {
    "embed_model": "nomic-embed-text",
    "collection": "docs_v3",
    "chunk_size": "medium",
    "index_params": {"m": 16, "ef_construct": 100}
}

A few hundred eval results stored in a JSONL file is enough to track trends and spot regressions. You do not need a dedicated metrics platform for a self-hosted setup.

Common Issues and What They Indicate

Recall@10 is good but Recall@1 is poor: the relevant document is in the top 10 but rarely at position 1. Consider adding a reranker after retrieval to push the most relevant result to the top.

Recall drops on filtered queries: your index does not handle metadata filters efficiently. Check whether your vector database applies filters during search or post-search. Qdrant filters during HNSW traversal; others filter after retrieval, which cuts effective recall with selective filters.

High recall on synthetic queries but poor quality answers from the LLM: retrieval is finding the right chunks but they may not contain enough context. Try larger chunks or a sliding window approach that overlaps adjacent chunks.

Recall is stable but answer quality regressed: the issue is downstream of retrieval. Check your prompt template, context assembly, and LLM.

A Note on Eval Harness Maintenance

The query set goes stale as your corpus evolves. Regenerate synthetic queries when you add a major new document category. Add real user queries to the set whenever you can — even a slow trickle of labeled real queries improves the harness over time.

The eval harness is not a one-time project. It is infrastructure. Treat it like your index backup: set it up once, run it on a schedule, and update it when the underlying system changes.


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: what query set are you using to evaluate your RAG pipeline? Synthetic, real, or none? Reply with what you are doing.

Sources

  1. MTEB Leaderboard, retrieval benchmarks: https://huggingface.co/spaces/mteb/leaderboard
  2. Qdrant filtering documentation: https://qdrant.tech/documentation/concepts/filtering/
  3. Ollama embeddings API: https://github.com/ollama/ollama/blob/main/docs/api.md#generate-embeddings

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

AI as Infrastructure: Value Moves Up-Stack

For a few years the AI conversation was about who had the biggest model. That is the wrong altitude now. Models still matter, the way CPUs s...