lucene
Every string search I did not have to write

The first version of any search feature is a LIKE:
SELECT id, title FROM substances WHERE name LIKE '%' + @q + '%';
It works for about a week. Then somebody searches for aspirin in a corpus
that spells it acetylsalicylic acid, somebody else pastes in C9H8O4 and
gets nothing, a third person types 2-(acetyloxy)benzoic acid with a
different kind of dash than the typesetter used, and the fourth complaint is
that the results come back in primary-key order, which nobody has ever wanted.
You can push a fair distance further than that — full-text indexes in SQL Server or Postgres are real search engines and I have shipped features on both. But the moment the tokenisation rules are domain-specific, you need to own the analysis chain, and owning the analysis chain is the thing Lucene is for. This is a note about what stepping onto it actually buys, written after two years of doing exactly that on a chemistry corpus.
What a chemical name does to a normal tokeniser
Here is StandardAnalyzer, which is the sensible default for English prose,
meeting four real queries:
input StandardAnalyzer produces
2-(acetyloxy)benzoic acid → 2, acetyloxy, benzoic, acid
C9H8O4 → c9h8o4
α-D-glucose → α, d, glucose
50-78-2 → 50, 78, 2
Every line is wrong in a different way. The first threw away the structure
that distinguishes one isomer from another. The second is technically intact
but lowercased into a token that will never match a document where the same
formula was written C₉H₈O₄. The third split a stereodescriptor off the head
of the name and left a bare d that now matches half the corpus. The fourth
turned a CAS registry number into three integers.
The naive fix is a pile of regular expressions in front of the query box. That is the wrong place: whatever you do to the query, you must have already done to the document, or the two strings will never meet. Which is the single most useful thing Lucene teaches, and it teaches it structurally.
The inverted index is the primitive worth having
Underneath the analysers, Lucene is one data structure and it is not
complicated to describe: for every term, the sorted list of documents
containing it, with positions and offsets. benzoic → documents 4, 17, 903,
at these word positions, at these character offsets.
That shape is what makes search cheap and ranked rather than expensive and unordered. A scan compares the query against every row. An inverted index intersects a handful of short lists and then scores only the documents that survived. Phrase queries fall out of the positions. Highlighting falls out of the offsets. Ranking has somewhere to stand, because the index already knows the term's document frequency.
What I did not appreciate until I had to read the internals: almost all of Lucene's cleverness is in making that structure small and sequential on disk. The term dictionary is an FST. Postings are delta-encoded and bit-packed with skip lists over them. Numerics live in a BKD tree so a range query does not become a term query with ten thousand clauses. Segments are immutable and get merged in the background, which is how writes and reads stop fighting.
That is the list of things I would have had to write badly. It is also the answer to "why not just build an index in Postgres" — you can, and it is a good index, but you do not get to replace the tokeniser with one that understands stereodescriptors.
Analysis is the part you actually write
An Analyzer is a factory for a chain: character filters, then a tokeniser,
then token filters. Ours, roughly:
final class ChemicalAnalyzer extends Analyzer {
private final NormalizeCharMap symbols; // α → alpha, ′ → ', ‐ ‑ ‒ – → -
private final SynonymMap synonyms; // curated, from the substance registry
@Override
protected Reader initReader(String field, Reader reader) {
// Character filters run before tokenisation and, crucially, keep an
// offset map so highlighting still points at the original text.
return new MappingCharFilter(symbols, reader);
}
@Override
protected TokenStreamComponents createComponents(String field) {
Tokenizer source = new WhitespaceTokenizer();
TokenStream out = new WordDelimiterGraphFilter(
source,
WordDelimiterGraphFilter.GENERATE_WORD_PARTS
| WordDelimiterGraphFilter.GENERATE_NUMBER_PARTS
| WordDelimiterGraphFilter.CATENATE_ALL
| WordDelimiterGraphFilter.PRESERVE_ORIGINAL,
null);
out = new LowerCaseFilter(out);
out = new SynonymGraphFilter(out, synonyms, /* ignoreCase */ true);
out = new FlattenGraphFilter(out); // index side only — see below
out = new KeywordRepeatFilter(out); // keep the unstemmed form too
out = new PorterStemFilter(out);
out = new RemoveDuplicatesTokenFilter(out);
return new TokenStreamComponents(source, out);
}
}
Five details in there took real time to learn, so they are worth writing down.
PRESERVE_ORIGINAL with CATENATE_ALL is the whole trick for names with
punctuation. 2-(acetyloxy)benzoic indexes as the parts, as the
concatenation 2acetyloxybenzoic, and as itself. A user who types any of the
three finds the document.
FlattenGraphFilter belongs on the index side and nowhere else. Both
graph filters can emit a token lattice — one input position producing
alternatives of different lengths. The index format cannot store a lattice, so
you flatten it, accepting a slightly lossy result. At query time you must not
flatten: the query parser handles the graph properly and builds the right
disjunction, and flattening there is how you get phrase queries that silently
stop matching.
KeywordRepeatFilter before the stemmer, RemoveDuplicates after. Every
token is indexed twice — stemmed and not — so an exact form outranks a stemmed
one without a second field. Two lines, and it removed a whole category of
complaint.
Symbol normalisation is a character filter, not a token filter. It has to
happen before tokenisation because it changes what the tokeniser sees, and
MappingCharFilter keeps the offset correction so a highlight still lands on
the right character in the source document. A hand-rolled String.replace
in front of the analyser loses that and your highlights drift by one character
per substitution.
Formulas and registry numbers do not go through any of this. They are a separate, unanalysed field:
doc.add(new StringField("formula", "C9H8O4", Field.Store.YES)); // exact, TermQuery
doc.add(new StringField("cas", "50-78-2", Field.Store.YES));
doc.add(new TextField("name", name, Field.Store.YES)); // analysed
doc.add(new TextField("body", body, Field.Store.NO));
StringField is one token, verbatim. Trying to make one clever analyser serve
both prose and identifiers is the mistake I watched us nearly make twice; the
answer is always another field. PerFieldAnalyzerWrapper then keeps the
per-field choices in one place.
The rule that costs the most to learn
Index-time and query-time analysis have to agree, and when they do not, the symptom is never an error. It is a document that obviously matches and does not come back.
Two tools make that a ten-minute problem instead of an afternoon. The first is dumping the chain by hand, which we wrapped in a test helper:
static List<String> terms(Analyzer analyzer, String field, String text) throws IOException {
var out = new ArrayList<String>();
try (TokenStream ts = analyzer.tokenStream(field, text)) {
var term = ts.addAttribute(CharTermAttribute.class);
ts.reset();
while (ts.incrementToken()) out.add(term.toString());
ts.end();
}
return out;
}
@Test
void formula_survives_analysis() {
assertThat(terms(indexAnalyzer, "name", "2-(acetyloxy)benzoic acid"))
.contains("2acetyloxybenzoic", "2", "acetyloxy", "benzoic", "acid");
}
We ended up with about ninety of those assertions, and they are the highest value tests in the search tier. They are also the ones a reindex will break loudly, which is exactly what you want.
The second is explain:
System.out.println(searcher.explain(query, docId));
Which prints the whole scoring tree — which clauses matched, each term's IDF,
the length normalisation, the final sum. Nobody's intuition about relevance
survives contact with an Explanation, including mine. Half the times I was
sure the ranking was broken, the ranking was fine and my query had a clause I
did not intend.
The extension points that made it ours
This is the actual argument for building on Lucene rather than on a search product: every layer is replaceable, and I mean the layers you would not expect.
Similarity. BM25 is the default and is the right default. We tunedk1andbper field —new BM25Similarity(1.2f, 0.35f)onname, because length normalisation is close to meaningless on a field where every value is a substance name, and leftbodyalone. APerFieldSimilarityWrapperholds that.- Payloads. The annotator emits a confidence per annotation, and it goes
into the postings as a payload on the term, then into the score through a
PayloadScoreQuery. A term the NLP pipeline was unsure about contributes less. There is nowhere to put that in aLIKEquery and nowhere obvious to put it in most search products. - Intervals.
Intervals.ordered(Intervals.term("acetylsalicylic"), Intervals.maxgaps(4, Intervals.term("synthesis")))expresses this name, then that word, within five positions — a proximity language with real composition, and much easier to reason about than the spans queries it replaced. - DocValues. A column-oriented per-document store, which is how you sort
and facet without loading fields. Publication year as a
NumericDocValues, substance class asSortedSetDocValuesFacetField. UnifiedHighlighter. Given offsets in the postings, it re-runs the query against a single document to produce snippets with the matched spans marked. It reuses the analysis chain, so a synonym hit highlights the word that was actually in the document.SearcherManager. Near-real-time search: anIndexSearcheris a snapshot, and the manager hands out the current one with reference counting so a reopen does not pull the index out from under a running query. Newly annotated documents appear in seconds without a restart.
None of that is exotic and all of it is in the box. The number of afternoons that has saved me is the reason I keep listing Lucene under things I am interested in rather than things I once used.
Where it is the wrong answer
Lucene is a library, and the failure mode of a library this capable is reaching for it as if it were a database.
It is not a database. One index, one IndexWriter, one process. No
transactions spanning documents, no queries across indexes, no replication.
Durability is a commit, which is an fsync and not cheap, so you batch — and
if the process dies between commits, that batch is gone and something upstream
must be able to replay it. Our source of truth was Virtuoso and the index was
derived, always, deliberately.
Changing analysis means reindexing. Everything above is baked into the terms on disk. Adding one synonym rule to the analyser means every affected document is wrong until it is rebuilt. Budget for a full rebuild being routine and automated on day one, not a thing you discover in month four.
Distribution is not included. Sharding, replication, failover, a query API, cluster state — that is precisely the gap Solr and Elasticsearch fill, and if you need those, use them rather than growing your own. Our corpus fit on one machine's index with room to spare, which is the only reason straight Lucene was defensible.
Wildcards and regexes will eat you. A leading wildcard has to enumerate the
term dictionary. RegexpQuery is worse. If users need infix matching, index
n-grams at index time and pay for it there, in disk, where you can measure it.
And if you are on .NET, know what you are signing up for. Lucene.NET is a careful, faithful port and I have shipped on it, but the current release line is still the 4.8.0 betas — years of Java-side improvements not yet available, and a beta version number you have to explain to somebody. On this product it settled the question of which runtime the search tier lived on: the index was Java because Lucene is Java, and the annotator stayed .NET for reasons I wrote about a fortnight ago. Two runtimes, each one where its best library already was.
The honest summary
I did not choose Lucene because I like Java. I chose it because the list of things I would otherwise have had to write — an FST term dictionary, a bit-packed postings format with skip lists, a BKD tree, a merge policy, crash-safe commits, BM25, a highlighter that agrees with the analyser — is a career, and none of it is the thing anybody is paying for.
What is left after you step onto it is the part that is genuinely yours: the analysis chain, the fields, the synonym list, and ninety tests asserting that a chemical name still looks like itself after normalisation. That is a couple of weeks of work rather than a couple of years, and it is the couple of weeks where the domain knowledge actually lands.
Written by
Deyan Peev
Founding Engineer · Sofia, Bulgaria


