Documentation pages

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 own rule_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, not fram; 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 LexicalIndex per connection. The tables carry a fingerprint of the content-relevant features and the analyzer version; opening with other options throws LexicalIndexMismatchError, and rebuild(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_meta and two fts5vocab views next to rule_fts (derived data, rebuildable from current_version).
  • 7.1: long texts use key terms (at most 6) instead of the first 12 words; code/unit tokens.
  • 7.4: rank.a is the fused score (higher is better) instead of −bm25.
  • 7.6: found_via also 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.synonyms adds 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.

Edit on GitHub