Hybrid Search
On this page
Overview
HybridSearchRAG fuses semantic search (delegated to the wrapped
rag_system) with keyword search (BM25 or TF-IDF, computed locally)
using one of several fusion strategies, and can compare all methods
side-by-side.
SearchConfig
@dataclass
class SearchConfig:
semantic_weight: float = 0.7
keyword_weight: float = 0.3
min_score: float = 0.3
top_k: int = 10
enable_reranking: bool = True
fusion_method: str = "weighted_sum"
StanzaTokenizer
class StanzaTokenizer:
def __init__(self, language: str = "ar", use_lemmatization: bool = True): ...
def tokenize(self, text: str) -> List[str]: ...
def tokenize_batch(self, texts: List[str]) -> List[List[str]]: ...
Wraps the Stanza NLP library for tokenization/lemmatization, with a
_simple_tokenize fallback (used, per the code structure, when Stanza isn't
available or fails) — this makes both BM25Scorer and TFIDFScorer
functional even without the Stanza dependency installed, at reduced
linguistic accuracy.
BM25Scorer
class BM25Scorer:
def __init__(self, k1: float = 1.5, b: float = 0.75, language: str = "ar", use_lemmatization: bool = True): ...
def fit(self, documents: List[str]): ...
def score(self, query: str, document: str, doc_idx: int) -> float: ...
def batch_score(self, query: str, documents: List[str]) -> List[float]: ...
Standard Okapi BM25: k1 controls term-frequency saturation (typical
1.2–2.0), b controls length normalization (typical ~0.75). Per its
docstring, BM25 is preferred here over TF-IDF for short/medium texts.
fit() must be called (indexing document frequencies/lengths) before
score/batch_score.
TFIDFScorer
class TFIDFScorer:
def __init__(self, language: str = "ar", use_lemmatization: bool = True): ...
def fit(self, documents: List[str]): ...
def score(self, query: str, document: str) -> float: ...
def batch_score(self, query: str, documents: List[str]) -> List[float]: ...
Simpler classic TF-IDF cosine-style scorer; use when BM25's extra tuning parameters aren't needed.
HybridSearchRAG
Constructor
def __init__(
self,
rag_system,
config: Optional[SearchConfig] = None,
keyword_method: str = "bm25",
language: str = "ar",
use_lemmatization: bool = True,
)
keyword_method is "bm25" or "tfidf".
train_keyword_index()
Must be called before keyword_search/hybrid_search — fits the chosen
scorer (BM25Scorer.fit or TFIDFScorer.fit) over the backend's document
set.
Search methods
Fusion strategies (_fuse_results dispatches to)
fusion_method in SearchConfig selects which of these hybrid_search
uses internally.
generate(query, search_method="hybrid", include_sources=False) -> str
Runs the selected search method, formats sources via _format_sources if
requested, and generates an answer.
get_stats() -> dict
System statistics (e.g. whether the keyword index has been trained, corpus size).
Async API
async def asemantic_search(self, query, top_k=None)
async def akeyword_search(self, query, top_k=None)
async def ahybrid_search(self, query, top_k=None, semantic_weight=None, keyword_weight=None)
async def agenerate(self, query, search_method="hybrid", include_sources=False) -> str
async def astream(self, query, search_method="hybrid")
ahybrid_search explicitly runs semantic and keyword searches in
parallel (per its docstring) rather than sequentially, unlike the sync
hybrid_search. Plus __aenter__/__aexit__.
Complete example
from zerda.rag.types.hybrid_search import HybridSearchRAG, SearchConfig
config = SearchConfig(semantic_weight=0.6, keyword_weight=0.4, fusion_method="rrf", top_k=8)
search = HybridSearchRAG(backend, config=config, keyword_method="bm25", language="en")
search.train_keyword_index()
results = search.hybrid_search("machine learning applications in healthcare")
for chunk, score in results:
print(score, chunk)
comparison = search.compare_methods("machine learning applications in healthcare")
print(comparison.keys()) # e.g. semantic / keyword / hybrid buckets
Best practices
- Re-run
train_keyword_index()whenever documents are added/removed from the backend — the keyword index is a separate, locally-fit structure that does not auto-sync with the vector DB. - Prefer Reciprocal Rank Fusion (
_reciprocal_rank_fusion) when semantic and keyword score distributions aren't directly comparable, since it sidesteps normalization entirely.
Common mistakes
- Calling
keyword_search/hybrid_searchbeforetrain_keyword_index()— the scorer has no fitted vocabulary yet. - Assuming
hybrid_searchparallelizes semantic and keyword search like its async counterpart does — the synchronous version does not (seeahybrid_search's docstring for the explicit contrast).
Related APIs
- Graph RAG — has its own
enable_hybrid_searchconfig flag for a related but separately-implemented mode.