Build a Text File Search Index and Query CLI
A tool that walks a directory, builds an inverted index of words to documents, persists the index to disk, and answers ranked keyword queries from the command line.
Project goal
Build a CLI that indexes the words of every text file under a directory into an inverted index, persists it, and answers queries with ranked document matches using term frequency.
Final features
- Tokenise text into lowercase words and build an inverted index.
- Persist the index to disk and reload it for later queries.
- Rank documents by term frequency and multi-term overlap.
- Report the best matching paths with a score per query.
- Skip binary files and common stop words during indexing.
Architecture
A tokenizer normalises text; an indexer maps terms to document postings with counts; a scorer ranks documents for a query; a persistence module serialises the index; and a CLI ties index and query subcommands together. Tokenisation and scoring are pure and heavily tested.
Prerequisites
- Node.js 20 LTS or later
- Familiarity with maps and basic information-retrieval ideas
- Comfort reading files in text mode
Dependencies
- node >=20 — File traversal, persistence, and the CLI entry point
- vitest ^2.1.0 — Tokenizer, indexer, and scorer tests
Directory structure
search-index/
├── src/
│ ├── tokenize.js
│ ├── indexer.js
│ ├── scorer.js
│ ├── persist.js
│ └── cli.js
├── test/
│ ├── indexer.test.js
│ └── scorer.test.js
└── package.json
Phases
1. Tokenizer
Split text into lowercase word tokens, dropping punctuation, pure numbers, and stop words.
Files: src/tokenize.js
const STOP = new Set(['the', 'a', 'an', 'and', 'or', 'of', 'to', 'in', 'is', 'it']);
export function tokenize(text) {
return text
.toLowerCase()
.split(/[^a-z0-9]+/)
.filter((w) => w.length > 1 && !STOP.has(w) && !/^\d+$/.test(w));
}Expected behavior: Punctuation and casing are normalised, stop words are removed, and short tokens and pure numbers are dropped.
2. Inverted index builder
Walk files, tokenise each, and record term to document postings with counts.
Files: src/indexer.js
import { readdir, readFile } from 'node:fs/promises';
import { join, relative } from 'node:path';
import { tokenize } from './tokenize.js';
export async function buildIndex(root) {
const index = new Map();
async function walk(dir) {
for (const entry of await readdir(dir, { withFileTypes: true })) {
const full = join(dir, entry.name);
if (entry.isDirectory()) await walk(full);
else if (/\.(txt|md|js|ts)$/.test(entry.name)) {
const text = await readFile(full, 'utf8');
const rel = relative(root, full).replace(/\\/g, '/');
const counts = new Map();
for (const w of tokenize(text)) counts.set(w, (counts.get(w) ?? 0) + 1);
for (const [w, n] of counts) {
if (!index.has(w)) index.set(w, new Map());
index.get(w).set(rel, n);
}
}
}
}
await walk(root);
return index;
}Expected behavior: A word appearing twice in one file records a count of two, and a word in two files has two postings with correct counts.
3. Query scorer
Score documents by summing term frequencies across query terms and sort by score then path.
Files: src/scorer.js
export function query(index, terms) {
const scores = new Map();
for (const term of terms) {
const postings = index.get(term);
if (!postings) continue;
for (const [doc, count] of postings) {
scores.set(doc, (scores.get(doc) ?? 0) + count);
}
}
return [...scores.entries()]
.map(([path, score]) => ({ path, score }))
.sort((a, b) => b.score - a.score || a.path.localeCompare(b.path));
}Expected behavior: A document matching two terms outranks one matching a single term, and a query with no matching terms returns an empty list.
4. Index persistence
Serialise the nested maps to JSON and reload them, reconstructing maps on read.
Files: src/persist.js
import { readFile, writeFile } from 'node:fs/promises';
export function serialise(index) {
const out = {};
for (const [term, postings] of index) out[term] = Object.fromEntries(postings);
return JSON.stringify(out);
}
export async function loadIndex(path) {
const raw = JSON.parse(await readFile(path, 'utf8'));
const index = new Map();
for (const [term, postings] of Object.entries(raw)) index.set(term, new Map(Object.entries(postings)));
return index;
}
export async function saveIndex(path, index) {
await writeFile(path, serialise(index));
}Expected behavior: Saving then loading an index yields an equal structure, and querying the loaded index returns the same ranking as the in-memory one.
5. CLI subcommands
Provide index and query subcommands, default the index path, and print ranked results.
Files: src/cli.js
import { buildIndex } from './indexer.js';
import { query } from './scorer.js';
import { loadIndex, saveIndex } from './persist.js';
import { tokenize } from './tokenize.js';
const [sub, target, ...rest] = process.argv.slice(2);
const indexPath = process.env.INDEX_FILE ?? './index.json';
if (sub === 'index') {
const index = await buildIndex(target ?? '.');
await saveIndex(indexPath, index);
console.log('indexed ' + index.size + ' terms');
} else if (sub === 'query') {
const index = await loadIndex(indexPath);
for (const hit of query(index, tokenize((target ?? '') + ' ' + rest.join(' ')))) {
console.log(hit.score + ' ' + hit.path);
}
} else {
console.error('usage: search-index <index|query> <dir|terms>');
process.exit(1);
}Expected behavior: Index prints a term count and writes the file, and query prints one score-prefixed path per hit in descending order.
6. Tests and hardening
Cover tokenising, indexing, scoring, and persistence round-trips, then fix any failure.
Files: test/scorer.test.js
import { query } from '../src/scorer.js';
import { indexDocument } from '../src/indexer.js';
function makeIndex() {
const index = new Map();
indexDocument(index, 'a.txt', 'apple banana banana');
indexDocument(index, 'b.txt', 'banana cherry');
return index;
}
test('two-term overlap outranks one-term match', () => {
const results = query(makeIndex(), ['apple', 'banana']);
expect(results[0].path).toBe('a.txt');
expect(results.map((r) => r.path)).toContain('b.txt');
});Expected behavior: The scorer suite passes, an empty query returns no results, and a persisted index answers identically to the in-memory build.
Testing
Run npx vitest. Tokenizer tests cover punctuation, casing, numbers, and stop words. Indexer tests assert posting counts for repeated words and across multiple documents. Scorer tests assert that a document containing two query terms outranks one containing a single term, and that an absent term yields no results.
Troubleshooting
The index file grows huge for a large corpus
Store posting counts as pairs and consider dropping terms that appear in a single very large document; a plain JSON index is fine for small corpora but should be gzipped or moved to a keyed store as it grows.
Searching is case-sensitive
Lowercase both the indexed text and the query terms in one tokenizer used by both paths; a shared tokenizer is what keeps indexing and querying consistent.
Reloaded index loses document counts
Reconstruct nested maps on load rather than leaving plain objects; the scorer calls map methods, so objects with the same shape are not interchangeable.
Deployment
Run the index step in CI to refresh a committed index, then serve queries from a static host or a small search endpoint. The persisted JSON index can be shipped with the site for client-side search.
Known limitations
- Ranking uses raw term frequency and ignores document length and rarity, so a long document can outrank a focused one.
- There is no stemming, so searching for a plural does not match the singular form.
Project overview
Search is a small problem with a clear right answer: build an index once, then answer queries by intersecting postings rather than rescanning every file. This project builds that index and explains the ranking.
The index maps each word to the documents containing it with a count, persists as JSON, and answers queries by scoring overlapping terms. Stop words are dropped so common words do not dominate the results.
The project is deliberately sized so that every stage ships something you can run: the first phase produces a working command or endpoint, and each later phase adds one capability without forcing a rewrite of what came before. That property — always runnable, never half-migrated — is what makes the tutorial usable as a weekend build rather than a sketch that only works once the final commit lands.
The finished tool carries no hidden services and no hosted dependencies. Everything it needs lives in the repository and a machine with the stated runtime, so a reader can clone, install, run the tests, and trust that the behaviour they see locally is the behaviour the tutorial promised. Where a real deployment needs an external system, that seam is called out explicitly rather than smuggled in through a default.
It is written for a working developer who wants the whole shape of a real tool in one readable pass rather than a tour of a framework. Each phase states the decision it makes and why, then shows the code that encodes it, so the reader can stop after any phase with something that runs and resume later without relearning the whole design. The prose is deliberately honest about trade-offs: where a simpler approach would break at scale, that ceiling is named instead of hidden behind a benchmark.
The indexer is a single pass that records, for each term, which documents contain it and how often:
export function indexDocument(index, path, text) {
const counts = new Map();
for (const word of tokenize(text)) counts.set(word, (counts.get(word) ?? 0) + 1);
for (const [word, count] of counts) {
if (!index.has(word)) index.set(word, new Map());
index.get(word).set(path, count);
}
return index;
}
Because the posting map stores a per-document count, ranking never needs the original text again, which is what makes the persisted index sufficient to answer queries.
Setup
Zero runtime dependencies keeps the retrieval logic the only interesting thing in the codebase.
Set up the workspace before writing any logic. The table below pins every dependency to a version so the behaviour you observe matches the tutorial; floating versions are the most common reason a reader’s output diverges from the text. Install exactly what the table lists, then confirm the toolchain reports the expected version before continuing.
Two configuration choices deserve attention up front. First, the project keeps its runtime entry and its test entry as separate scripts so that running tests never starts a server or writes to a data directory. Second, all tunables are read from one place — a small config module or environment variables — so that nothing depends on the working directory a command happens to be launched from.
| Dependency | Version | Why it is here |
|---|---|---|
| node | >=20 | File traversal, persistence, and the CLI entry point |
| vitest | ^2.1.0 | Tokenizer, indexer, and scorer tests |
Create the project and install:
mkdir search-index && cd search-index
npm init -y
npm install -D vitest@^2.1.0
Configure the index and query entry points plus the tests:
{
"name": "search-index",
"type": "module",
"scripts": {
"index": "node src/cli.js index",
"query": "node src/cli.js query",
"test": "vitest run"
}
}
Keep a fixtures directory of three or four small text files so indexer and scorer tests run against realistic content offline.
Phase 1: Tokenizer
Split text into lowercase word tokens, dropping punctuation, pure numbers, and stop words. Each phase produces something demonstrable, so the tutorial never asks the reader to hold an unfinished design in their head.
The first hard decision is where the boundary between parsing and doing sits. Input arrives as raw text — argv, environment variables, a file on disk — and this phase converts it exactly once into a typed object that the rest of the program trusts. Confining that conversion to a single module means every later layer receives clean, already-validated input and never re-checks a string, which removes a whole class of bugs where two modules quietly disagree about what a flag or a field means. The second decision is failure visibility: invalid input must produce an actionable message and a non-zero exit rather than a stack trace surfacing from three layers down. Getting these two boundaries right early is what lets the remaining phases stay small.
const STOP = new Set(['the', 'a', 'an', 'and', 'or', 'of', 'to', 'in', 'is', 'it']);
export function tokenize(text) {
return text
.toLowerCase()
.split(/[^a-z0-9]+/)
.filter((w) => w.length > 1 && !STOP.has(w) && !/^\d+$/.test(w));
}
Acceptance for this phase: Punctuation and casing are normalised, stop words are removed, and short tokens and pure numbers are dropped.
Phase 2: Inverted index builder
Walk files, tokenise each, and record term to document postings with counts. Each phase produces something demonstrable, so the tutorial never asks the reader to hold an unfinished design in their head.
This phase defines the data model that every other module speaks. The choice of shape matters more than it looks: flat records are easy to serialize and diff, nested structures model relationships but complicate equality checks, and identifiers must be stable across edits so that references never silently rebind. The code here stays pure — functions take data and return data, with no clocks, no disk, and no printing — because pure functions are the only kind that can be tested exhaustively without a fixture harness. When the model is right the surrounding code almost writes itself; when it is wrong every later phase pays interest.
import { readdir, readFile } from 'node:fs/promises';
import { join, relative } from 'node:path';
import { tokenize } from './tokenize.js';
export async function buildIndex(root) {
const index = new Map();
async function walk(dir) {
for (const entry of await readdir(dir, { withFileTypes: true })) {
const full = join(dir, entry.name);
if (entry.isDirectory()) await walk(full);
else if (/\.(txt|md|js|ts)$/.test(entry.name)) {
const text = await readFile(full, 'utf8');
const rel = relative(root, full).replace(/\\/g, '/');
const counts = new Map();
for (const w of tokenize(text)) counts.set(w, (counts.get(w) ?? 0) + 1);
for (const [w, n] of counts) {
if (!index.has(w)) index.set(w, new Map());
index.get(w).set(rel, n);
}
}
}
}
await walk(root);
return index;
}
Acceptance for this phase: A word appearing twice in one file records a count of two, and a word in two files has two postings with correct counts.
Phase 3: Query scorer
Score documents by summing term frequencies across query terms and sort by score then path. Each phase produces something demonstrable, so the tutorial never asks the reader to hold an unfinished design in their head.
The core operation is where the project delivers its value, and the temptation is to cram every option into one function. Resist it. The main path should read as a short, obvious sequence — load, transform, emit — with the branching behaviour factored into small decisions that can be tested on their own. This phase also fixes the error contract: which failures are fatal, which are recoverable, and how a partial result is reported. A tool that can explain what it did and why it stopped is far easier to trust in a pipeline than one that only signals success or silence.
export function query(index, terms) {
const scores = new Map();
for (const term of terms) {
const postings = index.get(term);
if (!postings) continue;
for (const [doc, count] of postings) {
scores.set(doc, (scores.get(doc) ?? 0) + count);
}
}
return [...scores.entries()]
.map(([path, score]) => ({ path, score }))
.sort((a, b) => b.score - a.score || a.path.localeCompare(b.path));
}
Acceptance for this phase: A document matching two terms outranks one matching a single term, and a query with no matching terms returns an empty list.
Phase 4: Index persistence
Serialise the nested maps to JSON and reload them, reconstructing maps on read. Each phase produces something demonstrable, so the tutorial never asks the reader to hold an unfinished design in their head.
Integration is where the project stops being a toy. Input may arrive in a format an external system produced, output may need to match a contract another system consumes, and the two rarely line up without adjustment. This phase owns the adapters — the small translation functions between the outside world’s shape and the internal model — and it owns the boundary decisions about what to do when the two disagree: coerce, reject, or warn and continue. Keeping adapters thin and explicit means a change to an external format touches one file, and the internal model stays stable as the ecosystem around it moves.
import { readFile, writeFile } from 'node:fs/promises';
export function serialise(index) {
const out = {};
for (const [term, postings] of index) out[term] = Object.fromEntries(postings);
return JSON.stringify(out);
}
export async function loadIndex(path) {
const raw = JSON.parse(await readFile(path, 'utf8'));
const index = new Map();
for (const [term, postings] of Object.entries(raw)) index.set(term, new Map(Object.entries(postings)));
return index;
}
export async function saveIndex(path, index) {
await writeFile(path, serialise(index));
}
Acceptance for this phase: Saving then loading an index yields an equal structure, and querying the loaded index returns the same ranking as the in-memory one.
Phase 5: CLI subcommands
Provide index and query subcommands, default the index path, and print ranked results. Each phase produces something demonstrable, so the tutorial never asks the reader to hold an unfinished design in their head.
The user-facing surface determines whether the project feels finished. A command-line tool owes its caller predictable stdout, meaningful stderr, and honest exit codes; a service owes a stable request and response schema plus consistent status handling. This phase wires the internal modules to that surface and nothing else — it contains no business logic of its own, only translation between the outside contract and the internal calls. That separation is what lets the same core serve a CLI today and an HTTP endpoint tomorrow without duplication, and it is why the tests for this layer assert process-level behaviour rather than poking at internals.
import { buildIndex } from './indexer.js';
import { query } from './scorer.js';
import { loadIndex, saveIndex } from './persist.js';
import { tokenize } from './tokenize.js';
const [sub, target, ...rest] = process.argv.slice(2);
const indexPath = process.env.INDEX_FILE ?? './index.json';
if (sub === 'index') {
const index = await buildIndex(target ?? '.');
await saveIndex(indexPath, index);
console.log('indexed ' + index.size + ' terms');
} else if (sub === 'query') {
const index = await loadIndex(indexPath);
for (const hit of query(index, tokenize((target ?? '') + ' ' + rest.join(' ')))) {
console.log(hit.score + ' ' + hit.path);
}
} else {
console.error('usage: search-index <index|query> <dir|terms>');
process.exit(1);
}
Acceptance for this phase: Index prints a term count and writes the file, and query prints one score-prefixed path per hit in descending order.
Phase 6: Tests and hardening
Cover tokenising, indexing, scoring, and persistence round-trips, then fix any failure. Each phase produces something demonstrable, so the tutorial never asks the reader to hold an unfinished design in their head.
The final phase turns a working program into a dependable one. Tests are the visible part, but the discipline behind them is deeper: every claim the earlier phases made is pinned by an executable check, and every failure mode the tutorial mentioned has a test that reproduces it before the fix. Edge cases — empty input, malformed records, concurrent invocations, interrupted writes — are enumerated and asserted rather than assumed away. The payoff is a suite that fails loudly and precisely when someone changes a decision, which is exactly the signal a maintainer wants the moment they touch the code.
import { query } from '../src/scorer.js';
import { indexDocument } from '../src/indexer.js';
function makeIndex() {
const index = new Map();
indexDocument(index, 'a.txt', 'apple banana banana');
indexDocument(index, 'b.txt', 'banana cherry');
return index;
}
test('two-term overlap outranks one-term match', () => {
const results = query(makeIndex(), ['apple', 'banana']);
expect(results[0].path).toBe('a.txt');
expect(results.map((r) => r.path)).toContain('b.txt');
});
Acceptance for this phase: The scorer suite passes, an empty query returns no results, and a persisted index answers identically to the in-memory build.
Design trade-offs
Minimal dependencies over framework convenience. A framework would hand over plumbing for free, but it would also own the interesting decisions this project exists to teach. Keeping the dependency surface small means the lifecycle is visible end to end, the install is instant, and the tool runs anywhere the stated runtime does. The cost is owning the edge cases yourself — which is precisely why each of them gets a test.
Explicit contracts over clever conventions. Where a convention would infer behaviour from a filename or a folder, the project asks for a named export or a declared field instead. It reads as slightly more verbose and is far easier to review: a reader sees the intent on the line that owns it rather than reconstructing it from directory layout. Debugging also gets cheaper, because the failure points at the declaration instead of the convention that silently produced it.
Deterministic behaviour over convenience defaults. Anything that could vary between machines — time, ordering, encoding, locale — is injected or pinned rather than read from the ambient environment. That choice costs a little ceremony and buys reproducible output, which is the property that makes the output diffable in review and the tests trustworthy across the team’s machines.
Testing strategy
Run npx vitest. Tokenizer tests cover punctuation, casing, numbers, and stop words. Indexer tests assert posting counts for repeated words and across multiple documents. Scorer tests assert that a document containing two query terms outranks one containing a single term, and that an absent term yields no results.
Tests are organised by layer so a failure points at the decision that broke. Pure unit tests cover the transformation logic without touching disk or network and run in milliseconds; integration tests exercise the real entry point against a temporary working directory and assert the observable contract; a small end-to-end tier proves the whole path produces the documented output. Keeping the fast tiers large and the slow tier thin is what lets the suite run on every save while still catching integration mistakes.
Every test asserts a specific promise from the phases above rather than merely covering lines. A passing suite therefore means the tool kept its word: filters compose, identifiers stay stable, failures exit non-zero with a readable message, and the output matches the format a downstream consumer expects. When someone changes a contract the matching test fails with an exact diff, turning a silent regression into a line in the pull request.
Running the suite in CI is the last line of defence, but the same command runs locally before every commit, which is what keeps the feedback loop short. A green run means the documented behaviour still holds; a red run names the exact promise that broke. Treat the suite as executable documentation and it stays current, because a stale assertion fails the moment the code and the text disagree.
The ranking test is the meaningful one: it pins the ordering that makes search feel useful rather than merely correct, and it fails loudly if the scoring formula drifts.
Extending the project
Stemming, phrase queries, and a persisted term-document frequency table for TF-IDF are each an addition to one module. The index format already carries counts, so richer ranking is a scorer change rather than an indexer change.
The structure leaves obvious seams for growth. Each capability that could be added — a new output format, an extra filter, an interactive mode — lands in one layer because the boundaries between parsing, logic, and presentation are already drawn. Extensions that would break the current design are equally visible: the moment a feature needs cross-process coordination or a database, the storage interface is where the change belongs, and its tests tell you immediately whether the surrounding code survives the swap.