Shardly/manual
v1.0.0TypeScript · 2,211 LOC000%
GitHub

Shardly · technical manual · edition 01

A document store with full-text search, built from nothing

Two maps
do all the
work.

Shardly stores JSON documents on disk and searches them by relevance. No database, no Lucene, no search library. Around 2,200 lines of TypeScript, and this page walks through all of it.

Where a document's bytes live. Why a crash cannot lose an acknowledged write. How a word becomes a posting list. What BM25 is really asking. And every place the design knowingly stops short.

2,211Lines of TypeScript
0Storage or search dependencies
125,719Acknowledged writes, 0 lost
172×Best indexed-over-naive ratio
SHARDLY · COMPONENT MAP · SINGLE PROCESS, SINGLE MACHINEINTAKEOne JSON body, or an array through /documents/bulk. The route validates, the engine writes.HTTP clientextractDocuments() dispatches on extension. PDFs go through unpdf; unknown extensions get a binary check before decoding.File uploadMetadata and README always. Deep mode walks the file tree behind a worker pool and a request budget.GitHub2,386 articles streamed from disk with readline, 500 at a time, so the file never lands in memory whole.Wikipedia corpusEDGEValidates input, clamps limits, truncates previews. No business logic, which is why the engine is testable without a server.Fastify routessrc/api/routes.tsVALIDATE INPUTCLAMP LIMITSTRUNCATE PREVIEWSNO BUSINESS LOGIC18 ROUTESCOORDINATOREnginesrc/engine.ts · 236 LOCwrite → storage + indexread → storage onlysearch → index onlyKNOWS NOTHING OF HTTPDURABILITYOwns the offset map, one write descriptor, and a read descriptor per segment. Many readers, one writer, no locking.StorageTwo records per write. The pending one bounds where damage can be; the committed one proves it is not there.WriteAheadLogOne JSON record per line. Old bytes are never touched, so a crash can only ever damage the tail.segment-NNNN.logRELEVANCELowercase, split on anything outside a-z0-9, drop 51 stopwords, apply six suffix rules. Query and document share it.tokenize()Four maps: postings, document frequency, document lengths, and the per-document term list that makes delete cheap.InvertedIndexStoreScores in one pass, selects with a bounded heap, then builds a term-by-term breakdown for the winners only.rankBM25()Ranked hitsSCORE + BREAKDOWNSEEK ONLYTHE WINNERSNO CLUSTER · NO REPLICATION · NO EMBEDDED DATABASE · NO SEARCH LIBRARY
FIG 00The whole system on one line. Intake normalizes anything into Document objects, the engine writes them twice (once durably, once searchably), and a query walks the index without touching the disk until it knows which documents to fetch.

How to read this

Top to bottom is the path a document takes. Sections 01 to 08 follow it onto the disk and prove it survives a crash. Sections 09 to 13 follow it into the index and back out as a ranked result. Sections 14 to 18 are the edges: how documents get in, what the API promises, what everything costs, and where the design stops.

Six sections carry a lab you can drive. They run the real tokenizer and the real BM25 in your browser, ported without changes, so nothing here shows a number the engine would not produce.

01PremiseTwo maps

Everything below follows from one claim

You can find the bytes of any document without reading any other document. You can find every document containing a word without reading any document at all.

Two maps do the work. OffsetIndex takes a document id and gives back a segment file, a byte offset, and a byte length. InvertedIndex takes a term and gives back the list of documents containing it, with how often each one uses it.

Both maps live in memory. Neither is the source of truth. The segment files on disk are, and both maps can be rebuilt from them. Everything else in Shardly is the machinery that keeps the maps honest when the power goes out.

A read is one fs.readSync call at a known offset. A search walks posting lists and does not open a segment file until it already knows which ten documents to return. If you understand those two sentences, the rest of this page is detail.

The two maps

A · offset
Map<string, { segment, byteOffset, length, deleted }>
B · inverted
Map<string, Array<{ docId, termFrequency }>>
Truth
Neither. The segments are.
On restart
Load a snapshot, or rebuild from segments.
RESIDENT IN MEMORY · REBUILDABLE FROM DISKAOffsetIndexMap<docId, { segment, byteOffset, length, deleted }>DOC IDSEGMENTOFFSETLENGTHa1f3…c07segment-00000184b8e2…9d1segment-0000185231c4a9…22fsegment-0000417196d0b7…8aesegment-00010308BInvertedIndexMap<term, [{ docId, termFrequency }]>storaga1f3 ×4c4a9 ×1indexa1f3 ×2b8e2 ×7d0b7 ×1crashb8e2 ×3SEARCH RESOLVES CANDIDATES HERE, THEN ASKS A FOR THEIR BYTESsegment-0000.log…64 MiBONE readSync, 231 BYTESNEITHER MAP IS THE SOURCE OF TRUTH. THE SEGMENTS ARE. BOTH MAPS REBUILD FROM THEM.
FIG 01.1Both maps in memory, and the one place they meet. Search resolves candidates from B, then asks A for the bytes of the winners only.
02StorageA record on disk

What a document actually is once it lands

One line of JSON in a file called segment-0000.log, and four numbers in memory that say where to find it.

SEGMENT-0000.LOG · NDJSON · ONE RECORD PER LINEBYTE RULER0160320480640RECORD 0 · 184 B{"id":"b8e2-…","doc":{"title":"Raft",…}}RECORD 2 · 196 BbyteOffset = 185length = 231THE \n IS NOT COUNTEDlength STOPS AT THE LAST BRACE, SO JSON.parse GETS EXACTLY ITS OBJECTTHE READfs.readSync(fd, buf, 0, 231, 185) → JSON.parse(buf) → docONE SYSCALL. NO SCAN. NO PARSE OF ANY NEIGHBOUR.COST IS THE SAME AT 100 DOCUMENTS AND AT 100 MILLION.
FIG 02.1The record, the byte range that describes it, and the newline that is deliberately outside that range.

The length stops at the closing brace

Records are separated by newlines, so the file is valid NDJSON and you can read it with tail. But length counts only the JSON itself. That way the buffer handed to JSON.parse holds exactly one object and nothing else, with no trimming step that could go wrong on the last record in a file.

Many readers, one writer

Storage keeps a read-only file descriptor per segment in this.readFds, so a hot read never pays for open. It keeps exactly one write descriptor, on the newest segment, in append mode. That asymmetry removes the need for any lock: writes only ever touch the end of one file, and reads only ever touch bytes that are already final.

An id carries no order, and that is a real cost

Every id comes from randomUUID(). Random ids spread evenly and never collide, which is what you want. They also mean two documents written a second apart sort next to each other by pure accident. There is no way to ask for "every document after this one" without a full pass, and liveDocIds() returns insertion order because that is what a JavaScript Map happens to preserve, not because anything guarantees it. A monotonic id would buy range scans and cursor pagination. Shardly does not need them, so it does not have them.

// src/storage/storage.ts

// The whole read path.
read(docId: string): Document | null {
  const entry = this.offsetIndex.get(docId);
  if (!entry || entry.deleted) return null;

  const fd  = this.readFd(entry.segment);
  const buf = Buffer.allocUnsafe(entry.length);
  fs.readSync(fd, buf, 0, entry.length, entry.byteOffset);

  return (JSON.parse(buf.toString("utf8"))
    as StoredDocument).doc;
}
1syscall per read
0neighbours parsed

Lab 02 / resolve an id to bytes

Pick a document

STEP 1 · OFFSET INDEX LOOKUP · O(1) HASH MAPoffsetIndex.get("b8e2…9d1") → { segment: "segment-0000", byteOffset: 185, length: 231, deleted: false }STEP 2 · SEEK INTO THE SEGMENT FILEbyte 185fs.readSync(fd, buf, 0, 231, 185)

Returned

{ "title": "Write-ahead logging", … }

Not one neighbouring record was opened, parsed, or even touched. The cost is identical whether the file holds five records or five million.

03StorageAppend only

Appending is the only write that is cheap and safe at once

An in-place update has to fit, or move. Both paths need the old state and the new state to be consistent if the power fails between them.

REJECTED · UPDATE IN PLACERECORD GREW FROM 184 TO 240 BYTESOLD RECORDNEW RECORD OVERRUNS56 BYTES OF THE NEXT RECORD ARE GONESO THE OPTIONS ARE:1 · Pad every record and cap growth2 · Move the record, leave a hole, rewrite the map3 · Rewrite the whole segmentEVERY OPTION NEEDS A CRASH-SAFE TWO-STEP.NONE OF THEM IS FREE.CHOSEN · APPENDOLD BYTES ARE NEVER TOUCHEDOLD RECORDNEWTHE MAP MOVES, THE BYTES DO NOToffsetIndex.set("b8e2…", { byteOffset: 1_204_886, … })WHAT IT BUYSA crash can only ever damage the tailOne writer, no locking, no in-place torn stateSequential writes, which every disk prefersWHAT IT COSTSDead bytes accumulate until compaction runs
FIG 03.1The rejected design on the left, the chosen one on the right. Appending moves the problem from correctness to housekeeping.

Why 64 MiB, and not any other number

SEGMENT_MAX_BYTES caps each file at 64 MiB. Nothing in the read path cares how large a segment is, because reads seek directly to an offset. The cap is not a performance knob.

It exists so compaction has units to work with, and so a corrupted file loses a bounded amount of data instead of everything. Pick 4 GiB and one bad sector takes the corpus. Pick 1 MiB and you drown in file descriptors.

The file is the authority on its own size

openCurrentSegment() sets currentSegmentSize from fs.statSync(p).size, never from the snapshot. This looks like a detail and is not.

Recovery can restore a write the snapshot never saw. If the next append trusted the snapshot, it would place a new record on top of a live one and silently destroy it. Asking the filesystem costs one syscall at startup and removes the entire class of bug.

The bill for appending

Space is never reclaimed until you compact. A store that deletes as much as it writes grows without bound.

That is a real cost, paid in disk, on a schedule you choose. The alternative was a cost paid in correctness, on a schedule the power company chooses.

04DurabilityThe write-ahead log

A single write to disk is not atomic

The power can fail with half a JSON object on disk, and nothing about those bytes says they are half. That is the entire problem the write-ahead log solves.

// Storage.append(), in order.

// 1 · Say where the record is about
//     to go, before any of it exists.
wal.logPending({ docId, segment,
                 byteOffset, length });
fsync(walFd);

// 2 · Put it there.
fs.writeSync(segFd, record + "\n");
fsync(segFd);

// 3 · Reflect it, then prove it.
offsetIndex.set(docId, entry);
wal.logCommitted({ docId, segment,
                   byteOffset, length });
fsync(walFd);

// Only now does write() return.

Two records per write looks wasteful. It is not.

Ask what a single record would tell you. A lone "I intend to write 231 bytes at offset 4096" leaves recovery unable to separate a finished write from a torn one. A lone "I wrote 231 bytes at offset 4096" is a lie if the process dies before the segment fsync returns, because the log entry is durable and the data is not.

You need the pair. The pending record bounds where the damage can be. The committed record proves the damage is not there. Neither one is enough alone.

What Shardly actually promises

write() returns to the caller only after step 3. An acknowledged write survives any crash, full stop. A write still in flight might survive and might not, and both outcomes are correct, because nobody was told it succeeded.

That is the whole contract. It is narrower than "nothing is ever lost", and it is the only kind of promise a single machine can actually keep.

Lab 01 / kill the process mid-write

Click a kill point

1wal.logPending() + fsync2writeSync(segment) + fsync3offsetIndex.set(...)4wal.logCommitted() + fsyncP0P1P2P3P4P5
Kill at
Halfway through the segment write
wal.log
pending @ segment-0000:4096, len 231
segment
torn: {"id":"b8e2-…","doc":{"tit
memory
unchanged
Client saw
Nothing. The connection died before any response.
On restart
The range exists, so the check reads it and calls JSON.parse. The parse throws on the truncated object, and the record is discarded.

Verdict

This is the case the whole protocol exists for. Without the pending record, nothing would know those bytes were suspect.

05DurabilityRecovery

Recovery, and the case it deliberately drops

Two passes over the log. The first trusts, the second interrogates.

Storage.replayWal() · TWO PASSES OVER wal.logPASS 1 · APPLY WITHOUT INSPECTIONstatus: committedProven durable. Trust it.offsetIndex.set(...)Or set deleted: true for tombstonesPASS 2 · THE CRASH WINDOWpending with no committed twinKeyed by segment:byteOffsetCheck 1: Does the segment file exist? A rotation that never landed.1 · Does the segment file exist?A rotation that never landedCheck 2: Is the file long enough? The write was cut short.2 · Is the file long enough?The write was cut shortCheck 3: Do those bytes parse as JSON? The write was cut mid-object.3 · Do those bytes parse as JSON?The write was cut mid-objectCheck 4: Does the embedded id match? The offset points at a stranger.4 · Does the embedded id match?The offset points at a strangerALL FOUR PASS → APPLYANY CHECK FAILSDISCARD THE RECORDTHE CALLER NEVER GOT AN ACKWHAT SLIPS THROUGHA FLIPPED BIT INSIDE A STRINGSTRUCTURE IS INTACT, MEANING IS NOTFIX: A CRC PER RECORD
FIG 05.1Committed records are applied without inspection. Pending records without a committed twin face four checks, and one failure discards the record.

Why a tombstone is committed from birth

Delete records are written as committed straight away, with no pending phase. A tombstone is a flag in memory, and the WAL record is the only durable part of it. There is no second file to keep in step, so there is nothing to be half-done.

Where the honesty is

The four checks confirm structure. They cannot confirm meaning. A record that is byte-complete, parses as JSON, and carries the right id can still have a flipped bit inside a string value, and every check will pass it. Detecting that needs a checksum per record, and Shardly does not have one.

Adding a CRC to the record format is the single most valuable change left in this file. I am saying so here rather than letting you discover it.

A torn tail should not condemn the file

readAll() swallows parse errors on individual lines. That is narrow and intentional. The last line of the log is the one most likely to be torn by the exact crash being recovered from, and one bad tail should not make the whole log unreadable.

Crash harness · npx tsx scripts/crash-test.ts 6

Spawn a writer, SIGKILL it after a random 150 to 550 ms, reopen the store, verify every id ever acknowledged.

125,719acknowledged writes
6hard kills
0missing
0corrupt

The bug that made the test lie

The writer acknowledges each id over a pipe. fs.writeSync on a non-blocking pipe throws EAGAIN once the parent's buffer fills.

A dropped acknowledgement does not fail the test. It quietly shrinks the set under test, which is worse, because it looks exactly like success. scripts/crash-writer.ts retries on EAGAIN instead of dropping the line. A harness you have not tried to break is a harness you are trusting on faith.

06ThroughputGroup commit

fsync costs the same for 200 bytes and 200 kilobytes

Which means the number of fsync calls, not the number of bytes, sets the ingest ceiling.

fsync COSTS THE SAME FOR 200 BYTES AND FOR 200 KILOBYTESONE AT A TIME · 1,000 DOCUMENTS3 fsync PER DOCUMENT3,000WAL PENDING · SEGMENT · WAL COMMITTED · REPEAT ×1000writeBatch() · SAME 1,000 DOCUMENTS3 fsync PER BATCH31 · ALL INTENTS 2 · ALL DATA 3 · ALL COMMITSThe invariant was never "one document at a time".It was "intent durable before data, data durable before commit". Batching keeps both.
FIG 06.1Writing 1,000 documents one at a time against writing the same 1,000 through writeBatch(). Each block is one fsync.

The fiddly part is the arithmetic, not the protocol

writeBatch() builds the whole NDJSON blob in memory first, assigning ids and computing offsets as it goes, rotating segments where the 64 MiB cap demands. Every offset is measured against this.currentSegmentSize plus Buffer.byteLength(chunk, "utf8"), which is the committed size plus the bytes buffered but not yet flushed.

It has to be Buffer.byteLength and not String.length. One multi-byte character puts every later offset in the batch off by the difference. That bug is invisible on ASCII test data and corrupts every read on real text, which is the worst combination a bug can have.

npx tsx scripts/seed.ts 20000 /tmp/shardly-demo

20,000documents
6.4 swall clock
3,100docs per second
1,000batch size

The ordering guarantee never changed. The invariant was never "one document at a time", it was "intent durable before data, data durable before commit".

07MaintenanceCompaction

Compaction is four steps, and only one of them commits

Deleting a document sets a flag. The bytes stay. Compaction is how the disk finds out.

Storage.compact() · ORDERING IS THE WHOLE DESIGNStep 1. Write live records into fresh segments. Numbered from currentSegmentIndex + 11 · Write live records into fresh segmentsNumbered from currentSegmentIndex + 1Step 2. fsync every new segment. Data durable, nothing references it yet2 · fsync every new segmentData durable, nothing references it yetStep 3. snapshot() writes new offsets, truncates the WAL. THE COMMIT POINT3 · snapshot() writes new offsets, truncates the WALTHE COMMIT POINTStep 4. Unlink the old segment files. Reclaim the dead bytes4 · Unlink the old segment filesReclaim the dead bytesCRASH BEFORE STEP 3Old snapshot + old segments, both intact.ONE ORPHANED PARTIAL FILE IS LEFT ON DISK.NOTHING POINTS AT IT. NOTHING IS LOST.CRASH AFTER STEP 3New snapshot valid, old files still present.WASTED DISK UNTIL THE NEXT COMPACTION.NOTHING IS LOST HERE EITHER.NO STARTUP SWEEP REMOVES THE ORPHAN, ON PURPOSE.A sweep that unlinks unreferenced segments is the exact code that deletes real data when a snapshot goes missing for an unrelated reason. Orphans cost disk. Sweeps cost documents.
FIG 07.1Old files stay complete and readable until the snapshot lands. A crash on either side of step 3 leaves a consistent store.

New segments are numbered past the current one

They start at currentSegmentIndex + 1, so a fresh file can never collide with one that live offsets point at. The old files are readable through the entire operation. Nothing is unlinked until the new snapshot is durable.

Leaving the orphan is a decision, not an oversight

A crash before step 3 leaves a partial new segment that nothing references. No startup sweep removes it, and that is a choice rather than an omission.

A sweep that unlinks unreferenced segments is exactly the code that deletes real data the day a snapshot goes missing for an unrelated reason. Leaving the orphan costs disk. The next compaction reclaims it anyway, because it reuses the same number and opens with "w". Even if a normal rotation reaches that number first, it opens with "a" and sizes itself from the file, so new records land after the dead bytes and every offset stays correct.

Wasted space, never corruption. I will take that trade every time.

Measured · 20,000 seeded documents, half deleted

9.75 MBreclaimed
141 msto rewrite

Trigger
Manual, through POST /compact. No background thread decides for you.
Blocking
Yes. All I/O in Shardly is synchronous, so a compaction stalls every concurrent request for its duration.
Commit point
snapshot(), step 3 of 4
08MaintenanceSnapshots

Counting documents cannot tell you a snapshot is current

Delete one document, add another, and the count matches while the contents do not. That case is not hypothetical, it is what a normal workload does all day.

IS THE SNAPSHOT I JUST LOADED STILL TRUE?THE CHECK THAT FAILED · COMPARE DOCUMENT COUNTSt03 documents indexedcount = 3t1delete b8e2…count = 2t2add f19c…count = 3SAME COUNT. COMPLETELY DIFFERENT CONTENT. THE STALE INDEX LOADS AND SEARCH LIES.THE CHECK THAT WORKS · A MONOTONIC VERSIONStorage.version++ on every mutationWritten into both snapshot filesindex.load(...) === storage.stateVersion() → keep, else rebuildt0 → version 3 · t1 → version 4 · t2 → version 5DELETE AND ADD BOTH BUMP IT, SO THEY CANNOT CANCEL OUT.ANYTHING BUT AN EXACT MATCH REBUILDS FROM THE SEGMENTS.
FIG 08.1The check that failed, above. The monotonic version counter that replaced it, below.

Renaming a file is atomic, and that is not the same as durable

writeJsonAtomic() writes to ${target}.tmp, calls fsync on it, renames it over the target, and then calls fsync on the parent directory. That last step is easy to skip and it matters here more than usual.

A rename is atomic with respect to readers, but the directory entry is not durable until the directory itself is flushed. snapshot() truncates the write-ahead log immediately afterward. Without the directory fsync, a power failure in that gap loses the snapshot and the log that would have rebuilt it. Two durable-looking writes, zero durable state.

Why the two snapshots are not written atomically together

The offset snapshot and the inverted-index snapshot are written in sequence. A crash between them leaves a fresh offset file next to a stale index file.

The version check catches that on the next start and rebuilds. Making the pair atomic would need a two-phase write across two files for no gain, because the inverted index is derived data. Rebuilding it is always safe and never wrong. Spending complexity to protect something you can always regenerate is how storage layers get hard to read.

The rule

SNAPSHOT_EVERY_N_WRITES is 500. Anything other than an exact version match rebuilds the index from the segments. Equal counts no longer pass for equal state.

09SearchTokenization

A tokenizer is a controlled loss of information

Four stages, and every one of them throws something away on purpose. The useful question is not whether it loses information. It is which information, and what that costs you.

tokenize() · FOUR STAGES, FOUR KINDS OF LOSStoLowerCase() — Case is gone. Acronyms and names flatten into common words.01toLowerCase()Zürich Cafézürich caféCase is gone. Acronyms and names flatten into common words.replace(/[^a-z0-9\s]+/g, ' ') — Every non-ASCII letter is a separator. 東京 becomes nothing at all.02replace(/[^a-z0-9\s]+/g, ' ')zürich caféz rich cafEvery non-ASCII letter is a separator. 東京 becomes nothing at all.stopword filter — 51 surface forms, checked before stemming.03stopword filterthe storage layerstorage layer51 surface forms, checked before stemming.stem() — Six suffix rules. Not Porter. It over-stems and under-stems.04stem()storage layerstorag layerSix suffix rules. Not Porter. It over-stems and under-stems.Query text and document text go through the identical function, so every collision hurts precision on both sides rather than silently breaking recall on one.
FIG 09.1Each stage with its input, its output, and the thing it can no longer distinguish afterwards.

The character class is ASCII-only

/[^a-z0-9\s]+/g runs after toLowerCase(), so every character outside a-z0-9 becomes a separator. tokenize("Zürich café") gives ["z", "rich", "caf"]. tokenize("東京 検索") gives an empty array.

Non-English text is not ranked poorly. It is unindexable. For the bundled English corpus this never shows up, which is exactly why it is worth writing down.

The stemmer over-stems and under-stems

Six suffix rules, not Porter. stem("cares") returns "car", colliding with the actual word car, so a query for cars matches documents about caring. That is over-stemming.

Meanwhile stem("running") gives "runn" and stem("runs") gives "run", so two forms of one verb never meet. That is under-stemming, and it is a plain defect rather than a trade-off. Porter handles it with a doubled-consonant rule.

Why keep it anyway

It is a dozen lines you can read in full and reason about, and swapping in Porter is a contained change to one function that already has tests around it.

The reason to write the failures down is simpler. A stemmer nobody has measured is a stemmer everyone trusts too much.

Symmetry saves it

Query text and document text go through the identical function. A collision costs precision on both sides evenly instead of breaking recall in one direction, which is the difference between a blunt tool and a broken one.

Lab 03 / the tokenizer, ported verbatim

Type anything

Input wordLowercasedAfter the ASCII splitRule that firedToken emitted
Zürichzürichz · richlen ≤ 3, untouched · no rule matchedz · rich
caféscaféscaf · slen ≤ 3, untouched · len ≤ 3, untouchedcaf · s
arearearestopword
runningrunningrunning-ing, len > 5runn
distributeddistributeddistributed-ed, len > 4distribut
queriesqueriesqueries-ies → yquery

Output · 7 tokens, duplicates kept because term frequency needs them

zrichcafsrunndistributquery

The complete stopword list · 51 surface forms, checked before stemming

theaanandorbutifofatbyforwithabouttofrominonisarewaswerebebeenbeingamititsthisthatthesethoseassothanthenthereherenotnododoesdidhavehashadiyouheshewethey

Because the check runs before stemming, have and has are dropped while having stems to hav and survives.

10SearchThe inverted index

Four maps, and the interesting one exists only for delete

index, docFreq, and docLengths are what BM25 needs. docTerms is what keeps deletion from scaling with the size of the corpus.

Removing a document means removing it from every posting list it appears in. The obvious way is to walk the whole index and filter each list. It is correct, it passes every unit test, and it costs O(vocabulary) per delete while allocating a replacement array for every term in the corpus, whether or not the document touched it.

docTerms maps a document id to the distinct terms it contributed. The loop becomes O(terms in this document). The gap widens as the vocabulary grows, which is the direction real corpora go.

docTerms is not in the snapshot format. load() rebuilds it by walking the postings once, which costs one pass over data already being parsed and keeps old snapshots readable. Deriving beats storing when the derivation is cheaper than the format change.

The memory cost is one array of string references per document. The strings are the same objects already used as keys in index, so this is pointers, not text.

The four maps

index
term → postings. The index itself.
docFreq
term → document count. IDF's n.
docLengths
docId → token count. BM25's |D|.
docTerms
docId → its distinct terms. Delete only.
totalTokens
A running sum, so averageDocumentLength() is O(1) instead of a scan.

addDocument() calls removeDocument() first when the id is already known. Without that, re-adding an id would leave two postings for one document and double its score.

REMOVE ONE DOCUMENT FROM EVERY POSTING LIST IT APPEARS INNAIVE · WALK THE WHOLE VOCABULARY… × 20,000 TERMSFIVE HITS. 19,995 ARRAYS ALLOCATED AND THROWN AWAY.WITH docTerms · WALK ONLY THIS DOCUMENT'S TERMSFIVE LOOKUPS. NOTHING ELSE IS TOUCHED.COST NOW TRACKS THE SIZE OF THE DOCUMENT, NOT THE SIZE OF THE CORPUS.MEASURED2,386 Wikipedia articles: 2,313 ms walking the whole vocabulary, 311 ms with docTerms. 7× faster.2,386 Wikipedia articles2,313 ms311 ms3,000 synthetic docs · 20,000-word vocabulary: 14,555 ms walking the whole vocabulary, 215 ms with docTerms. 68× faster.3,000 synthetic docs · 20,000-word vocabulary14,555 ms215 ms68×docTerms IS NOT IN THE SNAPSHOT FORMAT. load() REBUILDS IT IN ONE PASS OVER POSTINGS ALREADY BEING PARSED.
FIG 10.1Delete cost before and after docTerms, with the two corpora it was measured on.

Lab 04 / a five-document index, live

Real tokenizer, real BM25

The corpus · every document that exists

D1 · 19 tokens

Crash recovery. A write-ahead log records intent before data. On restart the log replays and verifies each pending record against the segment bytes.

D2 · 18 tokens

Segment storage. Segments are append-only files. Storage never rewrites a record in place, so a crash can only damage the tail of the newest segment.

D3 · 17 tokens

Ranking with BM25. BM25 scores a document by term frequency, inverse document frequency, and length. Rare terms weigh more than common terms.

D4 · 15 tokens

Index deletion cost. Deleting a document from an inverted index means visiting every posting list that document contributed a term to.

D5 · 15 tokens

Compaction. Compaction rewrites live records into fresh segments and drops the tombstoned ones. The offset snapshot is the commit point.

Posting lists · 62 terms, showing the 36 most common

record

D1×2 D2×1 D5×1

segment

D1×1 D2×3 D5×1

crash

D1×1 D2×1

document

D3×2 D4×2

rewrit

D2×1 D5×1

term

D3×3 D4×1

against

D1×1

ahead

D1×1

append

D2×1

before

D1×1

bm25

D3×2

byt

D1×1

can

D2×1

commit

D5×1

common

D3×1

compaction

D5×2

contribut

D4×1

cost

D4×1

damage

D2×1

data

D1×1

delet

D4×1

deletion

D4×1

drop

D5×1

each

D1×1

every

D4×1

fil

D2×1

frequency

D3×2

fresh

D5×1

index

D4×2

intent

D1×1

into

D5×1

inverse

D3×1

invert

D4×1

length

D3×1

list

D4×1

live

D5×1


What the engine did

Query terms
segment · crash · recovery
Lists unioned
3
Candidates
3 of 5
Docs read
3 · only the winners
Docs scanned
0

Ranked, with the score broken down term by term

01Crash recovery2.645

recovery tf=1 idf=1.39 → 1.309 crash tf=1 idf=0.88 → 0.827 segment tf=1 idf=0.54 → 0.509

02Segment storage1.731

segment tf=3 idf=0.54 → 0.883 crash tf=1 idf=0.88 → 0.848

03Compaction0.566

segment tf=1 idf=0.54 → 0.566

11RankingBM25

BM25 asks three questions and multiplies the answers

How surprising is this word? Do repeats keep helping? Is this document long, or is it thorough? Every part of the formula is one of those three.

score(D, Q) FOR ONE QUERY TERM · SUMMED OVER THE QUERYscore(D, Q) =Σq ∈ Qidf(q)×tf(q,D) · (k₁ + 1)tf(q,D) + k₁ · ( 1 − b + b ·|D| / avgdl)How surprising is this word? log((N − n + 0.5) / (n + 0.5) + 1) A term in every document contributes almost nothing. A term in three documents dominates the score. The + 1 keeps IDF positive. Without it, a word in more than half the corpus would cost a document points.AHow surprising is this word?log((N − n + 0.5) / (n + 0.5) + 1)A term in every document contributes almost nothing.A term in three documents dominates the score.The + 1 keeps IDF positive. Without it, a word in morethan half the corpus would cost a document points.Do repeats keep helping? k₁ = 1.5 makes term frequency saturate. The tenth occurrence adds far less than the second. Raw frequency would let a page that repeats one word 400 times beat a page genuinely about the topic. That failure is what BM25 was designed against.BDo repeats keep helping?k₁ = 1.5 makes term frequency saturate.The tenth occurrence adds far less than the second.Raw frequency would let a page that repeats one word400 times beat a page genuinely about the topic.That failure is what BM25 was designed against.Is it long, or is it thorough? b = 0.75 applies three quarters of the correction. A long document has more chances to contain any term, so its frequencies are discounted by |D| / avgdl. At b = 0, long documents win everything. At b = 1, depth gets punished as padding.CIs it long, or is it thorough?b = 0.75 applies three quarters of the correction.A long document has more chances to contain any term,so its frequencies are discounted by |D| / avgdl.At b = 0, long documents win everything.At b = 1, depth gets punished as padding.Every hit carries a breakdown: each term's frequency, its IDF, and its contribution, sorted by contribution.A test asserts the contributions sum to the total. That is what lets the UI answer "why did this rank here".
FIG 11.1The formula with each region traced to the question it answers. k1 = 1.5 and b = 0.75 are the standard defaults, set in src/config.ts.

Lab 05 / one term, one document

N fixed at 20,000 documents


Contribution to the score

8.838

idf 4.708 × tf-factor 1.877

A · Repeats saturate

k1 = 0.4k1 = 1.5k1 = 4.0tf →factor

Raw frequency would be a straight line climbing forever. k1 bends it flat. Set k1 to 0 and every repeat past the first counts for nothing.

B · Length is discounted

b = 0b = 0.75b = 1|D| →factor

At b = 0 the line is flat and a 10,000-word page wins on volume. At b = 1 depth is punished as padding. 0.75 sits between them.

C · Rarity is everything · why the + 1 inside the logarithm is not decoration

without the + 1 · goes negative past half the corpusShardly · log((N − n + 0.5) / (n + 0.5) + 1)n, documents containing the term →idf

Drag n past 10,000 and watch the dashed line cross zero. Under that version, a document would lose points for containing a common query word. The + 1 keeps every contribution positive.

The test that pins the middle setting

A ranker that punishes length is as broken as one that rewards it. tests/rank.test.ts covers both ends: a short document beats an over-long one at equal term frequency, and a genuinely more relevant long document still wins when it earns it. The second test is the one that catches an over-eager b.

Why every hit carries a breakdown

Each result includes a TermScore[] with the term, its frequency in that document, its IDF, and its contribution, sorted by contribution. A test asserts the contributions sum to the total.

That is what lets an interface answer "why did this rank here" instead of showing a number nobody can check. A relevance score you cannot decompose is a relevance score you cannot debug.

Constants

k1
1.5
b
0.75
Overridable
Yes, through RankOptions, so tests can pin them.
12RankingTop-N selection

Score in one pass, explain in another

Building a result object for every candidate would allocate for every document that matched any term. The answer is ten of them.

rankBM25() · 20,000 CANDIDATES, LIMIT 10PASS 1 · NUMBERS ONLYMap<docId, number>NO OBJECTS. NO BREAKDOWN ARRAYS.ONE FLOAT PER MATCHED DOCUMENT.SELECT · BOUNDED HEAPMinHeap(capacity = 10)THE WEAKEST OF THE TEN SITS AT THE ROOT.ONE COMPARISON DECIDES EACH CANDIDATE.PASS 2 · EXPLAIN THE WINNERSMap<docId, Map<term, tf>>ONE WALK PER TERM, KEYED BY WINNER IDS.NOT ONE WALK PER WINNER.WHY A HEAP AND NOT A SORT4.16.85.29.47.15.98.3MINIMUM AT THE ROOTNew candidate scores 3.9. One comparison against 4.1 rejects it.New candidate scores 7.7. It replaces the root, then sifts down. log₂(10) steps.20,000 comparisons and 10 slots, against 20,000 · log 20,000 for a full sort.WHEN limit IS Infinity OR EXCEEDS THE CANDIDATE COUNT, IT SORTS INSTEAD.A HEAP CANNOT BEAT A SORT WHEN YOU WANT THE WHOLE LIST.
FIG 12.1Numbers only, then a bounded heap, then breakdowns for the winners. Each stage exists to stop the next one from doing work it does not need.

The version that was replaced

The first implementation looked up each winner's term frequency by searching that term's posting list, once per winner. On a term appearing in 20,000 documents with a limit of 10, it re-scanned 20,000 postings ten times to recover ten numbers.

It now walks each term's postings once, filling a Map<docId, Map<term, tf>> keyed by a Set of winner ids. The work drops to O(terms × postings) no matter what the limit is.

total counts matches, not results

total reports every document matching at least one query term, which is scores.size, not the size of the returned page. Reporting the page size would make every search look like it found exactly ten things, and pagination would have nothing to paginate.

When the heap steps aside

If limit is Infinity or larger than the candidate count, selectTopN() sorts everything instead. A heap cannot beat a sort when you want the whole list, and pretending otherwise is how you end up with a slower "optimized" path.

13MeasurementThe benchmark

A search engine that cannot show its own speedup is a claim, not a result

So Shardly ships the slow path too, runs both on the same corpus with the same constants, and checks that they agree on the winner.

Lab 06 / the same query, both ways

npm run bench -- /tmp/shardly-demo · warm-up discarded, five runs averaged

Indexed · walk posting lists, score candidates, seek 10 documents2.20 ms
Unindexed · read and re-tokenize every document on disk, then score the corpus372.30 ms
172×Ratio
agreesTop hit, both paths

Read the second bar for what it is

naiveSearch() re-reads and re-tokenizes every document from disk on every call. The comparison is no index at all against index already built, not linear scan against index lookup. Building the index is paid once at ingest and appears in neither column. A pre-tokenized linear scan would land somewhere between the two.

What the comparison is worth

Both sides compute BM25 over the same documents with the same k1 and b, and topHitsMatch verifies the fast path returns the same answer as the slow one. A speedup with a different answer is a bug report, not a benchmark.

Where the number is soft

speedup divides by Math.max(indexed.tookMs, 1e-6). On a small corpus where the indexed path finishes in microseconds, that ratio is measuring timer resolution as much as anything. Treat the ordering as real and the exact multiplier as approximate.

These are local measurements on one machine, not a performance guarantee. Run npm run seed and npm run bench on your own hardware for numbers that mean something to you.

What each column contains

Indexed
Tokenize the query, union the posting lists, score the candidates, seek the top ten. The index is already built.
Unindexed
Read every document from disk, tokenize it, compute document frequencies, score the corpus. Every single call.
Not measured
Index build time. It is paid once at ingest and appears in neither column.
Missing third column
A linear scan over pre-tokenized data. It would land between the two, and it is the honest comparison nobody publishes.
14IntakeIngestion

Three sources, one function signature

Every adapter returns plain Document objects and hands them to Engine.addDocuments(). None of them knows segments, the log, or the index exist.

ADDING A FOURTH SOURCE TOUCHES ONE FILEFiles and PDFs (src/ingest/extract.ts): Dispatch on extension; PDF text via unpdf; JSON array → one doc per element; NDJSON → skip malformed lines; Binary check before decoding.Files and PDFssrc/ingest/extract.tsDispatch on extensionPDF text via unpdfJSON array → one doc per elementNDJSON → skip malformed linesBinary check before decodingGitHub (src/ingest/github.ts): user · owner/repo · full URL; Metadata and README always; Deep mode walks the file tree; mapLimit() worker pool, 18 lines; Token optional, never stored.GitHubsrc/ingest/github.tsuser · owner/repo · full URLMetadata and README alwaysDeep mode walks the file treemapLimit() worker pool, 18 linesToken optional, never storedWikipedia (src/ingest/corpus.ts): readline over the NDJSON file; Batches of 500, never loads whole; Live fetcher builds the file; Honors Retry-After on 429 and 503; Backoff with jitter, 25-failure limit.Wikipediasrc/ingest/corpus.tsreadline over the NDJSON fileBatches of 500, never loads wholeLive fetcher builds the fileHonors Retry-After on 429 and 503Backoff with jitter, 25-failure limitEngine.addDocuments(docs: Document[]): string[]NO ADAPTER KNOWS ABOUT SEGMENTS, THE WAL, OR THE INDEX
FIG 14.1The contract is the whole integration story. Adding a fourth source touches one file and changes nothing downstream.

Detecting binary before decoding it

Files with an unrecognized extension get checked first. isProbablyBinary() reads the first 8 KiB and returns true on any NUL byte. Otherwise it flags files where more than 30% of bytes are C0 control characters outside tab, newline, and carriage return.

A NUL byte is conclusive. The 30% threshold is a heuristic, tuned to let odd-but-real text through while rejecting compiled output. Extensions on the TEXT_EXTENSIONS list skip the check entirely, because a .ts file is text even if it opens with something strange.

A PDF with no text is reported, not indexed

PDFs go through unpdf. If extraction yields nothing, the file is returned as skipped with a reason rather than stored as an empty document.

A scanned page that silently produces zero tokens is precisely the thing you want to see in the response. Indexing it as an empty document hides the problem and pollutes avgdl at the same time.

Malformed NDJSON lines are skipped, not fatal

One bad line in a 200,000-line dump should not reject the dump. Same reasoning as the torn WAL tail.

Politeness is a feature of the GitHub adapter

Concurrency runs through mapLimit(), an eighteen-line worker pool. Promise.all over every file would open hundreds of sockets and trip the rate limiter on the first repository. Caps on repositories, files per repository, total files, and file size are parameters with defaults, so the HTTP route holds a fixed policy while tests pick their own.

Progress is streamed, not awaited

POST /corpus/wikipedia/index streams NDJSON progress events as it works. A request that returns after 2,386 documents tells the user nothing for the whole duration. The streaming version is about twenty lines.

15InterfaceThe HTTP layer

The HTTP layer stays thin so the engine stays testable

routes.ts validates input, shapes output, and calls Engine. Engine has no idea Fastify exists.

MethodRoutePurpose
GET/healthLiveness check
GET/statsLive document count
POST/documentsAdd one JSON document
POST/documents/bulkAdd an array of documents through writeBatch()
POST/documents/uploadMultipart text, code, JSON, NDJSON, and PDF
POST/ingest/githubIndex a user or repository, optionally deep
GET/corpus/wikipedia/statusBundled count against indexed count
POST/corpus/wikipedia/indexStream indexing progress as NDJSON
POST/corpus/wikipedia/deindexRemove the articles, keep the file
GET/documentsPaginated list with truncated previews
GET/documents/:idFull document, never truncated
DELETE/documents/:idTombstone and de-index
GET/searchRanked hits with snippets and BM25 breakdowns
GET/benchmarkIndexed and naive timings plus top-hit agreement
POST/compactRewrite segments, drop tombstoned records
POST/resetClear the store and the index

previewDoc() had to learn to recurse

GET /documents and GET /search trim string values to 280 characters and set _truncated. The first version only walked top-level keys.

A nested {content: {body: "…"}} from a deep GitHub index shipped whole, so responses ran to megabytes while _truncated was absent, which is the worst possible combination: wrong data and a flag saying it is fine. It now recurses through objects and arrays. GET /documents/:id still returns the full document, because that is the detail view.

parseLimit() clamps rather than trying

Unparseable or non-positive values fall back to the default, and everything is capped. A client asking for limit=100000000 gets the cap, not an attempt and a heap crash.

buildServer() returns the engine too

Tests need the handle. Production needs the onClose hook that snapshots and closes descriptors. Both SIGINT and SIGTERM route through app.close(), so a container stop is a clean shutdown rather than a recovery on next boot.

16ReferenceCost of everything

The cost of everything

One table for time, one for memory. Both are honest about what dominates in practice, which is rarely the term in the big-O.

Time · n is candidate count, k is the result limit

OperationCostWhat actually dominates
write(doc)O(1) work, 3 fsyncfsync latency, nothing else
writeBatch(docs)O(k) work, 3 fsync totalDisk bandwidth
read(id)O(1), one readSyncOS page cache
delete(id)O(terms in that document)Document size, not corpus size
search(q, limit)O(Σ postings of query terms + n log k)How common the query words are
naiveSearch(q)O(corpus bytes), re-tokenized every callCPU, then disk
compact()O(live records)Disk bandwidth
startup, snapshot validO(offset entries), JSON.parseParsing JSON
startup, snapshot staleO(corpus), full re-tokenizeCPU

Memory · rough, and rough on purpose

V8 does not promise an object layout, so these are estimates from typical heap snapshots rather than guarantees. They are still the right order of magnitude, and the order of magnitude is the point.

Offset entry
A 36-character UUID key plus a four-field object. Call it 180 bytes per document.
1M documents
Roughly 180 MB of heap before a single posting exists.
One posting
{ docId, termFrequency } is about 50 bytes, counting the shared string pointer.
A 200-token doc
Around 120 distinct terms, so roughly 6 KB of postings.
Therefore
A million such documents is several gigabytes of postings alone. This is the ceiling, and it is a heap ceiling, not a disk one.

Lucene stores the same information as delta-encoded integers in memory-mapped blocks. That is not an optimization detail, it is the difference between a corpus that fits and one that does not.

17ContextAgainst Lucene

What a real engine does that this one does not

Shardly and Lucene solve the same problem with the same shapes. The difference is everywhere in the second column, and naming it is more useful than pretending the gap is small.

ConcernShardlyLucene, and Elasticsearch on top of it
Posting storagePlain JS objects on the heapDelta and variable-byte encoded blocks on disk
SkippingNone. Walk the whole list.Skip lists over posting blocks
Term dictionaryA JavaScript MapA finite state transducer, prefix-compressed, memory-mapped
PositionsNot storedPositions, offsets, and payloads per posting
Query languageBag of wordsBoolean, phrase, span, fuzzy, wildcard, function score
SegmentsAppend log plus manual compactionImmutable segments plus a tiered merge policy
DeletesA flag in the offset mapA deleted-docs bitset per segment
DurabilityWAL, fsync per write or per batchTranslog, with refresh and flush phases
ConcurrencyOne process, synchronous I/OLock-free readers over immutable segments
Scale-outNoneShards, replicas, a coordinator, a cluster state

The structures are the same, which is the whole point

An inverted index is an inverted index whether the postings are JavaScript objects or variable-byte integers in a mapped file. BM25 is BM25 whether you compute it in TypeScript or in a JIT that has been tuned for twenty years. Segments are immutable and merged in both systems, and both put a durable log in front of the data.

Every row in that table is an engineering answer to a scale Shardly does not operate at. Skip lists matter when a posting list has ten million entries. A finite state transducer for the term dictionary matters when the vocabulary does not fit in memory. Positions matter the moment somebody types a phrase in quotes.

What you get for reading this one instead

You can read all of Shardly in an afternoon and hold it in your head afterwards. Every constant is in one file. Every fsync is on a line you can point at. When a search returns the wrong document, you can decompose the score term by term and find out why.

That is not a substitute for Lucene and is not trying to be. It is the thing you build once, so that Lucene stops being magic.

18HonestyWhere it stops

Said out loud, before you find them

Seven limits, and the order I would fix them in. Effort and payoff both matter, and they rarely point the same way.

01

No checksums

Recovery validates structure, not content. A flipped bit inside a string value survives JSON.parse and the id check, and every one of the four recovery tests passes it. A CRC per record is the fix, it is contained to the record format, and it closes the only silent-corruption hole in the design.

Fix first. Small change, removes a whole failure class.

02

All I/O is synchronous

fs.readSync and fs.writeSync block the Node event loop, so one slow read stalls every concurrent request. At a scale where the page cache holds the working set this is fine, and it keeps the storage code linear and readable. At a larger scale it is the first thing to change, and it is not a small change.

Second. Large diff, and the readability cost is real.

03

Search is a bag of words

No phrase queries, no boolean operators, no field weighting, no fuzzy matching. Position data has to go into the postings before any of that becomes possible, which means the posting format changes and the snapshot format changes with it.

Third. Biggest jump in what the product can do.

04

The stemmer is six rules

Porter's algorithm fixes the doubled-consonant case and most of the over-stemming. It is a self-contained swap into one function that already has tests around it.

Cheap. Do it any time.

05

The inverted index is memory-resident

Postings are JavaScript objects, nothing spills to disk, and the corpus has to fit in the heap. Real engines use skip lists and delta-encoded posting blocks on disk. See the memory table above for where the wall is.

Only when the wall is actually hit.

06

One writer, one process

Nothing coordinates two processes against one data directory. There is no lock file. Opening the same directory twice will corrupt it. A lock file is twenty lines and would at least turn corruption into an error message.

Twenty lines. Worth doing on principle.

07

No authentication

POST /reset destroys the store and DELETE /documents/:id removes a document, both unauthenticated, with CORS set to origin: true. Correct for a local demo, unacceptable the moment it is reachable from anywhere else.

Blocking, if this is ever deployed.

Also true, and less fixable

Snapshots are JSON. Human-readable and slow to parse. A binary format would load faster and diff worse. For a system whose entire point is being readable, JSON wins, and I would make the same call again.

The tokenizer cannot index non-English text. Not ranks it poorly. Cannot index it. Fixing that means a Unicode-aware character class and a per-language stemmer, at which point you are building a real analysis chain and should probably use one.

Compaction blocks. Everything blocks. See limit 02.


A limit you have written down is a decision. A limit you have not is a surprise waiting for someone else.