Lexical search (stage a)#
Stage a is the search stage that always runs: local, offline, deterministic and free. Stages b
(vectors) and c (reranker) only reorder or add to what it finds, so its recall bounds the whole
search. This module (src/search/lexical/) replaces steps 1–4 of the stage-a contract
(architecture section 7) with measures that can each
be switched on and off. With all switches off it reproduces the contract (golden test).
Recommendation#
| Measure | Default | Why (numbers below) |
|---|---|---|
keyterms — key terms from long texts |
on | Largest single gain on real data (R@10 0.63 → 0.76 alone); long mails 0.19 → 0.56 R@10 on the held-out set. |
synonyms — base list + scope list |
on | Largest gain on invented data (held-out R@50 0.73 → 0.85, cross-lingual queries); neutral on real data. |
validity — bonus current, penalty superseded |
on | Superseded version ranked above its successor: 86 % → 7 % of validity queries (held-out). |
decompound — German compound parts |
on | Held-out MRR 0.77 → 0.80, compound queries MRR 0.86 → 1.00; real R@10 +0.05 alone. |
fuzzy — typo correction against the vocabulary |
on | Typo queries R@10 0.80 → 0.90 against default without it (held-out); costs < 0.5 ms. |
stem — Snowball de + en |
on | Neutral on own, held-out and real data, but JEV pairs R@10 0.76 → 0.82 (English inflection). |
normalize — codes, units, ß/ae, hyphens |
on | Small (code queries MRR +0.10 on the own set); cheap. |
| score fusion (not RRF), field weights 4/3/2/1 | on | RRF lost 0.05–0.08 MRR on four of five sets (equal on JEV rerank); field weights hardly matter. |
trigram — FTS5 substring index |
off | No gain once fuzzy is separate; +30 MB per 20k rules and the main latency cost. |
prf — pseudo-relevance feedback |
off | No gain; MRR −0.02 on its own. |
| abstention threshold | off, expose signal | Every threshold that stops false alarms costs 38–50 % of the answerable queries (table below). Use matchedTerms ≥ 3 only where a false hit is worse than none; better let stage c decide. |
Defaults are DEFAULT_FEATURES in options.ts; CONTRACT_FEATURES is the contract behaviour.
Results#
Run pnpm eval:lexical (all invented sets) and pnpm eval:lexical -- --scale 20000 (latency and
size). Last output: eval/lexical/results/latest.md.
A local real-data set can be added with -- --real <file>; nothing is then written to disk.
Test sets#
| Set | Rules | Queries | Origin | Independent of tuning? |
|---|---|---|---|---|
| JEV rerank | 50 | 5 | from feat/jev-eval |
no: read before the synonym list was written |
| JEV pairs | 72 | 72 | from feat/jev-eval |
no (same) |
| own | 300 (225 de, 75 en; 17 superseded, 11 proposals) | 180 in 11 categories | written by a separate agent that never saw the code | no: defaults were tuned on it |
| held-out | same 300 | 137 in 11 categories | written by another agent after tuning, without code, results or the own queries | yes |
| real (local, hardening) | 74 in 3 scopes | 52 (12 without answer) | anonymised real requests, never in the repo | yes |
Metrics: R@k = share of relevant rules (grade ≥ 1) in the first k; MRR of the first relevant; nDCG@5 with grades 1–2; false alarms = share of no-answer queries whose first hit has coverage ≥ 0.5; stale first = share of validity queries where a superseded rule ranks above the best relevant one. Proposals are not in the pool (contract default).
Per measure — held-out set (not used for tuning), 300 rules, 137 queries#
| Configuration | R@10 | R@50 | MRR | nDCG@5 | False alarms | Stale first |
|---|---|---|---|---|---|---|
| contract (baseline) | 0.67 | 0.73 | 0.66 | 0.62 | 0.17 | 0.86 |
| + normalize | 0.66 | 0.73 | 0.66 | 0.62 | 0.17 | 0.86 |
| + stem | 0.67 | 0.73 | 0.66 | 0.61 | 0.25 | 0.86 |
| + decompound | 0.68 | 0.74 | 0.69 | 0.63 | 0.25 | 0.86 |
| + trigram | 0.68 | 0.74 | 0.69 | 0.64 | 0.17 | 0.79 |
| + fuzzy | 0.68 | 0.74 | 0.69 | 0.64 | 0.17 | 0.86 |
| + synonyms | 0.74 | 0.85 | 0.70 | 0.64 | 0.17 | 0.79 |
| + validity | 0.67 | 0.73 | 0.71 | 0.65 | 0.17 | 0.14 |
| + keyterms | 0.69 | 0.74 | 0.70 | 0.64 | 0.17 | 0.86 |
| + prf | 0.67 | 0.74 | 0.64 | 0.61 | 0.17 | 0.86 |
| default | 0.75 | 0.88 | 0.80 | 0.74 | 0.25 | 0.07 |
| default − normalize | 0.74 | 0.88 | 0.80 | 0.74 | 0.25 | 0.07 |
| default − stem | 0.75 | 0.87 | 0.81 | 0.74 | 0.25 | 0.07 |
| default − decompound | 0.73 | 0.87 | 0.77 | 0.69 | 0.17 | 0.14 |
| default + trigram | 0.75 | 0.89 | 0.80 | 0.73 | 0.25 | 0.07 |
| default − fuzzy | 0.74 | 0.87 | 0.79 | 0.73 | 0.25 | 0.07 |
| default − synonyms | 0.73 | 0.78 | 0.77 | 0.71 | 0.25 | 0.21 |
| default − validity | 0.75 | 0.88 | 0.74 | 0.70 | 0.25 | 0.86 |
| default − keyterms | 0.71 | 0.86 | 0.77 | 0.70 | 0.25 | 0.07 |
| default + prf | 0.75 | 0.88 | 0.81 | 0.74 | 0.25 | 0.00 |
| default, RRF fusion | 0.73 | 0.85 | 0.73 | 0.69 | 0.17 | 0.00 |
| default, equal field weights | 0.76 | 0.88 | 0.80 | 0.74 | 0.25 | 0.07 |
Held-out by category (R@10 / MRR), baseline → default: code 0.93/0.94 → 0.96/0.94; compound 0.87/0.86 → 0.87/1.00; cross-lingual 0.29/0.21 → 0.36/0.31; direct 0.79/0.96 → 0.83/0.96; English 0.85/0.88 → 0.92/1.00; inflection 0.75/0.81 → 0.80/0.76; long mail 0.19/0.40 → 0.56/0.76; paraphrase 0.50/0.42 → 0.54/0.61; typo 0.77/0.72 → 0.90/0.88; validity 0.88/0.56 → 0.88/0.93.
All sets, baseline → default#
| Set | R@10 | R@50 | MRR | nDCG@5 | False alarms |
|---|---|---|---|---|---|
| JEV rerank (5 queries) | 0.23 → 0.77 | 0.23 → 0.80 | 0.40 → 0.55 | 0.19 → 0.48 | – |
| JEV pairs (72) | 0.64 → 0.82 | 0.64 → 0.82 | 0.58 → 0.67 | 0.60 → 0.69 | 0.10 → 0.13 |
| own (180, tuned on) | 0.69 → 0.81 | 0.75 → 0.91 | 0.69 → 0.84 | 0.64 → 0.75 | 0.00 → 0.06 |
| held-out (137) | 0.67 → 0.75 | 0.73 → 0.88 | 0.66 → 0.80 | 0.62 → 0.74 | 0.17 → 0.25 |
| real, local (52) | 0.63 → 0.80 | 0.63 → 0.83 | 0.55 → 0.64 | 0.53 → 0.63 | 0.00 → 0.17 |
Real data, R@5: 0.59 → 0.70 (keyterms alone 0.73). Forbidden rules in the top 5 (20 queries name rules that must not appear): 0.30 → 0.40 — a better pool brings more near neighbours; stage c or the filter has to sort them out. JEV pair verdicts (paired rule in the top 10 of 72): tricky German pairs 0.14 → 0.29, tricky English 0 → 0, unrelated German 1.00 → 0.93; the paraphrase pairs stay stage c's job.
Abstention (asked for by hardening: no-answer queries always returned hits)#
Stage a would return nothing when its first hit is too weak. Two signals were measured: coverage
(IDF share of the query terms the hit matches) and matchedTerms (count of matched terms).
| Set | Rule | False alarms (no-answer with a hit) | Answerable that abstain | Relevant first hit kept |
|---|---|---|---|---|
| held-out | none | 1.00 | 0.00 | 0.74 |
| held-out | coverage ≥ 0.5 | 0.25 | 0.39 | 0.56 |
| held-out | coverage ≥ 0.7 | 0.00 | 0.66 | 0.34 |
| held-out | matchedTerms ≥ 3 | 0.08 | 0.50 | 0.48 |
| real (local) | none | 1.00 | 0.00 | 0.50 |
| real (local) | coverage ≥ 0.5 | 0.17 | 0.65 | 0.23 |
| real (local) | matchedTerms ≥ 3 | 0.00 | 0.38 | 0.40 |
A lexical threshold cannot tell "no rule" from "rule in other words": long texts match few of their
terms even when the rule is right. Recommendation: no abstention in stage a by default; return
coverage and matchedTerms with every candidate (done) so core can label weak results, and let
stage c abstain. For a stage-a-only setup that must not answer wrongly, matchedTerms ≥ 3 is the
best measured rule.
Latency, size, memory at 20,000 rules#
Synthetic rules built from the 300 (halves of two statements plus invented compounds, names and
article numbers, so the vocabulary grows to ~98,000 words), file database in WAL mode, all 180 own
queries including 20 long mails, fastest of three runs per query. Apple M5 Pro shared with other
work (load average 6–16): performance cores, and efficiency cores (taskpolicy -b) as a stand-in
for a small server.
| Configuration | p50 ms | p95 ms | p95 long mails ms | CPU p95 ms | first search after a write ms | lex_* tables MB |
JS heap MB |
|---|---|---|---|---|---|---|---|
| contract, performance cores | 2.1 | 6.5 | 9.5 | 6.5 | 3.6 | 13.8 | 28 |
| default, performance cores | 5.7 | 17.2 | 21.4 | 17.2 | 7.2 | 33.2 | 37 |
| default + trigram, performance cores | 6.5 | 18.8 | 23.9 | 18.8 | 6.9 | 63.4 | 36 |
| contract, efficiency cores | 5.1 | 14.7 | 29.8 | 14.3 | 6.4 | 13.8 | 29 |
| default, efficiency cores | 11.2 | 37.8 | 50.6 | 28.4 | 25.6 | 33.2 | 38 |
| default + trigram, efficiency cores | 15.5 | 45.7 | 54.9 | 43.4 | 25.9 | 63.4 | 36 |
- p95 < 50 ms holds for the default on both core types; long mails on efficiency cores are at the limit in wall time (other load on the machine) and at 33 ms in CPU time.
- Memory: the vocabulary cache (word and stem counts, bigram index for typo candidates) is the JS
heap above: about 37 MB at 98,000 words. Disk: 33 MB for the
lex_*tables (1.7 KB per rule), plus core's ownrule_fts. - Build: ~10 s for 20,000 rules in one transaction (performance core); per rule well under 1 ms.
- Writes do not reload the vocabulary: only the counts of the touched terms are re-read (also correct after a rollback), so the first search after a write costs 7 ms instead of ~50 ms.
How it works#
text / terms ──► terms (7.1) ──┬─► word : contract query, prefix variants (unicode61) ─┐
key terms for long texts ├─► stem : Snowball de+en stems, compound parts │
code/unit tokens ├─► fuzzy : edit-distance corrections against the vocabulary ├─► score fusion ─► validity ─► pool (200)
├─► syn : other members of matching synonym groups │ weighting found_via, coverage
├─► tri : substrings of rare words, trigram index (off) │
└─► prf : terms of the first hits (off) ─┘
Every sub-query is an FTS5 MATCH built only from quoted literals ("…" with inner quotes
doubled, optional *); control characters are replaced first (NUL would end an FTS5 string). FTS
syntax in user text (", NEAR, *, :, AND, ^) is inert (tested for text and terms).
Each sub-query returns its bm25 top 200 with field weights keywords 4, statement 3, relations 2,
quote 1, restricted to confirmed/superseded (plus proposed with includeProposals). Score
fusion adds, per sub-query, weight × bm25 / best bm25 (weights word 1, stem 1, syn 0.6, fuzzy 0.5,
tri 0.5, prf 0.3); ties break by seq, so results are deterministic.
| Switch | What it does |
|---|---|
normalize |
Folds ß → ss, adds hyphen joins (Wärmepumpen-Angebot → warmepumpenangebot), ae/oe/ue spellings for ä/ö/ü (only after a consonant other than q, so neue, Feuer, queue stay), and canonical tokens for codes and units: RAL 9005/RAL-9005/ral9005 → ral9005, VDE 0100-410 → vde0100410, DIN EN 1838 → din1838 + en1838, ART-44821 → art44821, 11 kW/11kW/11 Kilowatt → 11kw, 1,5 bar → 1p5bar, 4.180 € → 4180eur, 30 %/30 Prozent → 30pct. |
stem |
Indexes the German and the English Snowball stem of every word in a second FTS5 table (a rule's language is not stored, so both are kept). |
decompound |
Splits German compounds with linking elements (-s-, -es-, -n-, -en-, -er-, -e-, -ens-) and inflection endings, using a hand-written lexicon of about 690 base words and verb stems; at query time the index vocabulary is added, so compounds of rule words split too. Words over 40 letters are not split (bounded cost). Wärmepumpenförderantrag → warme pumpe forder antrag. German stop words and a short prefix list (ab, los, voll, frei, halb) are never a part of their own: their compounds mean something else than the parts (Vollmacht is not voll + macht, Vollzug not voll + Zug, Halbleiter not halb + Leiter), and as parts they would match every rule using the word (practice finding P15). Such a prefix stays glued to the word after it, so Überspannungsschutz → uberspannung schutz, while Vollmacht, Vollzug, Mehrkosten, Unterverteiler stay whole. Measured against the previous split: own set MRR 0.83 → 0.84, held-out and the compound category unchanged (R@10 0.84 / 0.87, MRR 0.89 / 1.00), false alarms unchanged (0.06 / 0.25). A minimum length or a lexicon duty for both parts is not needed for this: the wrong parts came from the stop words and prefixes, and index-vocabulary parts already need 4 letters. |
fuzzy |
Query words that no indexed word starts with are corrected by restricted Damerau-Levenshtein distance (1 edit for 5–7 letters, 2 from 8), at most 8 words per search. Candidates come from a bigram index (the 3d + 1 rarest bigrams, then the full q-gram count), not from a scan. |
trigram |
FTS5 trigram table over the folded text; substring queries for at most 6 words that occur in at most 0.5 % of the rules. |
synonyms |
Hand-written base list (136 groups, German and English mixed, so it also bridges languages) plus the scope's search.synonyms. Words match on their folded form minus one plural ending (not on Snowball stems, which merge "zahlen" and "Zähler"). Only groups anchored on a selected term expand, so a mail's small talk does not. |
validity |
Multiplies the fused score: superseded or expired × 0.5, not yet valid × 0.8, proposal × 0.95, up to + 5 % for a recent valid_from. |
keyterms |
For texts with more than 25 content words: drops the mail frame (greeting, small talk, sign-off), keeps indexed codes, ranks words by IDF, repetition and capitalization; unknown nouns count only if they split into known parts (names drop out); words in more than 20 % of the rules are dropped; at most 6 terms (10 measured no better). |
prf |
Pseudo-relevance feedback: the 5 most characteristic terms of the first 3 hits as one more sub-query. |
Each candidate carries ranks (rank per sub-query), foundVia (contract step 6, see below),
coverage and matchedTerms (see abstention).
foundVia names words, never bare stems, so it reads as an explanation and not as a typo:
- a query word that matched by itself, its inflection, stem, ä/ae spelling or a substring appears
as the query spells it (
frame→frame, notfram;Zählerschränke→zählerschränke); - a compound part appears as the query word spells it (
Wärmepumpenförderantrag→antrag,förder,wärmepumpen), but only when the whole word did not match the rule; - a typo correction appears as the corrected index word (
Walbox→wallbox), again only when the query word did not match by itself; the index stores folded text, so a correction carries no umlauts (zahlerschrank); - synonym expansions and configured synonyms stay as the phrase (
Ladestation→wallbox); - given multi-word terms stay phrases (
wallbox installation).
The list is sorted and free of duplicates; it is display only and does not change ranking or coverage.
Learned synonyms (suggestions only)#
suggestSynonyms(db) compares quote and statement of every confirmed rule: words of the quote (how
people say it) that the statement expresses differently (how the rule says it). Pairs seen in at
least two rules with NPMI ≥ 0.3 are returned with support and example rules; inflections,
participles and compounds of the same word are filtered out. Nothing is applied: a person adds a
pair to the scope's search.synonyms. On the 300-rule set about one in six of the top 25
suggestions is useful (e.g. "extra" → "Aufpreis", "Propan" → "Kältemittel"); the method needs more
rules than that to be precise, so it stays a suggestion list for review.
Interface for core#
import { LexicalIndex } from "./search/lexical/index.js";
const lexical = new LexicalIndex(db, { synonyms: config.search.synonyms, languages: config.search.languages });
// in the transaction that writes a rule, next to reindexRule():
lexical.upsert({ seq, ...ftsRow(current), status, validFrom, validUntil }); // rejected → lexical.remove(seq)
// stage a, per scope:
const { terms, variants, candidates } = lexical.search({ text, terms, today, includeProposals });
// candidates: [{ seq, score, coverage, matchedTerms, foundVia, ranks }], best first, ≤ 200
- One
LexicalIndexper connection. The tables carry a fingerprint of the content-relevant features and the analyzer version; opening with other options throwsLexicalIndexMismatchError, andrebuild(docs)re-indexes in one transaction. - Core keeps steps 5–9 (filter, successor replacement, dedupe, cross-scope interleave, log).
Contract changes this needs (in architecture.md first, by core):
- 4.1: tables
lex_word,lex_stem,lex_tri,lex_doc,lex_metaand twofts5vocabviews next torule_fts(derived data, rebuildable fromcurrent_version). - 7.1: long texts use key terms (at most 6) instead of the first 12 words; code/unit tokens.
- 7.4:
rank.ais the fused score (higher is better) instead of−bm25. - 7.6:
found_viaalso lists compound parts, corrections and synonym phrases; stem and spelling matches name the query word, not the stem. - 3: the base synonym list ships with the code;
search.synonymsadds to it.
Licenses#
| Component | License | Use |
|---|---|---|
snowball-stemmers 0.6.0 (npm, no dependencies) |
ISC; port of Snowball (BSD 3-Clause) | runtime dependency, attribution in NOTICE |
SQLite FTS5 unicode61, trigram, fts5vocab |
public domain | via better-sqlite3 (MIT), already a core dependency |
| German lexicon, stop words, synonym base list, mail words | Apache-2.0, written for Groundnote | no third-party word list |
Not used, and why: OpenThesaurus (database dump LGPL 2.1, not for bundling in an Apache project),
Open German WordNet / OdeNet (CC BY-SA 4.0, share-alike), GermaNet (research licence), fastText
vectors (CC BY-SA 3.0), hunspell de_DE / igerman98 (GPL/LGPL). Princeton WordNet's licence is
permissive, but it is English only and the measured gaps are German compounds and long texts.
Small word vectors and co-occurrence expansion were not built: pseudo-relevance feedback (the cheap
co-occurrence variant) gave nothing, and every vector set with a fitting licence is English-only or
share-alike.
Limits#
- Invented data, written by agents; the defaults were tuned on the own set. The held-out set and the 52 real queries are the honest numbers.
- Paraphrases without a shared word and cross-lingual questions outside the synonym list stay weak (held-out cross-lingual R@10 0.36): that is stage b/c's job.
- Latency comes from one shared machine; efficiency cores are a stand-in, not a measured server.