Module 4.3 — ANN Search, Top-K & Metadata Filtering

The search mechanics that power every RAG system — in depth


What This Module Covers

Last module gave you the big picture of how vector databases work.

This module goes one level deeper — into the actual search mechanics you will control as a developer.

When you query a vector database in your RAG application — you make decisions about:

How many results to return     → Top-K
How to filter by metadata      → Metadata Filtering  
How to balance speed vs quality → Search Parameters

Getting these right is the difference between a RAG system that works and one that works well.


Part 1 — Top-K Search

What it Means

Top-K simply means:

Return the K most similar vectors to my query.

K = 1  → return only the single best match
K = 5  → return top 5 most similar results
K = 10 → return top 10 most similar results

You've already seen this. But let's go deeper into what it actually returns and how you use it.


What a Top-K Result Looks Like

When you query a vector database with K=5, you get back something like this:


    // Query result — top 5 most similar chunks
    [
        {
            id: "handbook_chunk_47",
            score: 0.9421,
            // cosine similarity score — how close this is to your query
            // 0.94 = very similar

            text: "Common side effects of aspirin include nausea, stomach pain, and heartburn.In rare cases...",
            // the original text chunk that was stored

            metadata: {
                source: "medical_handbook.pdf",
                page: 23,
                category: "side_effects",
                lastUpdated: "2024-01"
            }
            // all the extra info stored alongside the vector
        },
        {
            id: "research_paper_chunk_12",
            score: 0.8876,
            text: "Aspirin has been associated with gastrointestinal bleeding in approximately 2% of long- term users...",
            metadata: {
                source: "research_paper_2023.pdf",
                page: 7,
                category: "clinical_studies",
                lastUpdated: "2023-11"
            }
        },
        {
            id: "drug_guide_chunk_89",
            score: 0.8234,
            text: "Patients taking aspirin regularly should be aware of potential bleeding risks...",
            metadata: { source: "drug_guide.pdf", page: 45 }
        },
        {
            id: "faq_chunk_3",
            score: 0.7123,
            text: "Is aspirin safe for everyone? Aspirin is not recommended for children under 16...",
            metadata: { source: "faq.pdf", page: 2 }
        },
        {
            id: "history_chunk_5",
            score: 0.6234,
            text: "The history of aspirin dates back to ancient willow bark used by Egyptians...",
            metadata: { source: "aspirin_history.pdf", page: 1 }
        }
    ]

Notice the scores drop from 0.94 down to 0.62. The first three are clearly relevant. The last two are less relevant but still returned because K=5.


Choosing the Right K — Practical Guide

This is a real decision you make in every RAG system:

K too small (e.g. K=1 or K=2):

Risk: Miss important context
Example: User asks a complex question that needs 
         information from multiple document sections
         → K=1 only returns one chunk
         → LLM doesn't have enough context
         → Answer is incomplete

K too large (e.g. K=20 or K=50):

Risk 1: Context window fills up
→ 20 chunks × 500 words each = 10,000 words
→ Might exceed context limit
→ Definitely expensive

Risk 2: Noisy context confuses the LLM
→ Irrelevant chunks get included
→ LLM gets confused by contradictory information
→ Answer quality drops

Risk 3: Higher cost
→ More tokens injected = more tokens billed

The sweet spot for most RAG applications:

K = 3 to 5   → good starting point for most use cases
K = 5 to 10  → when questions need broader context
K = 1 to 3   → when you want precise, focused answers

In production you'll tune this based on testing. Start with K=5 and adjust.


Score Thresholds — Filtering by Quality

Top-K always returns exactly K results — even if some are not very relevant.

To avoid injecting low-quality chunks into your LLM prompt — you can add a score threshold:


    const results = await vectorDB.query({
        vector: questionEmbedding,
        topK: 10,
        // get up to 10 results

        scoreThreshold: 0.70,
        // but only return results with similarity score above 0.70
        // results below 0.70 are dropped even if K hasn't been reached
    });

    // Example outcome:
    // Requested K=10 but only 4 chunks scored above 0.70
    // → returns 4 results (not 10)
    // Better to have 4 relevant chunks than 10 with 6 bad ones

Typical thresholds:

> 0.90  → nearly identical meaning    (very strict)
> 0.75  → very relevant               (strict — good for focused tasks)
> 0.65  → reasonably relevant         (balanced — good default)
> 0.50  → somewhat related            (loose — more results, less precise)
< 0.50  → probably not relevant       (usually not worth including)

Part 2 — Metadata Filtering

Why Metadata Filtering Exists

Pure vector search finds semantically similar content. But sometimes similarity alone is not enough.

Real scenarios where you need more:

Scenario 1 — Multi-tenant app
You have 100 companies using your RAG product.
Each company's documents must stay separate.
User from Company A must NEVER see Company B's data.

Pure vector search:
→ Might return similar chunks from ANY company ❌

Vector search + metadata filter:
→ WHERE companyId = "company_A" ✓


Scenario 2 — Time-sensitive information
User asks about "current tax rates"
Your database has tax documents from 2019, 2021, 2023.

Pure vector search:
→ Returns most semantically similar — might return 2019 doc ❌

Vector search + metadata filter:
→ WHERE year = 2023 ✓ (only search recent documents)


Scenario 3 — Category-specific search
Medical app — user is a doctor asking about pediatric dosage.

Pure vector search:
→ Returns all aspirin-related content including adult dosage ❌

Vector search + metadata filter:
→ WHERE patientType = "pediatric" ✓

How Metadata Filtering Works

The filter runs at the same time as the vector search — not after it.

WITHOUT filter:
Search ALL vectors → find top K similar → return results

WITH filter:
Search ONLY vectors matching the filter → find top K similar → return results

The filter narrows down the search space FIRST.
Then similarity search runs within that space.

This is important to understand because it affects your results:

Example:
Total vectors: 100,000
After filter (category = "medical"): 8,000 vectors
Top K from those 8,000: 5 results

You searched 8,000 not 100,000.
Results are all medical AND most similar to your question.

Metadata Filtering Syntax

Different vector databases have slightly different syntax — but the concepts are the same.

Here are the common filter operations:

Exact match:


    filter: { category: "medical" }
    // only return vectors where category equals "medical"

Multiple conditions (AND):


    filter: {
        category: "medical",
        language: "english"
    }
    // category = "medical" AND language = "english"

Comparison operators:


    filter: {
        year: { $gte: 2022 },
        // year greater than or equal to 2022

        pageCount: { $lte: 100 }
        // pageCount less than or equal to 100
    }

OR conditions:


    filter: {
        $or: [
            { category: "medical" },
            { category: "pharmaceutical" }
        ]
    }
    // category = "medical" OR category = "pharmaceutical"

IN operator:


    filter: {
        source: { $in: ["handbook.pdf", "guidelines.pdf", "faq.pdf"] }
    }
    // source is one of these three files

NOT operator:


    filter: {
        category: { $ne: "archived" }
    }
    // category is NOT "archived"


Designing Good Metadata — Practical Tips

The metadata you store when indexing determines what filters you can apply when searching.

Think ahead — what filters will your users need?

Bad metadata design:


    // Storing too little
    {
        source: "document1.pdf"
    }
    // Can only filter by filename — not very useful

Good metadata design:


    // Storing everything useful
    {
    source: "medical_handbook_2023.pdf",
    // which file this came from

    page: 47,
    // which page in the file

    chunkIndex: 3,
    // which chunk on that page (for ordering)

    category: "side_effects",
    // what type of content

    documentType: "handbook",
    // what type of document

    specialty: "cardiology",
    // medical specialty

    lastUpdated: "2024-01-15",
    // when this document was last updated

    authorVerified: true,
    // quality flag

    language: "english",
    // language of the content

    companyId: "company_abc",
    // for multi-tenant apps — critical for data isolation
    }

Good metadata makes your RAG system dramatically more useful.


Part 3 — Search Quality Parameters

Beyond K and filters — vector databases expose parameters that let you control the tradeoff between search speed and accuracy.


The Speed vs Accuracy Tradeoff

Remember HNSW from last module — it skips most vectors to search fast.

But how many vectors does it check? You control this.

Parameter: ef (in HNSW — also called efSearch)
→ Higher ef = check more candidates = more accurate = slower
→ Lower ef  = check fewer candidates = faster = slightly less accurate
ef = 10   → very fast, ~90% accuracy
ef = 50   → fast, ~97% accuracy    ← good default
ef = 200  → slower, ~99% accuracy
ef = 500  → slow, ~99.9% accuracy

For most RAG applications — the default ef setting is fine. You'd only tune this if:

  • You need maximum accuracy (medical, legal) → increase ef
  • You have extremely high traffic needs → decrease ef

Reranking — A Second Pass for Better Results

This is an advanced technique but important to know about early.

The problem with ANN search alone:

ANN search returns top 5 by vector similarity.
But vector similarity is not perfect.
Sometimes a chunk that's "semantically similar" 
is not actually the best answer to the specific question.

The solution — Reranking:

Step 1 — Initial ANN search
→ Get top 20 results by vector similarity (fast)
→ Cast a wide net

Step 2 — Reranker model
→ Take those 20 candidates
→ Run a more expensive but more accurate model
→ Score each one specifically for this question
→ Re-order them

Step 3 — Take top 5 from reranked results
→ Now you have the best 5 from the best 20
→ Much higher quality than top 5 from ANN alone
Without reranking:
ANN returns [A, B, C, D, E] by vector similarity
→ C and D might not actually answer the question well

With reranking:
ANN returns top 20: [A, B, C, D, E, F, G, H...]
Reranker reorders: [A, F, B, K, E] ← F and K moved up
→ Better answers, even if they weren't top 5 by similarity

We'll use reranking in Phase 5 when building the full RAG pipeline.


Part 4 — Putting It All Together

Here is a complete, realistic vector database query for a RAG system:


    async function searchDocuments(userQuestion, userId, category) {

        // Step 1 — Embed the user's question
        const questionEmbedding = await getEmbedding(userQuestion);
        // questionEmbedding = [0.23, -0.87, 0.41, ...] (1536 numbers)

        // Step 2 — Query the vector database
        const searchResults = await vectorDB.query({

            vector: questionEmbedding,
            // the question as a vector

            topK: 10,
            // get up to 10 candidates

            scoreThreshold: 0.65,
            // only results with similarity above 0.65

            filter: {
                userId: userId,
                // CRITICAL — only search this user's documents
                // prevents data leakage between users

                category: category,
                // only search within the relevant category

                isArchived: { $ne: true },
                // exclude archived documents
            },

            includeMetadata: true,
            // return metadata alongside results

            includeValues: false,
            // don't return the actual vectors (saves bandwidth)
        });

        // searchResults is now an array of up to 10 objects
        // each has: id, score, text, metadata

        // Step 3 — Filter out low quality results
        const relevantChunks = searchResults
            .filter(result => result.score > 0.65)
            // double-check threshold (some DBs handle this internally)

            .slice(0, 5);
        // take only top 5 from the results

        // relevantChunks is now array of 5 most relevant chunks
        // example:
        // [
        //   { score: 0.94, text: "Aspirin side effects include...", ... },
        //   { score: 0.88, text: "Common aspirin reactions are...", ... },
        //   ...
        // ]

        return relevantChunks;
    }  


Using the Results in Your LLM Prompt

Once you have the chunks — here's how they flow into the LLM:


    async function answerQuestion(userQuestion, userId, category) {

        // Get relevant chunks from vector DB
        const chunks = await searchDocuments(userQuestion, userId, category);

        if (chunks.length === 0) {
            return "I couldn't find relevant information to answer your question.";
            // Handle the case where nothing relevant was found
        }

        // Build context string from chunks
        const context = chunks
            .map((chunk, index) =>
                `[Source ${index + 1}: ${chunk.metadata.source}, Page ${chunk.metadata.page}]
        ${chunk.text}`
            )
            .join("\n\n");

        // context now looks like:
        // "[Source 1: medical_handbook.pdf, Page 23]
        //  Common side effects of aspirin include nausea...
        //
        //  [Source 2: research_paper.pdf, Page 7]
        //  Aspirin has been associated with gastrointestinal bleeding..."

        // Build the LLM prompt
        const prompt = `Answer the user's question using ONLY the context provided below.
        If the answer is not in the context, say "I don't have that information."
        Always mention which source your answer comes from.

        CONTEXT:
        ${context}

        QUESTION: ${userQuestion}

        ANSWER:`;

        // Send to LLM
        const response = await openai.chat.completions.create({
            model: "gpt-4o",
            messages: [{ role: "user", content: prompt }],
            temperature: 0.1,
            // low temperature = more factual, less creative
            // good for RAG where accuracy matters
        });

        return response.choices[0].message.content;
    }

This is the complete RAG search-to-answer flow. Everything we've learned in Phase 3 and Phase 4 comes together here.


The Complete Mental Model — Search to Answer

User question
      ↓
Embed question → vector [1536 numbers]
      ↓
Query Vector DB:
  → topK: 5-10
  → scoreThreshold: 0.65+
  → filter: {userId, category, ...}
      ↓
ANN search (HNSW) runs in ~2ms
      ↓
Get back: text chunks + scores + metadata
      ↓
(Optional) Rerank for better ordering
      ↓
Take top 3-5 chunks
      ↓
Build prompt: system + context + question
      ↓
Send to LLM (temperature: 0.1)
      ↓
LLM generates answer grounded in real context
      ↓
User gets accurate, sourced answer

3-Line Summary

  1. Top-K controls how many similar chunks you retrieve — K=3 to 5 is the sweet spot for most RAG systems — too small misses context, too large adds noise and cost — score thresholds ensure only genuinely relevant chunks are returned.
  2. Metadata filtering lets you combine semantic similarity with structured conditions — like userId for data isolation, date for freshness, or category for relevance — this runs before the vector search to narrow the search space first.
  3. For production quality, reranking adds a second pass — get top 20 by ANN search, then use a more precise model to reorder them, then take the top 5 — this gives much better results than ANN similarity alone.

Module 4.3 — Complete ✅

Coming up — Module 4.4 — Hands-on: Working With Chroma (Local Vector Database)

Theory is done. Now we write real code. We'll set up Chroma locally, store real embeddings, run real similarity searches, and use metadata filters — all in JavaScript. By the end you'll have a working local vector database with documents stored and searchable.


No comments:

Post a Comment

Module 5.1 — What is RAG & Why it Exists

The most valuable skill in AI engineering right now Start With a Real Problem Imagine you're a lawyer at a big firm. Your firm has 10 ye...