GUIDE Search

Hybrid search query patterns

ManyVector Team 2026-07-12 12 min read

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 typePure vectorHybridPure 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.

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.

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}")
Related guides

Start building with ManyVector today.