buildersai / app /utils /bm25_search.py
Kushal
Initial deployment: FastAPI backend with Docker
f3997d4
Raw
History Blame Contribute Delete
6.48 kB
"""
BM25 keyword-based search for hybrid retrieval.
Provides exact keyword matching to complement semantic search.
"""
from typing import List, Dict
from rank_bm25 import BM25Okapi
import numpy as np
class BM25Search:
"""BM25 keyword-based search engine."""
def __init__(self):
"""Initialize BM25 search."""
self.corpus = []
self.tokenized_corpus = []
self.bm25 = None
self.chunks = []
print("[BM25] Initialized")
def index_documents(self, chunks: List[Dict]):
"""
Index documents for BM25 search.
Args:
chunks: List of chunk dictionaries with 'content' key
"""
self.chunks = chunks
self.corpus = [chunk['content'] for chunk in chunks]
# Simple tokenization (lowercase + split)
self.tokenized_corpus = [
doc.lower().split() for doc in self.corpus
]
# Build BM25 index
if self.tokenized_corpus:
self.bm25 = BM25Okapi(self.tokenized_corpus)
print(f"[BM25] Indexed {len(self.chunks)} documents")
def search(
self,
query: str,
top_k: int = 30
) -> List[Dict]:
"""
Search documents using BM25.
Args:
query: Search query
top_k: Number of results to return
Returns:
List of chunks sorted by BM25 score
"""
if not self.bm25:
return []
# Tokenize query
tokenized_query = query.lower().split()
# Get BM25 scores
scores = self.bm25.get_scores(tokenized_query)
# Get top_k indices
top_indices = np.argsort(scores)[::-1][:top_k]
# Return chunks with scores
results = []
for idx in top_indices:
if scores[idx] > 0: # Only return non-zero scores
chunk = self.chunks[idx].copy()
chunk['bm25_score'] = float(scores[idx])
results.append(chunk)
return results
def get_score(self, query: str, document: str) -> float:
"""
Get BM25 score for a single query-document pair.
Args:
query: Search query
document: Document text
Returns:
BM25 relevance score
"""
tokenized_query = query.lower().split()
tokenized_doc = document.lower().split()
# Create temporary BM25 for single document
temp_bm25 = BM25Okapi([tokenized_doc])
score = temp_bm25.get_scores(tokenized_query)[0]
return float(score)
class HybridSearch:
"""Combines semantic and keyword search."""
def __init__(
self,
semantic_weight: float = 0.7,
keyword_weight: float = 0.3
):
"""
Initialize hybrid search.
Args:
semantic_weight: Weight for semantic search (0-1)
keyword_weight: Weight for keyword search (0-1)
"""
self.semantic_weight = semantic_weight
self.keyword_weight = keyword_weight
self.bm25 = BM25Search()
print(f"[Hybrid Search] Initialized (semantic: {semantic_weight}, keyword: {keyword_weight})")
def combine_results(
self,
semantic_results: List[Dict],
keyword_results: List[Dict],
top_k: int = 10
) -> List[Dict]:
"""
Combine and rerank results from semantic and keyword search.
Args:
semantic_results: Results from semantic search (with 'distance' scores)
keyword_results: Results from BM25 search (with 'bm25_score')
top_k: Number of final results
Returns:
Combined and reranked results
"""
# Normalize scores to 0-1 range
def normalize_scores(results, score_key):
if not results:
return results
scores = [r.get(score_key, 0) for r in results]
min_score = min(scores)
max_score = max(scores)
if max_score == min_score:
return results
for r in results:
r[f'{score_key}_normalized'] = (
(r.get(score_key, 0) - min_score) / (max_score - min_score)
)
return results
# For semantic search, lower distance = higher relevance
# Need to invert: score = 1 - normalized_distance
for r in semantic_results:
if 'distance' in r:
r['semantic_score'] = r['distance'] # Will normalize below
semantic_results = normalize_scores(semantic_results, 'semantic_score')
keyword_results = normalize_scores(keyword_results, 'bm25_score')
# Invert semantic scores (lower distance = better)
for r in semantic_results:
if 'semantic_score_normalized' in r:
r['semantic_score_normalized'] = 1 - r['semantic_score_normalized']
# Merge results by chunk ID or content
merged = {}
for chunk in semantic_results:
chunk_id = chunk.get('metadata', {}).get('document_id', '') + '_' + str(chunk.get('metadata', {}).get('chunk_index', ''))
merged[chunk_id] = chunk.copy()
merged[chunk_id]['hybrid_score'] = (
self.semantic_weight * chunk.get('semantic_score_normalized', 0)
)
for chunk in keyword_results:
chunk_id = chunk.get('metadata', {}).get('document_id', '') + '_' + str(chunk.get('metadata', {}).get('chunk_index', ''))
if chunk_id in merged:
merged[chunk_id]['hybrid_score'] += (
self.keyword_weight * chunk.get('bm25_score_normalized', 0)
)
else:
merged[chunk_id] = chunk.copy()
merged[chunk_id]['hybrid_score'] = (
self.keyword_weight * chunk.get('bm25_score_normalized', 0)
)
# Sort by hybrid score
results = sorted(
merged.values(),
key=lambda x: x.get('hybrid_score', 0),
reverse=True
)
return results[:top_k]
# Global hybrid search instance
hybrid_search = HybridSearch()