Hybrid Search¶
Hybrid search combines vector (semantic) and BM25 (keyword) search for better retrieval quality. It captures both conceptual similarity and exact term matching.
Overview¶
Query: "golang error handling best practices"
│
├──────────────────┬──────────────────┐
▼ ▼ │
Vector Search BM25 Search │
(Semantic) (Keywords) │
│ │ │
▼ ▼ │
[doc3, doc1, [doc1, doc2, │
doc5, doc2] doc4, doc3] │
│ │ │
└────────┬─────────┘ │
▼ │
Score Fusion │
│ │
▼ │
[doc1, doc3, │
doc2, doc5] │
Quick Start¶
import (
"github.com/plexusone/omniretrieve/hybrid"
"github.com/plexusone/omniretrieve/vector"
"github.com/plexusone/omniretrieve/bm25"
)
// Create indices
vectorIndex := vector.NewIndex(vector.Config{Dimensions: 384})
bm25Index := bm25.NewIndex()
// Create hybrid searcher
searcher := hybrid.NewSearcher(hybrid.Config{
VectorIndex: vectorIndex,
BM25Index: bm25Index,
Alpha: 0.5, // Equal weight
})
// Add documents
searcher.Add(ctx, "doc1", "Error handling in Go", embedding1)
searcher.Add(ctx, "doc2", "Go best practices guide", embedding2)
// Search
results, err := searcher.Search(ctx, "error handling", queryEmb, 10)
Configuration¶
searcher := hybrid.NewSearcher(hybrid.Config{
VectorIndex: vectorIndex,
BM25Index: bm25Index,
Alpha: 0.5, // Vector weight (0-1)
Fusion: hybrid.FusionRRF, // Fusion strategy
RRFConstant: 60, // RRF k parameter
Normalize: true, // Normalize scores
})
Alpha Parameter¶
Controls the balance between vector and BM25:
| Alpha | Vector Weight | BM25 Weight | Best For |
|---|---|---|---|
| 0.0 | 0% | 100% | Keyword-only |
| 0.3 | 30% | 70% | Keyword-heavy |
| 0.5 | 50% | 50% | Balanced |
| 0.7 | 70% | 30% | Semantic-heavy |
| 1.0 | 100% | 0% | Semantic-only |
Tuning Alpha¶
// For technical documentation (exact terms matter)
searcher := hybrid.NewSearcher(hybrid.Config{
Alpha: 0.3, // More keyword weight
})
// For conversational queries
searcher := hybrid.NewSearcher(hybrid.Config{
Alpha: 0.7, // More semantic weight
})
Fusion Strategies¶
Reciprocal Rank Fusion (RRF)¶
Combines rankings rather than scores:
searcher := hybrid.NewSearcher(hybrid.Config{
Fusion: hybrid.FusionRRF,
RRFConstant: 60, // k parameter
})
Advantages:
- Robust to score distribution differences
- No normalization needed
- Works well with diverse retrieval methods
Weighted Sum¶
Combines normalized scores:
searcher := hybrid.NewSearcher(hybrid.Config{
Fusion: hybrid.FusionWeightedSum,
Alpha: 0.5,
Normalize: true, // Required for weighted sum
})
Advantages:
- Intuitive weighting
- Preserves score magnitudes
- Good when scores are comparable
Choosing a Strategy¶
| Scenario | Recommended |
|---|---|
| Different retrieval methods | RRF |
| Same embedding space | Weighted Sum |
| Unknown score distributions | RRF |
| Fine-grained control | Weighted Sum |
Operations¶
Adding Documents¶
// Add with content and embedding
searcher.Add(ctx, "doc1", "Document content", embedding)
// The content goes to BM25, embedding to vector index
Searching¶
// Basic search
results, err := searcher.Search(ctx, "query text", queryEmbedding, 10)
// Search with options
results, err := searcher.SearchWithOptions(ctx, "query", emb, hybrid.SearchOptions{
TopK: 10,
MinScore: 0.5,
VectorOnly: false,
BM25Only: false,
})
Removing Documents¶
Advanced Usage¶
Custom Embedder Integration¶
type HybridSystem struct {
searcher *hybrid.Searcher
embedder Embedder
}
func (h *HybridSystem) Add(ctx context.Context, id, content string) error {
// Generate embedding
embeddings, err := h.embedder.Embed(ctx, []string{content})
if err != nil {
return err
}
// Add to hybrid index
return h.searcher.Add(ctx, id, content, embeddings[0])
}
func (h *HybridSystem) Search(ctx context.Context, query string, k int) ([]hybrid.Result, error) {
// Generate query embedding
embeddings, err := h.embedder.Embed(ctx, []string{query})
if err != nil {
return nil, err
}
return h.searcher.Search(ctx, query, embeddings[0], k)
}
Dynamic Alpha¶
Adjust alpha based on query characteristics:
func dynamicAlpha(query string) float64 {
// Short queries -> more keyword
if len(strings.Fields(query)) <= 2 {
return 0.3
}
// Question queries -> more semantic
if strings.HasSuffix(query, "?") {
return 0.7
}
// Default balanced
return 0.5
}
results, _ := searcher.SearchWithOptions(ctx, query, emb, hybrid.SearchOptions{
Alpha: dynamicAlpha(query),
})
Two-Stage Retrieval¶
First retrieve, then rerank:
import "github.com/plexusone/omniretrieve/rerank"
// Stage 1: Hybrid retrieval
candidates, _ := searcher.Search(ctx, query, emb, 100)
// Stage 2: Neural reranking
reranker := rerank.NewCrossEncoder(config)
final, _ := reranker.Rerank(ctx, query, candidates[:20])
Performance¶
Latency¶
| Component | Typical Latency |
|---|---|
| BM25 search | 1-5 ms |
| Vector search | 10-50 ms |
| Score fusion | < 1 ms |
| Total | 15-60 ms |
Memory¶
Both indices are maintained, so memory usage is:
When to Use Hybrid¶
| Scenario | Pure Vector | Pure BM25 | Hybrid |
|---|---|---|---|
| Semantic similarity | ✓ | ||
| Exact term matching | ✓ | ✓ | |
| Typo tolerance | ✓ | ✓ | |
| Unknown query type | ✓ | ||
| Best overall recall | ✓ |
See Also¶
- Vector Search - Semantic similarity
- BM25 Search - Keyword matching
- Reranking - Result quality improvement
- Memory Manager - Collection-based storage