Yasser Ameur

02 / 06MiniGoogleJava 21 · no framework

How MiniGoogle raised retrieval quality to 0.75 NDCG@10 with hybrid ranking over BM25 alone

A search engine you run yourself, with sharding, gossip and Raft replication, Java 21

NDCG@10, hybrid ranking
0.75
Source: Benchmark.md, 2026-08-15
MiniGoogle results page for the query http caching semantics, listing RFC 9110, RFC 7231, the MDN Cache-Control page and the nginx docs with highlighted snippets
The results page in dark mode, from assets/hero-dark.png in the repo, produced by assets/capture.cjs

The problem

A search engine's real mechanics, an inverted index, BM25 scoring, link based authority, consistent hashing, gossip membership, log replication, are usually hidden behind a database or a managed service before an engineer gets to build them by hand. I wanted to build all of it myself: one process by default, several when I choose, without Lucene, Elasticsearch or Kafka doing the interesting part for me. I built MiniGoogle, a search engine you run yourself: crawl pages into it, query them over HTTP or the bundled web UI, and, when you need it, run several nodes that place documents by consistent hashing, track each other by gossip and replicate a key value store by Raft. Java 21, one jar, one Docker image.

The mechanism

A STANDALONE node does everything in one JVM. RestServer, built on com.sun.net.httpserver with no framework underneath, exposes POST /api/v1/crawl and POST /api/v1/search. A crawl request runs through com.minigoogle.crawler, its CrawlWorker and jsoup based parser, appends the parsed page to a JSONL file and rebuilds com.minigoogle.indexer's inverted index. A search request reaches com.minigoogle.query's lexer, parser and executor, retrieves candidate postings, and hands them to com.minigoogle.ranking, which scores by BM25, blends in PageRank and, with semantic search on by default, merges lexical and vector candidates before a learning to rank model reorders the top results.

Running as CLUSTER adds three subsystems beside that same index and API. com.minigoogle.cluster.ConsistentHashRing decides which nodes own a crawled document. com.minigoogle.cluster.GossipProtocol keeps membership current: a silent peer becomes SUSPECT, then DEAD, and leaves the ring. com.minigoogle.cluster.RaftConsensus replicates the cluster's key value store through leader election, AppendEntries and snapshots, and never reads the ring or membership once its own configuration is committed.

The STANDALONE query path (highlighted) from RestServer through the inverted index to BM25 plus PageRank ranking, and the CLUSTER layer beneath it: consistent hashing for placement, gossip for membership, Raft for the replicated key value store. Package names from src/main/java/com/minigoogle.
MetricBM25 onlyHybrid + LTR
NDCG@100.69290.7477
MAP0.27500.7877
Standalone search latencyBeforeAfter
p5036.26 ms4.98 ms to 9.59 ms
p9962.70 ms to 63.79 ms14.82 ms to 32.66 ms

Retrieval quality rows: seed 42, 320 document corpus, 16 judged queries. Latency rows: a separate 3200 document corpus, 500 queries after a 100 iteration warmup, on one otherwise idle developer machine. Both from Benchmark.md, measured 2026-08-15.

What it did

On the 320 document seeded corpus, turning on hybrid retrieval and the learning to rank reranker moved NDCG@10 from 0.6929 to 0.7477 and MAP from 0.2750 to 0.7877 against the same 16 judged queries. On the separate 3200 document corpus used for timing, standalone search latency dropped from a p50 of 36.26 ms to a p50 between 4.98 ms and 9.59 ms, and p99 from a range of 62.70 ms to 63.79 ms down to 14.82 ms to 32.66 ms, after a 100 iteration warmup.

The honest caveat, stated in Benchmark.md itself: this is a seeded 320 document corpus with 16 judged queries on one idle developer machine, not a public BEIR leaderboard number, and the latency rows are described in the same document as bimodal and range based rather than single deterministic values. The document's own README also carries a second, larger relevance run: pure BM25 on BEIR scifact, 5,183 documents and 300 judged queries, reaching NDCG@10 of 0.6746, a separate measurement on a different corpus that I am not blending into the headline number above.

What I learned

The gap between the quality numbers and the latency numbers taught me that a search engine has two nearly independent report cards, and improving one tells you nothing about the other. Hybrid retrieval and reranking are what moved NDCG@10 and MAP; neither touches how fast a single query returns. The latency drop came from a separate pass through the ranking stage, isolated benchmarks show its 5,000 candidate, top 20 case going from a p50 of 1205.35 ms to 10.42 ms, work that would have looked like nothing on a relevance benchmark and everything on a latency one.

Running the CLUSTER mode end to end also taught me where the three distributed pieces actually meet. Gossip and the hash ring both reason about which nodes exist and what they own, so it is tempting to let them share state. Keeping GossipProtocol responsible only for liveness and ConsistentHashRing responsible only for placement, with Raft never consulting either once its own log is committed, is what let me stop a node, watch it declared SUSPECT then DEAD, restart it, and see repair reconverge the ring without touching a single line of the replicated key value store.

Sources

  1. Benchmark.md, 2026-08-15
  2. README.md, 2026-09-04
  3. ARCHITECTURE.md, 2026-09-04
  4. src/main/java/com/minigoogle (crawler, indexer, query, ranking, cluster packages), 2026-09-04