Spaces:
Running
Running
Download src/rag_engine/bm25_search.py from Jayan12321/rag-engine: direct link, hf CLI and curl.
- Browser
- Download file 4.22 kB
-
https://huggingface.co/spaces/Jayan12321/rag-engine/resolve/main/src/rag_engine/bm25_search.py
- Command line
-
hf download hf://spaces/Jayan12321/rag-engine/src/rag_engine/bm25_search.py
-
curl -L -o bm25_search.py https://huggingface.co/spaces/Jayan12321/rag-engine/resolve/main/src/rag_engine/bm25_search.py
4.22 kB
| """Sparse keyword search using BM25 (Best Matching 25). | |
| Implemented cleanly in pure Python with optional rank_bm25 fallback: | |
| Zero external dependencies required! | |
| Why BM25? | |
| While vector embeddings capture semantic meaning (synonyms, conceptual similarity), | |
| BM25 captures exact lexical matches (rare keywords, acronyms, specific numbers). | |
| """ | |
| import math | |
| import re | |
| from collections import Counter | |
| TOKEN_RE = re.compile(r"[A-Za-z0-9_\-]+") | |
| def tokenize(text): | |
| """Clean and tokenize text into lowercase words/terms.""" | |
| if not text: | |
| return [] | |
| return [t.lower() for t in TOKEN_RE.findall(text)] | |
| class SimpleBM25: | |
| """Pure Python implementation of Okapi BM25. | |
| Formula: | |
| IDF(q) = ln((N - n(q) + 0.5) / (n(q) + 0.5) + 1) | |
| Score(D, Q) = sum( IDF(q) * (f(q, D) * (k1 + 1)) / (f(q, D) + k1 * (1 - b + b * (|D| / avgdl))) ) | |
| """ | |
| def __init__(self, corpus, k1=1.5, b=0.75): | |
| self.k1 = k1 | |
| self.b = b | |
| self.corpus_size = len(corpus) | |
| self.doc_lengths = [len(doc) for doc in corpus] | |
| self.avg_doc_len = sum(self.doc_lengths) / max(1, self.corpus_size) | |
| # Document frequencies for each term | |
| self.doc_freqs = Counter() | |
| self.doc_term_counts = [] | |
| for doc in corpus: | |
| counts = Counter(doc) | |
| self.doc_term_counts.append(counts) | |
| for term in counts.keys(): | |
| self.doc_freqs[term] += 1 | |
| def get_scores(self, query_tokens): | |
| """Calculate BM25 relevance score for each document in the corpus.""" | |
| scores = [0.0] * self.corpus_size | |
| for token in query_tokens: | |
| if token not in self.doc_freqs: | |
| continue | |
| # Inverse Document Frequency (IDF) | |
| df = self.doc_freqs[token] | |
| idf = math.log((self.corpus_size - df + 0.5) / (df + 0.5) + 1.0) | |
| # Term frequency & length normalization for each doc | |
| for doc_idx, term_counts in enumerate(self.doc_term_counts): | |
| tf = term_counts.get(token, 0) | |
| if tf == 0: | |
| continue | |
| doc_len = self.doc_lengths[doc_idx] | |
| numerator = tf * (self.k1 + 1.0) | |
| denominator = tf + self.k1 * (1.0 - self.b + self.b * (doc_len / max(1.0, self.avg_doc_len))) | |
| scores[doc_idx] += idf * (numerator / denominator) | |
| return scores | |
| class BM25SearchIndex: | |
| """Keyword search index over chunks.""" | |
| def __init__(self): | |
| self.chunks = [] | |
| self.corpus_tokens = [] | |
| self.bm25 = None | |
| def build(self, chunks): | |
| """Index a list of TextChunk objects.""" | |
| if not chunks: | |
| self.chunks = [] | |
| self.corpus_tokens = [] | |
| self.bm25 = None | |
| return | |
| self.chunks = chunks | |
| self.corpus_tokens = [] | |
| for chunk in chunks: | |
| # Include text and document title for matching | |
| combined_text = f"{chunk.title} {chunk.text}" | |
| self.corpus_tokens.append(tokenize(combined_text)) | |
| try: | |
| from rank_bm25 import BM25Okapi | |
| self.bm25 = BM25Okapi(self.corpus_tokens) | |
| except ImportError: | |
| # Use pure Python BM25 fallback | |
| self.bm25 = SimpleBM25(self.corpus_tokens) | |
| def search(self, query, top_k=5): | |
| """Find the top_k best matching chunks by BM25 keyword relevance. | |
| Returns: | |
| List of dicts: [{"chunk": TextChunk, "score": float, "source": "bm25"}] | |
| """ | |
| if not self.bm25 or not self.chunks: | |
| return [] | |
| query_tokens = tokenize(query) | |
| if not query_tokens: | |
| return [] | |
| scores = self.bm25.get_scores(query_tokens) | |
| scored = list(enumerate(scores)) | |
| scored.sort(key=lambda x: x[1], reverse=True) | |
| results = [] | |
| for idx, score in scored[:top_k]: | |
| if score <= 0: | |
| continue | |
| results.append({ | |
| "chunk": self.chunks[idx], | |
| "score": round(float(score), 4), | |
| "source": "bm25", | |
| }) | |
| return results | |
| def __len__(self): | |
| return len(self.chunks) | |