Optimizing RAG Performance by Implementing Hybrid Search with Milvus and BGE-M3

GameDevSarah Intermediate 5/12/2026 505 views 14 likes 2 min read

Dense retrieval is great until you search for a specific product SKU or a technical term like "K8s-node-affinity" and the vector search returns a bunch of conceptually similar but practically useless documents. I hit this wall hard last month while building a technical documentation bot, and the fix was moving to a hybrid search architecture using Milvus and the BGE-M3 model.

The magic of BGE-M3 is that it's "all-in-one"—it handles dense embeddings, sparse vectors (for BM25-style keyword matching), and multi-vector reranking. Instead of managing two separate indexes (one for Elasticsearch and one for a vector DB), you can store both dense and sparse vectors in Milvus.

Here is the actual workflow I implemented to stop the "hallucinations caused by poor retrieval" cycle:

1. The Indexing Strategy
You can't just throw text at the DB. I used BGE-M3 to generate both the dense vector and the sparse vector for every chunk. In Milvus, you define a schema with two vector fields.

from pymilvus import CollectionSchema, FieldSchema, DataType

# Define schema with both dense and sparse vectors
fields = [
    FieldSchema(name="id", dtype=DataType.INT64, is_primary=True),
    FieldSchema(name="dense_vector", dtype=DataType.FLOAT_VECTOR, dim=1024), 
    FieldSchema(name="sparse_vector", dtype=DataType.SPARSE_FLOAT_VECTOR),
    FieldSchema(name="text", dtype=DataType.VARCHAR, max_length=65535)
]
schema = CollectionSchema(fields)

2. Implementing Weighted Hybrid Search
The real trick isn't just searching both; it's how you merge the results. Milvus supports a WeightedRanker or RRFRanker (Reciprocal Rank Fusion). I found that for technical docs, giving a slight edge to the sparse search (keyword match) prevents the AI from drifting into "general knowledge" when a specific term is mentioned.

from pymilvus import AnnSearchRequest, RRFRanker

# Search dense and sparse separately
dense_req = AnnSearchRequest(
    data=[query_dense_vector], 
    anns_field="dense_vector", 
    param={"metric_type": "L2", "params": {"nprobe": 10}}, 
    limit=50
)

sparse_req = AnnSearchRequest(
    data=[query_sparse_vector], 
    anns_field="sparse_vector", 
    param={"metric_type": "BIPARTITE", "params": {}}, 
    limit=50
)

# Merge using RRF to balance keyword accuracy and semantic meaning
results = collection.hybrid_search(
    reqs=[dense_req, sparse_req], 
    ranker=RRFRanker(), 
    limit=10
)

3. The "Gotchas" and Productivity Gains
The biggest performance jump didn't actually come from the search itself, but from the reranking step. Even with hybrid search, the top 10 results are often noisy. I added a Cross-Encoder reranker (again, using BGE-M3's reranking capability) to shave the top 10 down to the top 3.

Crucial config tips:

  • Chunking: Stop using fixed-size character splitting. I switched to recursive character splitting with a 10% overlap. Hybrid search is sensitive to where keywords land; if you split a technical term in half, your sparse vector is useless.
  • Memory: Sparse vectors take up significantly less space than dense ones, but don't ignore the index load time. Use IVF_FLAT for the dense part if you're on a tight budget, but HNSW is mandatory if you need sub-100ms latency.
Optimizing RAG Performance by Implementing Hybrid Search with Milvus and BGE-M3

Since switching to this setup, my retrieval precision (hit rate) jumped from about 62% to 88% on a test set of 200 complex queries. The "I can't find that specific error code" problem basically vanished because the sparse vector catches the exact string while the dense vector provides the context.

All Replies (0)

No replies yet — be the first!

Write a Reply

Markdown supported