cpp-searchlib

September 5, 2026 · View on GitHub

C++17 full-text search engine library (WIP. Far from release...)

TODO:

  • Save/load index to/from storage
  • Posting list compression
  • Search scope (document, section, paragraph)

Installation

One header, include/searchlib.h. Nothing to build and nothing to link: copy it, or add include/ to your include path and

#include <searchlib.h>

It needs three header-only libraries, vendored under third_party/ and included as <fstlib.h>, <peglib.h>, <unicodelib.h> and <unicodelib_encodings.h>, so each of those directories goes on the include path too. A project that already vendors any of them points at its own copy instead and ends up with one definition of each. With CMake, linking the searchlib interface target does all of that.

Japanese segmentation lives in a second header, <searchlib_segment.h>, because it needs a fourth vendored library and a model file; see Japanese word segmentation.

Usage

using namespace searchlib;

std::vector<std::string> documents = {
  "This is the first document.",
  "This is the second document.",
  "This is the third document. This is the second sentence in the third document.",
  "This is not the first document.",
};

auto normalizer = [](const auto &str) { return unicode::to_lowercase(str); };

// Indexing...
InMemoryInvertedIndex<TextRange> invidx;
InMemoryIndexer<TextRange> indexer(invidx, normalizer);

// The key is yours: any size_t, in any order. The index numbers documents
// itself underneath (see IPostings::document_ordinal in searchlib.h).
size_t document_key = 0;
for (const auto &doc : documents) {
  indexer.index_document(document_key, UTF8PlainTextTokenizer(doc));
  document_key++;
}

// Search...
auto expr = parse_query(normalizer, R"( document -third )");
auto result = perform_search(invidx, *expr);
BM25Scorer scorer(invidx, *expr);

for (size_t i = 0; i < result->size(); i++) {
  // A result speaks ordinals; the index maps them back to your keys.
  auto document_key = invidx.document_key(result->document_ordinal(i));
  auto score = scorer(*result, i);

  for (size_t hit = 0; hit < result->search_hit_count(i); hit++) {
    // Text range for highlighting (UTF-8 byte position and length)
    auto rng = invidx.text_range(*result, i, hit);
    // documents[document_key].substr(rng.position, rng.length)
  }
}

Query syntax

SyntaxDescription
apple bananaAND - documents containing all terms
apple | bananaOR
apple -bananaNOT - exclude documents containing the term
"apple tree"Phrase - adjacent terms
apple ~ treeNEAR - terms within 4 term positions
app*Prefix - every term starting with app
a*e, *ana, app*ionWildcard - * matches zero or more characters anywhere in the term
apple~2Fuzzy - terms within 2 edits of apple
( ... )Grouping

Terms are tokenized and normalized in the same way as documents, so Unicode terms work as long as the tokenizer indexed them. See Words for what a term is, and Japanese word segmentation for languages written without spaces. NOT is only valid along with at least one positive term.

A trailing * expands against the index's dictionary at search time, so app* is equivalent to an OR over every indexed term starting with app, scoring included. The star has to touch its term (app*, not app *), and a bare * is not a match-all: it stays an ordinary term, which no index can contain. The prefix is enumerated with IInvertedIndex::enumerate_terms_with_prefix, which is also usable directly:

index.enumerate_terms_with_prefix(U"app", [](const auto &term) {
  std::cout << u8(term) << std::endl;
});

The two backends have different cost shapes. The writable in-memory index scans its whole dictionary (a hash map), so it pays for the entire vocabulary no matter how selective the prefix is. An index loaded with load_compressed_index descends the FST its dictionary is stored as, so it pays per matching term instead.

On the KJV corpus (12,594 terms) that is a fixed ~85us for the hash map against ~60ns per match for the FST, so the FST is far faster for the selective prefixes real queries use (about 11x for a 148-term prefix, 100x for a 4-term one) and only loses once the prefix matches more than roughly a tenth of the vocabulary.

perform_search enumerates once per query, but bm25_score takes an expression per hit, so scoring a prefix query with it repeats that lookup for every hit. BM25Scorer expands once at construction instead:

auto expr = *parse_query(normalizer, "app*");
auto result = perform_search(index, expr);
BM25Scorer scorer(index, expr);
auto hits = top_k(*result, 10, [&](size_t i) { return scorer(*result, i); });

A * anywhere else in a term -- leading, interior, or more than one -- is a wildcard instead of a prefix: a*e matches apple, *ana matches banana, app*ion matches application. It is backed by IInvertedIndex::enumerate_terms_with_wildcard and expanded the same way (expand_wildcards before scoring). Only * is supported, matching zero or more characters; there is no ? or character class.

The two backends answer it differently, same as prefix search: the in-memory index tests every term against the pattern, while the compressed backend walks the FST with a small automaton (step/is_match/can_match, the same shape as fstlib's own edit_distance_search) that prunes any subtree the pattern cannot match. It still visits more of the FST than a literal prefix does -- a * can match anything, so descent can't be confined to one subtree -- which is why the common single-trailing-* case stays on the cheaper Prefix path instead of going through the wildcard automaton.

term~N matches every term within N Levenshtein edits (insertion, deletion, substitution) of term, counted in codepoints rather than bytes, so it works on non-ASCII text. apple~1 finds ample and apply; sanctifed~1 finds sanctified.

The ~ has to touch its term and be followed by digits, which is what keeps it apart from the NEAR operator: apple~2 is a fuzzy query, while apple ~ 2 and apple~tree are still NEAR. N is capped at 2 (as it is in Lucene): a larger distance defeats the pruning both backends depend on and matches most of the dictionary. The underlying IInvertedIndex::enumerate_terms_with_edit_distance takes any distance, since a caller naming one directly is not untrusted input.

index.enumerate_terms_with_edit_distance(U"apple", 1, [](const auto &term) {
  std::cout << u8(term) << std::endl;
});

Like a prefix or wildcard query, this expands to an OR over the matching terms, so expand_fuzzy is worth calling once before scoring for the same reason expand_prefixes is. Every matching term contributes equally to the score -- unlike Lucene, a closer edit distance carries no boost.

The compressed backend walks the FST with fstlib's own LevenshteinAutomaton, which prunes a subtree as soon as every alignment through it already costs more than N. The in-memory backend tests each term with a rolling-row DP, skipping any term whose length alone puts it out of range.

Scoring

term_count_score, tf_idf_score and bm25_score are available to score each search result. Ranking is up to the caller.

top_k collects the k highest-scoring hits using a bounded min-heap (O(n log k) instead of scoring and sorting every hit):

BM25Scorer scorer(invidx, *expr);
auto hits = top_k(*result, 10, [&](size_t i) { return scorer(*result, i); });

for (const auto &hit : hits) {
  auto document_key = invidx.document_key(result->document_ordinal(hit.index));
  // hit.score
}

Persistence

An index can be saved to and loaded from disk, so it does not have to be rebuilt on every startup.

// Save...
InMemoryInvertedIndex<TextRange> invidx;
// ... index documents ...
invidx.save("index.bin");

// Load...
InMemoryInvertedIndex<TextRange> loaded;
loaded.load("index.bin");

save/load also have std::ostream/std::istream overloads. The built-in TextRange value type is serialized automatically; for a custom text-range type T, pass a serializer/deserializer pair:

invidx.save(os, [](std::ostream &os, const T &v) { /* write v */ });
loaded.load(is, [](std::istream &is) -> T { /* read and return a T */ });

The default on-disk format is a "plain" host-native dump (format_type 0) tagged with a format_type/schema_version header. It is intended to be loaded on the same platform that wrote it.

Compressed format

IndexFormat::Compressed (format_type 2) stores the postings and text ranges as Elias-Fano sequences and the term dictionary as an FST, which on the KJV corpus brings the index down to about 20% of the plain size:

invidx.save("index.bin", {}, IndexFormat::Compressed);

It can be read back into a normal InMemoryInvertedIndex with load, or opened as an immutable, memory-lean index that answers queries straight off the compressed structures without expanding them:

auto index = load_compressed_index("index.bin");

Being immutable, it is safe to share across reader threads without external locking.

The FST dictionary is what makes this backend both small and good at prefix search: on KJV it holds the same 12,594 terms in 54KB instead of 459KB, and answers a selective prefix 11x to 100x faster than a hash scan. The trade is exact term lookup, which walks the FST instead of hashing once: 181ns against 20.5ns, so about 160ns more per lookup.

Multi-field schema

Documents with several distinct text fields (title/body/tags) each get their own InMemoryInvertedIndex, grouped by name in a MultiFieldIndex. All fields of one document share the same key, and every hit carries it as document_key, so hits from different fields for the same document are grouped by a plain comparison on that -- the fields' own ordinals differ, and never need to be compared:

MultiFieldIndex<TextRange> index;

InMemoryIndexer(index.field("title"), normalizer)
    .index_document(0, UTF8PlainTextTokenizer("The Great Gatsby"));
InMemoryIndexer(index.field("body"), normalizer)
    .index_document(0, UTF8PlainTextTokenizer("A story about wealth."));

// Field-qualified search: just call perform_search on one field's index.
auto title_hits = perform_search(index.field("title"), *expr);

// Search every field and get back hits tagged with their field name;
// combining/ranking across fields is left to the caller.
auto hits = perform_multi_field_search(index, *expr);

There is no query-string field: syntax; field selection is a C++-level choice. MultiFieldIndex::save/load persist every field.

Words

A term is a UAX #29 word segment that contains a letter or a number; that is what UTF8PlainTextTokenizer, utf8_plain_text_splitter() and parse_query (when handed no splitter) all cut, on both sides. So version 2.0 is version and 2.0, don't and U.S.A and 1,234.56 are one term each, spaces and punctuation between words are not terms, and the scripts written with spaces come out word by word:

version 2.0 shipped   -> version / 2.0 / shipped
don't say U.S.A.      -> don't / say / U.S.A
Русский язык, 한국어   -> Русский / язык / 한국어

The scripts written without spaces are where UAX #29 itself defers to a dictionary: Han, Hiragana, Thai and their kin come out one scalar per term (a run of Katakana is one term), which is the unigram baseline -- 東京タワー indexes as / / タワー and a query for 東京 becomes the implicit phrase + , so it is found. utf8_plain_text_splitter(segmenter, claims) plugs a dictionary in: every segment whose first scalar claims accepts is handed to segmenter with the whole text and its offset, and the segmenter emits the words it finds from there and returns how many bytes it consumed. What comes back is checked, not trusted (see Segmenter in the header): a span that does not end on a grapheme cluster boundary is dropped whole, a word that starts before its neighbour or is cut inside a cluster is dropped alone, and everything else is indexed exactly as emitted.

Japanese word segmentation

load_segmenting_splitter returns the default splitter with a Japanese segmenter plugged in for Han, Hiragana and Katakana, using the vendored cpp-segmentlib:

#include <searchlib_segment.h>

// One splitter, shared by both sides. This is what keeps their term
// boundaries identical -- using it on only one side gives an index where
// 東京 can be found but 東京タワー cannot.
auto splitter = load_segmenting_splitter("ja-ud-gsd.mod");

// Indexing: wrap it in SplitterTokenizer, which adds the term positions.
indexer.index_document(0, SplitterTokenizer(splitter, "私は東京タワーに行った"));
// indexed as: 私 / は / 東京 / タワー / に / 行っ / た

// Search: pass the same splitter to parse_query.
auto expr = parse_query(splitter, nullptr, "東京タワー");
// -> Adjacent(東京, タワー), an implicit phrase, so it matches the document
//    above but not a document merely containing 東京 and タワー separately.

A query token the splitter cuts up becomes an implicit phrase, the same treatment well-known gets. Insert a space to get an AND instead.

Only runs of Han, Hiragana and Katakana go through the model, so everything else is split byte-for-byte the way the default splitter splits it -- a Japanese model would otherwise shred it (iPhone into i/Phone). Analyzer<T> chains, prefix (東京タワ*) and fuzzy (東京タワー~1) all compose with it as usual.

Note that switching an existing index to a different splitter requires a full re-index: the term boundaries change, and with them document_term_count and every BM25 score.

The model file is supplied by the caller; upstream's 2.1 MB MLP reference model is used by the tests as test/models/ja-ud-gsd.mod. It is licensed CC BY-SA 4.0, not MIT like this repository's code (it derives from the UD_Japanese-GSD treebank) -- see test/models/NOTICE before redistributing it.

Cutting words into subwords

A language written with spaces is already cut into words by the default splitter; what a morphological analyzer for it adds is cutting each word into its morphemes. That is a string -> list<string> function, and subword_splitter lifts one into a TextSplitter, giving every piece its own position and byte range:

auto splitter = subword_splitter(nullptr, [](std::string_view word) {
  return my_analyzer.morphemes(word);   // e.g. 한국어를 -> 한국어, 를
});

indexer.index_document(0, SplitterTokenizer(splitter, text));
auto expr = parse_query(splitter, nullptr, "한국어를");   // -> Adjacent(한국어, 를)

The pieces are a sequence, so a query for the whole word becomes an implicit phrase over them, the same way a token the segmenting splitter cuts up does. This is the difference from a TermFilter that emits several times: those outputs are alternatives (a synonym set), which parse_query turns into an OR and which the index side stacks on one term position (Lucene's positionIncrement == 0). A TextSplitter stacks the same way, by emitting terms whose ranges start at the same byte -- a compound beside its parts -- and a query token it splits so parses as an Adjacent of one OR per position. The pieces must be the word's own bytes in order (an analyzer that answers with a lemma rather than the surface form should be a TextSplitter of its own, carrying its offsets); an empty answer drops the word.

CLI

cli/ builds a small searchlib-cli executable exercising the library:

# Index every file under a directory into INDEX_PATH. Each file's path is
# its document key, so a hit comes back as the path it was read from.
searchlib-cli index SOURCE INDEX_PATH

# Search INDEX_PATH, printing the top hits ranked by BM25 with their
# matching text ranges.
searchlib-cli search INDEX_PATH QUERY [-n N]