Every full-text engine lies a little

93 983 Linux kernel files. Three search engines. One byte-by-byte ground truth.

Lucie Defraiteur · 7 September 2026

$ search "de" — two letters, over 93 983 files
Truth, a scan of the files93 009 files
Elasticsearch, trigram analyzer0
tantivy, n-gram tokenizer0
lucivy93 009

Here is a thing I did not expect to find: ask a search engine for the two letters "de" over the source of the Linux kernel, and two of the three engines I tested answer 0 documents. Not an error, not a warning. Zero. The truth is 93 009 of 93 983 files.

I write a search engine, so I am not neutral, and I will say where it loses. But the point of this post is not my engine. It is a habit, and it fits in one box:

The rule

The files are the ground truth. Every engine's answer is compared against a byte-by-byte scan of the same files — document ids and exact byte spans. Not against another engine, not against a fixture someone wrote by hand.

Once you have that habit, every engine turns out to lie a little. Not mine, today: the test that caught my own engine twice this summer now runs on every release, and it passes with zero mismatches.

Setup Results Where they win The method Reproduce it Try it

The setup

One corpus: a Linux kernel tree (7.2), 93 983 text files after filtering, 857 MB of text. Three engines, each configured the way its own documentation says to do substring search, not left at defaults that would make it a straw man:

And one judge: a scan of the files. For every query, a small program reads each file and finds the matches with plain string operations or a regex, then counts the documents and, when it can, the byte offsets. The scan takes seconds per query; that is fine, it only has to be right. Everything here is reproducible with one script, benches/compare_engines.sh, which writes this report with the exact mappings and tokenizer settings.

First, the boring sanity check: ordinary substrings work everywhere. mutex_lock (5 145 files), spin_lock (6 569) and sched (9 289) match the scan exactly on all three engines. That is the case everyone tests, and everyone passes it.

Where they part

93 0090
A two-character substring. Both trigram indexes return nothing, and say nothing.
10 0343 549
A typo sitting across an underscore. Term-level fuzziness misses two thirds of the files.
20 797 / 20 797
Every byte position of mutex_lock in 5 145 files, delivered with the answer, in 15 ms.
askedtruthlucivyElasticsearchtantivy
spin_lock, separators strict6 569✔ 6 569 12 ms✔ 6 569 10 ms✔ 6 569 116 ms
spin_lock, separators relaxed9 552✔ 9 552 23 ms▲ 6 577▲ 6 601
spinlokc, two edits, across the boundary10 034✔ 10 034 148 ms▲ 3 549▲ 6 557
spin_lock_[a-z]+, a regex5 510✔ 5 510 219 ms▲ 5 440 480 ms✘ 0
"de", two characters93 009✔ 93 009 561 ms, 7.7 M spans✘ 0✘ 0
"©", one character, in 1 878 files1 878✔ 1 878 6 ms✘ 0✘ 0
retur -ENOMEM, a fuzzy phrase14 449✔ 14 449 30 ms✔ 14 446 24 ms
positions of mutex_lock, 5 145 files20 797✔ all 15 ms▲ top 200 179 ms▲ re-read 96 ms

✔ matches the scan · ▲ incomplete · ✘ nothing, silently. Timings are the engine's own, idle machine.

What is going on in each column:

Read the zeros again. They are the interesting part. An engine that returns 6 577 where the truth is 9 552 is wrong in a way you might notice. An engine that returns 0 for two characters looks like it worked and found nothing, and nobody notices, because who checks a zero?

To be precise about the word in the title: some of these are bugs; others are consequences of the index configuration, and an Elasticsearch expert will rightly say that with min_gram = 3 the query "de" is unrepresentable by design. True. From the caller's point of view the dangerous part is the same: a plausible-looking answer can still be incomplete, and the API does not distinguish "none" from "I cannot ask that".

Where they win, because they do

This is not a hit piece. Same corpus, same machine, idle:

tantivy, default tokenizer612 MB · ×0.7
tantivy, n-grams680 MB · ×0.8
Elasticsearch, standard781 MB · ×0.9
Elasticsearch, trigrams + wildcard3 082 MB · ×3.6
lucivy, derived_in_ram3 335 MB · ×3.9
lucivy, default4 926 MB · ×5.8

Index size, and the ratio to the 857 MB of text.

tantivy, default tokenizer1 s
tantivy, n-grams5 s
Elasticsearch, standard28 s
lucivy, default107 s
lucivy, derived_in_ram111 s
Elasticsearch, trigrams + wildcard123 s

Time to index the corpus.

tantivy indexes this corpus in one to five seconds. Mine takes a hundred. Its index is seven times smaller. On a whole-word query it answers in 0 ms where mine takes 27. Elasticsearch does the fuzzy phrase as well as I do. If your queries are whole words, use them and be happy.

And note the difference of kind: Elasticsearch is a service you run next to your application; mine is a library that goes inside it. The index lives in your process, in your transaction if you plug your own storage, on your machine — nothing to deploy beside your service, nothing that leaves it, and the same engine runs in the browser with the data staying in the tab.

What my index buys with its size is the first table: the questions a trigram index cannot ask, and the exact position of every match delivered with the answer instead of recomputed for the top 200. Put more simply: you decide what to ask when you ask it, not six months earlier when you picked an analyzer. Every zero in that table is a decision taken at index time — min_gram = 3, a tokenizer that drops separators — that forbids a question forever. A suffix index takes no such decision; strict or relaxed, exact or fuzzy, regex or two characters are options of the query.

The method is the point

I did not start with the comparison. I started with a test that indexes the kernel, runs a panel of queries, and fails if any count or any span disagrees with the scan. It runs on every release. It caught my own engine lying twice this summer: once dropping documents whose fuzzy match crossed a separator (3.0.2 to 3.0.6, a whole month), once returning a stale dictionary one run in three under a race. Both were invisible in the ordinary tests, which showed "20 hits" because 20 was the result limit.

If you take one thing from this post: put a scan of the files next to your engine, on a real corpus, and diff. It is a slow, dumb program. It is also the only one in the room that cannot be wrong about what is in the files.

Reproduce it

git clone --depth=1 --branch v7.2 https://github.com/torvalds/linux /tmp/linux-bench
V3_CORPUS=/tmp/linux-bench V3_SFX_VERSION=4 cargo test --release -p lucivy-core \
    --test test_sfx_v3_ground_truth v3_ground_truth_demo -- --ignored --nocapture
# the three-engine comparison, Elasticsearch optional (a container):
benches/compare_engines.sh /tmp/linux-bench

Another kernel tree gives other counts; what does not move is the claim: the harness fails on any disagreement between the engine and the scan of your files.

If you want to try the engine

lucivy is a Rust library, MIT, with Python, Node.js and C++ bindings, and it runs in the browser: the playground clones its own source from GitHub and indexes it in your tab in about three seconds, then you can type index postgres or index linux and search a whole tree with --fuzzy, --regex, --strict. The repository is here. And if you know a configuration of Elasticsearch or tantivy that gets closer on any row above, tell me: it goes into the report with your name on the line.