Skip to content

The Exact Match Problem: Deep Dive

This section addresses your core question:

In a RAG system with similarity search, how do you make sure that when someone searches for an exact ID like "Order #1766", it doesn't return a similar one like "Order #1767"?

You've learned all the pieces. Now we put them together into a complete solution.

The Problem: Why It Happens

Semantic embeddings treat similar tokens as semantically similar:

"Order #1766" embedding: [0.1, 0.2, 0.3, ..., 0.9]
"Order #1767" embedding: [0.10, 0.20, 0.31, ..., 0.90]
                         (slightly different due to "1767" vs "1766")

Cosine similarity: 0.99 (almost identical!)

The embedding model learned: - "Order #" always appears before a number - Numbers in similar ranges are similar - Context around them is almost identical

So it treats them as nearly equivalent. This is correct behavior for embeddings. They're designed to capture meaning, and "Order #1766" and "Order #1767" are semantically very similar (both are orders, similar structure).

The Four-Layer Solution

You need to layer strategies:

Layer 1: Metadata Extraction & Filtering

Extract structured data from query before searching:

def extract_constraints(query):
    """Extract exact IDs and constraints from query."""

    # Look for order IDs
    order_id = re.search(r'Order #(\d+)', query)
    if order_id:
        return {"order_id": order_id.group(1)}

    # Look for customer IDs
    customer_id = re.search(r'Customer (\w+)', query)
    if customer_id:
        return {"customer_id": customer_id.group(1)}

    return {}

query = "What about Order #1766?"
constraints = extract_constraints(query)
# Result: {"order_id": "1766"}

# Use this to pre-filter before semantic search
results = vector_db.search(
    query_embedding,
    filter={"order_id": constraints["order_id"]},
    top_k=10
)

Layer 2: Hybrid Search (Dense + Sparse)

Use both semantic and keyword search:

Not only is "Order #1766" semantically similar to #1767, but BM25 (sparse) search treats them completely differently:

# Sparse search (BM25)
bm25_scores = bm25.get_scores(["Order", "#1766"])
# Order #1766: 8.5
# Order #1767: 0.2  ← Much lower!

# Combined hybrid score
hybrid_score = w_dense * dense_norm + w_sparse * sparse_norm
# Dense: both ~0.99, similar
# Sparse: #1766 >> #1767
# Hybrid: #1766 wins decisively

BM25's IDF term heavily penalties common terms and rewards rare ones: - "Order" is common (low IDF) - "#1766" is unique (very high IDF)

Layer 3: Metadata Filtering (Structural Guarantee)

Filter by exact order ID to eliminate false positives:

# Even if both #1766 and #1767 were retrieved,
# filtering removes #1767 completely

filter = {"order_id": "1766"}

results = vector_db.search(
    query_embedding,
    filter=filter,  # Hard constraint
    top_k=10
)

# Result: ONLY Order #1766 documents,
# even if #1767 was semantically close

Layer 4: Chunking Strategy (Preserve Token Identity)

Don't bury order IDs in large chunks where they become meaningless:

❌ BAD CHUNKING (Order ID gets lost):
"Your order containing {item1, item2, ...} for customer {name} 
with Order ID #1766 placed on {date} is confirmed. The status 
updates will be sent to {email}. Your..."
↑ Order ID in the middle of 200-word chunk

✅ GOOD CHUNKING (Order ID prominent):
Chunk 1: "ORDER: #1766"
Chunk 2: "Order #1766 is CONFIRMED"
Chunk 3: "Order #1766 shipping address: ..."
Chunk 4: "Order #1766 tracking: ..."
↑ Order ID in every chunk

For structured data, create separate chunks for each key property.

Topics Covered

The Complete Picture

User: "What about Order #1766?"
[Extract Constraints]
    order_id = "1766"
[Hybrid Search (Dense + Sparse)]
    ├─ Dense: [#1766: 0.98, #1767: 0.96, #1765: 0.95]
    ├─ Sparse: [#1766: 8.5, #1767: 0.2, #1765: 0.3]
    └─ Hybrid: [#1766: ✅, #1767: ✅, #1765: ✅]
[Metadata Filter]
    ├─ Filter: order_id = "1766"
    └─ Result: [#1766 only]
[Re-ranking (optional)]
    └─ Score Order #1766 more precisely
[Return]
    └─ "Your Order #1766 is confirmed"

Root Cause Summary

Aspect Root Cause Solution
Semantic similarity Embeddings capture meaning, not exact tokens Use BM25 for keywords
Both returned Dense search finds both Add metadata filter
Wrong rank Dense ranking favors #1767 Hybrid search re-ranks
Lost context ID buried in text chunk Better chunking

Solutions Overview

Combine semantic (embeddings) with keyword (BM25) search. BM25 heavily rewards exact matches.

✅ Add Metadata Filtering

Store order_id as structured metadata. Filter before or after semantic search.

✅ Pre-processing/Chunking

Make order IDs prominent in chunks. Create separate chunks for structured properties.

✅ Dedicated Lookup Layer

For highly structured data, consider bypassing embeddings entirely for exact ID lookups.

Simple Rule of Thumb

If the user is searching for something exact (IDs, names, codes) → use keyword/exact match

If they're searching by meaning (concepts, intent) → use semantic search

Best systems use BOTH together.

Next Steps

  1. Why Semantic Search Fails — Detailed technical explanation
  2. Hybrid Solution — Step-by-step implementation
  3. Chunking Strategies — Document preparation

The key insight: You can't solve this with semantic search alone. You need a layered approach combining semantic, keyword, filtering, and smart chunking.