RAG Search Engine
A retrieval-augmented search engine supporting keyword, semantic, and hybrid search — sentence-transformer embeddings with cosine similarity, BM25 combined with semantic scores via weighted interpolation and reciprocal rank fusion, and Gemini-powered query expansion, rewriting, and spell-checking, exposed through a CLI.
Highlights
- $ Implemented keyword, semantic, and hybrid search approaches in a single retrieval-augmented generation (RAG) engine, each exposed as its own CLI command.
- $ BM25 keyword search with the standard k1=1.5, b=0.75 constants, built on an inverted index with Porter-stemmed, stopword-filtered tokens.
- $ Semantic search using all-MiniLM-L6-v2 sentence embeddings and cosine similarity, with both fixed-size (word-count) and sentence-aware chunking strategies for splitting documents before embedding.
- $ Hybrid search combining BM25 and semantic scores two ways: min-max normalized weighted interpolation (configurable alpha), and reciprocal rank fusion (RRF) with configurable k.
- $ Integrated Google Gemini for query enhancement (spelling correction, query rewriting, query expansion) and three reranking strategies: per-document scoring, batch ranking, and a cross-encoder model.
- $ Built a CLI for keyword, semantic, and hybrid search with configurable result limits.
Architecture
Trade-offs & decisions
Weighted interpolation vs. reciprocal rank fusion
Both fusion strategies are implemented and configurable. RRF is more robust when BM25 and cosine-similarity scores live on very different scales, while weighted interpolation gives more direct control when both signal distributions are well understood.
Fixed-size vs. sentence-aware chunking
Two chunking strategies exist: fixed-size chunking splits on a raw word count, while sentence-aware chunking splits on sentence boundaries and groups a configurable number of sentences per chunk with overlap. Hybrid search uses the sentence-aware version, keeping chunks semantically coherent at the cost of variable chunk length.
Three reranking strategies vs. one
Per-document scoring, batch ranking, and cross-encoder reranking are all implemented rather than picking one. Per-document scoring is simplest but issues one LLM call per result (with a fixed delay between calls); batch ranking issues a single call for the whole result set; the cross-encoder runs locally with no external API call at all, trading some ranking quality for speed and no rate limits.
Gemini query enhancement vs. raw query
Routing queries through Gemini for expansion, rewriting, and spell-checking adds an external API call and latency, but meaningfully improves recall on sloppy or under-specified queries before retrieval even runs.
Code excerpt
def rrf_score(rank, k=60):
return 1 / (k + rank)
def rrf_search(self, query, k=DEFAULT_K, limit=10):
keyword_results = self.idx.bm25_search(query, limit * 500)
semantic_results = self.semantic_search.search_chunks(query, limit * 500)
search_map = {}
for rank, result in enumerate(keyword_results, 1):
doc_id = result["id"]
if doc_id not in search_map:
search_map[doc_id] = {"rrf_score": 0.0, "keyword_rank": None, "semantic_rank": None}
if search_map[doc_id]["keyword_rank"] is None:
search_map[doc_id]["keyword_rank"] = rank
search_map[doc_id]["rrf_score"] += rrf_score(rank, k)
for rank, result in enumerate(semantic_results, 1):
doc_id = result["id"]
if doc_id not in search_map:
search_map[doc_id] = {"rrf_score": 0.0, "keyword_rank": None, "semantic_rank": None}
if search_map[doc_id]["semantic_rank"] is None:
search_map[doc_id]["semantic_rank"] = rank
search_map[doc_id]["rrf_score"] += rrf_score(rank, k)
sorted_items = sorted(search_map.items(), key=lambda x: x[1]["rrf_score"], reverse=True)
return sorted_items[:limit] Live demo
Keyword search below runs a JS port of the real BM25 implementation
(same k1/b constants, same IDF formula). Semantic search runs
all-MiniLM-L6-v2 client-side via,
computing real embeddings and real cosine similarity, not a lookup table.
Hybrid mode fuses them with the same weighted-interpolation and RRF formulas
from hybrid_search.py. Gemini-based query rewriting/reranking needs a
private API key and isn't part of this public demo.
This runs against a small original demo dataset (20 movies), not the
real project's dataset (which is gitignored and unavailable here).