Simple Search Engine
Underneath every "search this corpus" feature is the same three-step pipeline: break text into terms, remember which documents each term appears in, and score documents against a query using those term overlaps. The design question is which of those three steps you're willing to swap out later - the answer should be all of them.
Requirements
Functional
index(document)tokenizes a document's text and records it so it becomes searchable.search(query)tokenizes the query and returns matching documents ranked by relevance.- Relevance ranking should account for how often a query term appears in a document, not just whether it appears at all.
Non-functional
- Looking up documents for a query term must not scan every indexed document - it should go straight to the documents that contain that term.
- Tokenization rules (lowercasing, stemming, stopword removal) and the scoring formula both change often as search quality improves; neither should require touching the indexing or query flow itself.
Design
InvertedIndex maps each term to a postings list of (documentId, termFrequency) pairs -
the one structure that makes "which documents mention this term, and how often" an O(1)
lookup instead of a document scan. Tokenizer and Scorer sit on either side of it as
interfaces: one turns raw text into terms, the other turns term overlaps into a ranking, and
SearchEngine just wires the three together.
- 1Indexing and querying both go through the same engine facade.
- 2The document's raw text is turned into terms using the one shared tokenizer.
- 3Each term is recorded against this document, with its frequency, in the inverted index.
- 4Later, a query arrives as plain text, same shape as a document.
- 5The query is tokenized identically - this is what makes the two tokenizer calls safe to be the same instance.
- 6Each query term goes straight to its postings list - no document is scanned that doesn't contain the term.
- 7Term frequencies collected from the index are handed to the scorer to rank matching documents.
Indexing and querying run through the exact same Tokenizer - if they used different
tokenization rules, a document containing "running" would never match a query for "running"
tokenized differently, silently.
Class diagram
Code
Design decisions
- The index stores postings as
(documentId, termFrequency), not just a set of document ids per term. A pure set answers "does this document contain the term" but throws away the count a scorer needs. Storing the frequency once, at index time, meansScorernever has to re-read the document's raw text to count anything. Tokenizeris shared by both indexing and querying, not reimplemented for each. The entire index is only useful if a query term and an indexed term were produced by the same rules. Passing oneTokenizerinstance into bothindexandsearchis what guarantees that invariant instead of hoping two call sites stay in sync.Scoreris a separate interface fromInvertedIndex, even though it's the index it reads from. Swapping term-frequency scoring for TF-IDF (which also needs to know how many total documents contain a term) is then a new class that reads the same index differently, not a rewrite of how the index stores data.- What's missing for a real system: term-frequency scoring rewards long documents that repeat common words - TF-IDF or BM25 correct for that by weighting down terms that appear in most documents - and this design only supports append-only indexing; updating or deleting a document means also removing its old postings, which a real inverted index tracks via document versioning rather than an in-place overwrite.