Hybrid search query patterns
Hybrid search combines vector similarity (semantic meaning) with BM25 full-text ranking (keyword precision) using Reciprocal Rank Fusion (RRF). The right combination depends on your corpus and query type. This guide covers the most common hybrid search patterns.
When to use hybrid vs. pure vector
| Query type | Pure vector | Hybrid | Pure BM25 |
|---|---|---|---|
| Natural language question | ✓ | ✓ | ✗ |
| Exact product code / SKU | ✗ | ✓ | ✓ |
| Named entity (person, brand) | ✗ | ✓ | ✓ |
| Concept / topic search | ✓ | ✓ | ✗ |
| Multi-language corpus | ✓ | ✓ | ✗ |
| Short queries (1-2 words) | ✗ | ✓ | ✓ |
As a rule of thumb: start with hybrid search for all production RAG and semantic search use cases. Tune alpha based on your eval results.
The alpha parameter
Alpha controls the weight of the vector signal:
alpha = 1.0— pure vector search (BM25 ignored)alpha = 0.5— balanced (equal weight to both signals)alpha = 0.0— pure BM25 (vector ignored)
# Start with alpha=0.7 for most RAG workloads
results = ns.query(
vector=embedding,
query_text=query,
search_mode="hybrid",
alpha=0.7,
top_k=10,
)
Tuning alpha: Build an eval set, sweep alpha from 0.1 to 0.9 in 0.1 increments, and pick the value that maximizes recall@10 on your queries. For natural-language queries, alpha=0.6-0.8 typically wins. For queries with exact keyword requirements, alpha=0.3-0.5.
Pattern 1: RAG retrieval with hybrid search
For RAG, you want high recall at top-5. The user’s question is often paraphrased from the answer — hybrid handles both the exact keywords and the semantic meaning:
def rag_retrieve(question: str, top_k: int = 5) -> list[str]:
embedding = embed(question)
results = ns.query(
vector=embedding,
query_text=question,
search_mode="hybrid",
alpha=0.7,
top_k=top_k,
)
return [r.attributes["text"] for r in results]
Pattern 2: Scoped hybrid search with metadata filters
Apply metadata filters before the hybrid search runs. This constrains the candidate set to the relevant document subset:
# Only retrieve from documents in the "legal" category published after 2024
results = ns.query(
vector=embedding,
query_text=query,
search_mode="hybrid",
alpha=0.7,
top_k=10,
filters={
"$and": [
{"category": {"$eq": "legal"}},
{"published_year": {"$gte": 2024}},
]
},
)
Pre-filtering before scoring is more precise than post-filtering and typically faster for selective filters.
Pattern 3: Boosting exact-match signals
For corpora with important structured identifiers (product codes, case numbers, ticket IDs), lean more heavily on BM25:
# Customer support: ticket IDs and product names need keyword precision
def support_search(query: str, product_line: str) -> list:
return ns.query(
vector=embed(query),
query_text=query,
search_mode="hybrid",
alpha=0.4, # 40% vector, 60% BM25 — prioritize keyword precision
top_k=10,
filters={"product_line": {"$eq": product_line}},
)
Pattern 4: Multi-stage retrieval with re-ranking
For the highest quality, use ManyVector for first-stage retrieval, then re-rank with a cross-encoder:
from sentence_transformers import CrossEncoder
reranker = CrossEncoder("cross-encoder/ms-marco-MiniLM-L-6-v2")
def retrieve_and_rerank(query: str, top_k: int = 5, oversample: int = 4) -> list:
# Over-retrieve with hybrid search
candidates = ns.query(
vector=embed(query),
query_text=query,
search_mode="hybrid",
alpha=0.7,
top_k=top_k * oversample,
)
# Re-rank with cross-encoder (slower but much higher precision)
texts = [c.attributes["text"] for c in candidates]
scores = reranker.predict([(query, t) for t in texts])
ranked = sorted(zip(scores, candidates), key=lambda x: x[0], reverse=True)
return [c for _, c in ranked[:top_k]]
Cross-encoder re-ranking improves NDCG@5 by 5-15% in production RAG benchmarks but adds 50-200ms latency. Worth it for user-facing search; less important for background pipeline retrieval.
Pattern 5: Session-aware search
For multi-turn conversations, expand the query with session context:
def session_search(
current_query: str,
session_history: list[str],
top_k: int = 5,
) -> list:
# Expand query with recent session context
expanded_query = current_query
if session_history:
context = " ".join(session_history[-3:]) # last 3 turns
expanded_query = f"{context} {current_query}"
return ns.query(
vector=embed(expanded_query),
query_text=current_query, # BM25 on current query only
search_mode="hybrid",
alpha=0.8, # higher vector weight when using expanded query
top_k=top_k,
)
Debugging retrieval quality
ManyVector returns scores for both signals. Use them to debug which signal is contributing to which result:
results = ns.query(
vector=embedding,
query_text=query,
search_mode="hybrid",
top_k=10,
return_signal_scores=True, # returns vector_score and bm25_score separately
)
for r in results:
print(f"{r.id}: RRF={r.score:.4f} | vec={r.vector_score:.4f} | bm25={r.bm25_score:.4f}")
Results dominated by bm25_score are keyword matches. Results dominated by vector_score are semantic matches. If all results are keyword matches, increase alpha; if all are semantic, decrease it.
Building an eval loop
Always tune search parameters against a ground-truth eval set:
def eval_recall_at_k(alpha: float, k: int = 10) -> float:
scores = []
for item in eval_set:
results = ns.query(
vector=embed(item["query"]),
query_text=item["query"],
search_mode="hybrid",
alpha=alpha,
top_k=k,
)
retrieved = {r.id for r in results}
relevant = set(item["relevant_doc_ids"])
scores.append(len(retrieved & relevant) / len(relevant))
return sum(scores) / len(scores)
# Sweep alpha
for alpha in [0.3, 0.5, 0.7, 0.9]:
recall = eval_recall_at_k(alpha)
print(f"alpha={alpha}: recall@10={recall:.3f}") Migrating from Pinecone to ManyVector
Step-by-step migration guide: export vectors from Pinecone, import into ManyVector, verify recall parity, and cut over traffic without downtime.
Read →Namespace branching for A/B testing embedding models
How to use ManyVector's copy-on-write namespace forks to safely test new embedding models in production without disrupting live traffic.
Read →