Text search benchmarks

What a search box costs, and how good the answers are.

What a search box costs: match ranked by BM25, the marks of what it matched, the facets beside it, and the words a typo or a half typed word reaches. In one process on an M1.

100 000 documents of 30 wordsmedian
match … limit 10, two words0.06 ms
… with highlight(), a row's marks1–3 µs more
… with snippet(), a row's2–4 µs more
… with two facets over what it reaches, about 16 667 rows1.6 ms
a facet over every row, through @hash0.01 ms
… read from every row6.1 ms
fuzzy and prefix 
a word's neighbours among FiQA's 135 500 words, one edit14 µs
… two edits120 µs
… the words it begins4 µs
the sorted words, made at the first loose match, 60 000 words6 ms, 1.3 MB
  • A query that asks for no marks runs as it did. The marks read the row's text again by the index's own tokenizer; nothing is kept for them in the index.
  • How good the answers are is the next section: BM25 on two BEIR datasets, with a typo in each query and half typed, beside the vectors.
Method, and the commands that run it

100 000 documents of 30 words each over a vocabulary of 5 000, the commoner words more common, a brand under @hash (40 values) and a color with no index (12). Each figure is the median of 15 runs over 200 two-word queries; a row's marks are the difference over the ten rows of match alone. The words a typo reaches are timed over FiQA's own words, 2 000 query words with a typo in each, and the sorted words over 100 000 documents of made-up words, many an edit or two from another, as a language's are (Typos and words begun, Highlights and snippets, Facets).

make search-bench
make fuzzy-bench BEIR=path/to/fiqa

Retrieval quality

How good the answers are, not how fast: every way fenecdb ranks documents, scored by nDCG@10 on two public BEIR datasets. Fusing keyword and vector search (fuse) is the one path near the top of both.

 SciFactFiQAFiQA p50
match, BM250.6620.2320.26 ms
near, the graph0.6450.3660.37 ms
near … exact0.6450.3694.99 ms
match … rerank, 50 candidates0.6560.3370.45 ms
match … rerank, 200 (default)0.6460.3610.78 ms
match … rerank, 1 0000.6490.3691.82 ms
match … near … fuse, 10 a side0.7000.3640.67 ms
match … near … fuse, 20 (default)0.7000.3660.74 ms
match … near … fuse, 1000.6880.3590.97 ms
match … near … fuse, 1 0000.6900.3644.03 ms
near, sparse (SPLADE++)0.6930.3310.68 ms
match … near … fuse, sparse0.6980.3141.07 ms

With a typo in each query, and with each query's last word half typed, as a search box sends it: fuzzy and prefix take back most of what is lost, and leave the queries written whole where they were.

 SciFactFiQAFiQA p50
match, as written0.6620.2320.26 ms
match … fuzzy, as written0.6650.2330.74 ms
match … prefix, as written0.6630.2340.32 ms
match, a typo0.6360.2130.24 ms
match … fuzzy, a typo0.6630.2290.71 ms
match … fuzzy 1, a typo0.6660.2310.53 ms
match, the last word half typed0.6160.1880.26 ms
match … prefix, half typed0.6550.2080.60 ms
match … fuzzy prefix, half typed0.6580.2101.09 ms
How the typos were made

One typo a query, by a generator seeded by the query's place (crates/fenec-bench/src/bin/beir.rs): in one word of four letters or more, a letter dropped, a key beside it on the keyboard typed as well or instead, or two letters swapped — the four kinds Damerau found four misspellings in five to be — and in the first letter 7% of the time, as Pollock and Zamora found it. SciFact's 300 test queries hold 297 such words, FiQA's 648 hold 644. Half typed is the last word cut to half its letters, rounded up. A word of under five letters takes no edit, which is most of what fuzzy leaves unrecovered; with one typo a query, fuzzy 1 does a little better than the two edits nine letters allow, which pay where a long word holds two. The p50s are the whole query's, the words found and their lists merged included.

Method, and what the BEIR runs found
cd crates/fenec-bench/beir && npm install
node embed.mjs path/to/scifact     # dense: all-MiniLM-L6-v2, 90 MB
node splade.mjs path/to/scifact    # sparse: SPLADE++, 532 MB, optional
make beir BEIR=path/to/scifact

Two BEIR datasets as BEIR ships them — SciFact, 5 183 documents and 300 test queries, and FiQA, 57 638 and 648 — with every document and query embedded by all-MiniLM-L6-v2 the way sentence-transformers does it, and encoded by SPLADE++ (prithivida/Splade_PP_en_v1) into a sparse<30522> field, both cut at 256 tokens with the closing [SEP] kept. transformers.js drops it when it cuts a text, so both scripts cut texts themselves: for SPLADE that was worth 0.46 of SciFact's nDCG@10, for the dense vectors up to 0.003. Each of the engine's ways of ranking ten documents is then scored by nDCG@10, through FenecQL: the answers are the engine's own.

The two datasets pull opposite ways. In SciFact a claim shares its words with the evidence and BM25 alone beats the transformer; in FiQA a question shares few words with its answer and BM25 is far behind. fuse is the one path near the top of both. The rank offset matters less than the depth: at 20 a side, k from 10 to 120 moves either score by at most 0.005.

SPLADE sits between the two. On SciFact it comes within 0.006 of the dense fusion with one index; on FiQA it lifts BM25 by 0.099 but stays under the dense vectors, and fused with BM25 there it loses 0.016 — both sides are lexical. Its queries' 37 to 65 terms reach most of the corpus, and its lists are summed a list at a time rather than walked a document at a time: 0.68 ms, where MaxScore's walk took 2.58 (How it works).