Semantisches Such-Backend hinter der vorhandenen Registry-Grenze, statt Retrieval in wikitool einzubauen #35

Open
opened 2026-09-01 21:11:29 +00:00 by torben · 1 comment
Owner

Aus der Plattform-Debatte vom 2026-09-01. Der Bedarf: hybrides Retrieval (BM25 + Vektor,
semantische Suche) für einen Korpus in der Größenordnung wenige Tausend Seiten, weil
ripgrep allein dort nicht mehr trägt.

Die Naht existiert bereits

tools/chemenu/search/registry.py sagt es im eigenen Docstring:

One registry entry today. It exists so that adding a semantic/vector backend is a new module
plus one line here — not a change to the command, the filters, or the output shape.
Selecting several at once fuses them through RRF.

Dazu search/base.py (Backend-Protokoll, „a backend answers the text half of a query and
nothing else"), search/fuse.py (Reciprocal Rank Fusion — ausdrücklich gebaut, um lexikalische
und semantische Rankings ohne gemeinsame Score-Skala zu kombinieren) und search/ripgrep.py
(betreibt seit Tag eins einen fremdsprachigen Prozess hinter dieser Grenze). Das Suchmodul war
bei Anlage dieses Issues 613 Zeilen groß und ist am 2026-09-15 bei 825 — die Grenze selbst
(registry.py, base.py, fuse.py) ist in der Zeit unverändert geblieben, der Zuwachs liegt
im Backend und im Ausgabepfad.

Frontmatter-Prädikate bleiben in filters.py und laufen nachgelagert — ein Vektor-Backend muss
confidence>=0.8 nie kennen.

Was ein neues Backend an Ausgabevertrag erbt

Seit 6.0.0-beta.8 (#100) liegen Trefferdarstellung und Trunkierungsauskunft backend-unabhängig
in run_search/render_table, nicht im rg-Adapter. Ein semantisches Backend erbt das, statt es
zu wiederholen — und darf es nicht unterlaufen:

  • Ein Backend liefert SearchHit-Werte; die Trefferzeile
    (score | kind/subtype | titel | pfad | summary) rendert render_table daraus. Titel und
    Pfad müssen echt und vollständig sein
    — der Titel ist das Argument, das touch/xref/cite
    nehmen, der Pfad das, womit die Seite geöffnet wird. Ein Backend, das über Chunks statt über
    Seiten rankt, muss also auf die Seite zurückabbilden, bevor es einen Hit zurückgibt.
  • run_search zählt die Gesamtzahl vor dem Limit und gibt sie im SearchResult mit. Bei
    mehreren Backends ist das die Größe der fusionierten Menge, nach fuse.py — das funktioniert
    ohne Zutun, solange ein Backend seine vollständige Rangliste zurückgibt und nicht selbst kappt.
    Ein Backend, das intern ein Top-k zieht, macht total falsch, und zwar still: die Ausgabe
    würde wieder eine Vollständigkeit behaupten, die sie nicht hat. Das ist genau der Fehler, den
    #100 behoben hat, und die einzige echte neue Randbedingung hier. Wenn ein ANN- oder
    Reranker-Backend ohne Top-k nicht auskommt, muss es das im SearchResult sichtbar machen,
    statt es zu verschlucken.
  • Das Default-Limit ist eine Konstante (DEFAULT_LIMIT in search/types.py), die CLI,
    api.search und der MCP-search-Tool teilen. Kein Adapter und kein Backend bringt ein eigenes
    mit.

Das ändert nichts an der Kostenschätzung unten und an keinem Akzeptanzkriterium — es sagt nur,
was beim Bauen nicht neu erfunden wird.

Größenordnung: kein ANN-Index nötig

Überschlag für 5.000 Seiten bei ~250-Token-Chunks: grob 20.000–25.000 Chunks, bei 768 Dimensionen
float32 rund 77 MB. Ein linearer Kosinus-Scan darüber liegt im niedrigen zweistelligen
Millisekundenbereich — gegenüber 50–400 ms für das Einbetten der Query selbst.

Ein ANN-Index (HNSW o. ä.) spart also einen Bruchteil eines Vorgangs, der von etwas anderem
dominiert wird, und handelt sich Rekall-Verlust und Indexpflege ein. Brute Force reicht bei
dieser Größenordnung.
Das erledigt den größten Teil der Bibliotheksdiskussion, bevor sie
beginnt.

qmd prüfen, bevor etwas nachgebaut wird

kb/entities/tools/qmd.md beschreibt genau das gesuchte Profil. Verifiziert am 2026-09-01 gegen
https://github.com/tobi/qmd: qmd ist TypeScript/Bun und löst die Aufgabe über SQLite FTS5 +
sqlite-vec + node-llama-cpp mit GGUF-Modellen (embeddinggemma-300M, Qwen3-Reranker). Es bringt
einen MCP-Server und HTTP-Transport auf localhost:8181 mit.

Damit ist „einhängen statt nachbauen" keine Architekturskizze, sondern eine Konfigurationsfrage.
Zu prüfen, bevor entschieden wird:

  • Lizenz, Reifegrad, Release-Kadenz, Bus-Faktor des Upstreams
  • Ob das LLM-Reranking abschaltbar ist. Nicht abschaltbar wäre ein K.-o.-Kriterium: es
    kollidiert mit dem Determinismus-Anspruch dieses Stacks (AGENTS.md, „never re-derive, always
    compile") und macht dieselbe Anfrage zweimal verschieden beantwortbar.
  • Ob sich der Index auf kb/ richten lässt, ohne Annahmen über unser Frontmatter zu treffen
  • Ob es eine vollständige Rangliste herausgibt oder nur ein Top-k — siehe § Ausgabevertrag oben

Die Alternative — ein eigenes Backend in Python — ist bei Brute-Force-Kosinus überschaubar
(NumPy-Dot-Produkt über eine Matrix) und hat den Vorteil, keine Fremdabhängigkeit zu erben. Der
Nachteil ist, dass Embedding-Erzeugung, Chunking und Modellverwaltung dann selbst gebaut werden.

Was der Preis der Grenze ist, ehrlich

  • Modell-Bindung. Query und Index müssen dasselbe Embedding-Modell benutzen. Ein
    Modellwechsel bedeutet Vollreindex. Das ist echte, nicht wegdefinierbare Versionsdrift.
  • Stille Lücken. Ein veralteter Index liefert weniger, nicht falsch — aber leise. Für einen
    Wissenskompiler ist das das schlechtere Fehlerbild. Braucht einen doctor-Check:
    Index-Zeitstempel gegen jüngstes modified: im Korpus.
  • Kaltstart. rg kostet 7 ms; ein Embedding-Prozess kostet Hunderte ms bis Sekunden pro
    Start. Das verlangt einen residenten Prozess — den #19 ohnehin mitbringt.
  • Optionale Abhängigkeit. dist export und doctor müssen sie als optional führen.
    Präzedenzfall existiert: RipgrepMissing in search/ripgrep.py.
  • Ein zweites Ding zum Patchen.

Offene Fragen

  • Wo kommen die Embeddings her: lokaler Daemon (Ollama/llama-server), In-Process-Bibliothek oder
    API? Das entscheidet über Offline-Fähigkeit, Latenz und laufende Kosten.
  • Chunking-Strategie: ganze Seite, Abschnitt, oder Fenster? Der Korpus hat mit ## -Abschnitten
    eine natürliche Struktur, die ein Chunker nutzen könnte. Bei Abschnitt oder Fenster kommt die
    Rückabbildung Chunk → Seite dazu, die § Ausgabevertrag verlangt.
  • Wird der Index gitignoriert (Derivat wie reports/) oder ausgeliefert?
  • Wechselwirkung mit #6 (Backlink-boosted Ranking): beides sind Ranking-Signale, beide laufen
    durch fuse.py.

Akzeptanzkriterien

  • Entscheidung qmd einhängen vs. eigenes Backend, mit den Prüfpunkten oben belegt
  • Backend über einen Eintrag in BACKENDS (search/registry.py) erreichbar, --backend wählt es
  • Kombination mit rg über RRF funktioniert; Frontmatter-Prädikate greifen unverändert
  • Jeder Treffer trägt einen echten, vollständigen Titel und Pfad; total bleibt die Zahl der
    Treffer vor dem Limit, auch bei Fusion zweier Backends
  • Fehlt die Abhängigkeit, verhält sich wikitool wie bei fehlendem rg: klare Meldung, kein stiller Fallback
  • doctor meldet Index-Alter gegen den Korpus
  • An einem Korpus der Zielgröße gemessen, nicht am Demo-Korpus (188 Seiten bei Anlage dieses
    Issues, 182 am 2026-09-15)

Auslöser für prio/3: ein Korpus, an dem rg tatsächlich nicht mehr trägt. Vorher ist das
eine Wette auf eine Anforderung, die noch niemand gemessen hat.

Verwandt: #19 (der residente Prozess, in dem der Index lebt), #6 (Ranking-Signale).

Hinweis zum Nachzug

Am 2026-09-15 gegen den Baum geprüft und nachgezogen: § Ausgabevertrag ist neu (Folge von #100);
die Zeilenzahl des Suchmoduls und die Korpusgröße sind als Werte mit Datum stehen geblieben statt
ersetzt zu werden. Der registry.py-Zitatblock wurde gegen die Quelle geprüft und stimmt
weiterhin wörtlich; registry.py, base.py und fuse.py sind seit Anlage des Issues inhaltlich
unverändert.

Aus der Plattform-Debatte vom 2026-09-01. Der Bedarf: hybrides Retrieval (BM25 + Vektor, semantische Suche) für einen Korpus in der Größenordnung **wenige Tausend Seiten**, weil `ripgrep` allein dort nicht mehr trägt. ## Die Naht existiert bereits `tools/chemenu/search/registry.py` sagt es im eigenen Docstring: > One registry entry today. It exists so that adding a semantic/vector backend is a new module > plus one line here — not a change to the command, the filters, or the output shape. > Selecting several at once fuses them through RRF. Dazu `search/base.py` (Backend-Protokoll, „a backend answers the *text* half of a query and nothing else"), `search/fuse.py` (Reciprocal Rank Fusion — ausdrücklich gebaut, um lexikalische und semantische Rankings ohne gemeinsame Score-Skala zu kombinieren) und `search/ripgrep.py` (betreibt seit Tag eins einen fremdsprachigen Prozess hinter dieser Grenze). Das Suchmodul war bei Anlage dieses Issues 613 Zeilen groß und ist am 2026-09-15 bei 825 — die Grenze selbst (`registry.py`, `base.py`, `fuse.py`) ist in der Zeit unverändert geblieben, der Zuwachs liegt im Backend und im Ausgabepfad. Frontmatter-Prädikate bleiben in `filters.py` und laufen nachgelagert — ein Vektor-Backend muss `confidence>=0.8` nie kennen. ## Was ein neues Backend an Ausgabevertrag erbt Seit `6.0.0-beta.8` (#100) liegen Trefferdarstellung und Trunkierungsauskunft **backend-unabhängig** in `run_search`/`render_table`, nicht im `rg`-Adapter. Ein semantisches Backend erbt das, statt es zu wiederholen — und darf es nicht unterlaufen: - Ein Backend liefert `SearchHit`-Werte; die Trefferzeile (`score | kind/subtype | titel | pfad | summary`) rendert `render_table` daraus. **Titel und Pfad müssen echt und vollständig sein** — der Titel ist das Argument, das `touch`/`xref`/`cite` nehmen, der Pfad das, womit die Seite geöffnet wird. Ein Backend, das über Chunks statt über Seiten rankt, muss also auf die Seite zurückabbilden, bevor es einen Hit zurückgibt. - `run_search` zählt die Gesamtzahl **vor** dem Limit und gibt sie im `SearchResult` mit. Bei mehreren Backends ist das die Größe der fusionierten Menge, nach `fuse.py` — das funktioniert ohne Zutun, solange ein Backend seine vollständige Rangliste zurückgibt und nicht selbst kappt. **Ein Backend, das intern ein Top-k zieht, macht `total` falsch**, und zwar still: die Ausgabe würde wieder eine Vollständigkeit behaupten, die sie nicht hat. Das ist genau der Fehler, den #100 behoben hat, und die einzige echte neue Randbedingung hier. Wenn ein ANN- oder Reranker-Backend ohne Top-k nicht auskommt, muss es das im `SearchResult` sichtbar machen, statt es zu verschlucken. - Das Default-Limit ist eine Konstante (`DEFAULT_LIMIT` in `search/types.py`), die CLI, `api.search` und der MCP-`search`-Tool teilen. Kein Adapter und kein Backend bringt ein eigenes mit. Das ändert nichts an der Kostenschätzung unten und an keinem Akzeptanzkriterium — es sagt nur, was beim Bauen nicht neu erfunden wird. ## Größenordnung: kein ANN-Index nötig Überschlag für 5.000 Seiten bei ~250-Token-Chunks: grob 20.000–25.000 Chunks, bei 768 Dimensionen float32 rund **77 MB**. Ein linearer Kosinus-Scan darüber liegt im niedrigen zweistelligen Millisekundenbereich — gegenüber 50–400 ms für das Einbetten der Query selbst. Ein ANN-Index (HNSW o. ä.) spart also einen Bruchteil eines Vorgangs, der von etwas anderem dominiert wird, und handelt sich Rekall-Verlust und Indexpflege ein. **Brute Force reicht bei dieser Größenordnung.** Das erledigt den größten Teil der Bibliotheksdiskussion, bevor sie beginnt. ## `qmd` prüfen, bevor etwas nachgebaut wird `kb/entities/tools/qmd.md` beschreibt genau das gesuchte Profil. Verifiziert am 2026-09-01 gegen <https://github.com/tobi/qmd>: qmd ist **TypeScript/Bun** und löst die Aufgabe über SQLite FTS5 + sqlite-vec + node-llama-cpp mit GGUF-Modellen (embeddinggemma-300M, Qwen3-Reranker). Es bringt einen **MCP-Server** und HTTP-Transport auf `localhost:8181` mit. Damit ist „einhängen statt nachbauen" keine Architekturskizze, sondern eine Konfigurationsfrage. Zu prüfen, bevor entschieden wird: - Lizenz, Reifegrad, Release-Kadenz, Bus-Faktor des Upstreams - Ob das **LLM-Reranking abschaltbar** ist. Nicht abschaltbar wäre ein K.-o.-Kriterium: es kollidiert mit dem Determinismus-Anspruch dieses Stacks (`AGENTS.md`, „never re-derive, always compile") und macht dieselbe Anfrage zweimal verschieden beantwortbar. - Ob sich der Index auf `kb/` richten lässt, ohne Annahmen über unser Frontmatter zu treffen - Ob es eine vollständige Rangliste herausgibt oder nur ein Top-k — siehe § Ausgabevertrag oben Die Alternative — ein eigenes Backend in Python — ist bei Brute-Force-Kosinus überschaubar (NumPy-Dot-Produkt über eine Matrix) und hat den Vorteil, keine Fremdabhängigkeit zu erben. Der Nachteil ist, dass Embedding-Erzeugung, Chunking und Modellverwaltung dann selbst gebaut werden. ## Was der Preis der Grenze ist, ehrlich - **Modell-Bindung.** Query und Index müssen dasselbe Embedding-Modell benutzen. Ein Modellwechsel bedeutet Vollreindex. Das ist echte, nicht wegdefinierbare Versionsdrift. - **Stille Lücken.** Ein veralteter Index liefert weniger, nicht falsch — aber leise. Für einen Wissenskompiler ist das das schlechtere Fehlerbild. Braucht einen `doctor`-Check: Index-Zeitstempel gegen jüngstes `modified:` im Korpus. - **Kaltstart.** `rg` kostet 7 ms; ein Embedding-Prozess kostet Hunderte ms bis Sekunden pro Start. Das verlangt einen residenten Prozess — den #19 ohnehin mitbringt. - **Optionale Abhängigkeit.** `dist export` und `doctor` müssen sie als optional führen. Präzedenzfall existiert: `RipgrepMissing` in `search/ripgrep.py`. - **Ein zweites Ding zum Patchen.** ## Offene Fragen - Wo kommen die Embeddings her: lokaler Daemon (Ollama/llama-server), In-Process-Bibliothek oder API? Das entscheidet über Offline-Fähigkeit, Latenz und laufende Kosten. - Chunking-Strategie: ganze Seite, Abschnitt, oder Fenster? Der Korpus hat mit `## `-Abschnitten eine natürliche Struktur, die ein Chunker nutzen könnte. Bei Abschnitt oder Fenster kommt die Rückabbildung Chunk → Seite dazu, die § Ausgabevertrag verlangt. - Wird der Index gitignoriert (Derivat wie `reports/`) oder ausgeliefert? - Wechselwirkung mit #6 (Backlink-boosted Ranking): beides sind Ranking-Signale, beide laufen durch `fuse.py`. ## Akzeptanzkriterien - [ ] Entscheidung `qmd` einhängen vs. eigenes Backend, mit den Prüfpunkten oben belegt - [ ] Backend über einen Eintrag in `BACKENDS` (`search/registry.py`) erreichbar, `--backend` wählt es - [ ] Kombination mit `rg` über RRF funktioniert; Frontmatter-Prädikate greifen unverändert - [ ] Jeder Treffer trägt einen echten, vollständigen Titel und Pfad; `total` bleibt die Zahl der Treffer vor dem Limit, auch bei Fusion zweier Backends - [ ] Fehlt die Abhängigkeit, verhält sich `wikitool` wie bei fehlendem `rg`: klare Meldung, kein stiller Fallback - [ ] `doctor` meldet Index-Alter gegen den Korpus - [ ] An einem Korpus der Zielgröße gemessen, nicht am Demo-Korpus (188 Seiten bei Anlage dieses Issues, 182 am 2026-09-15) **Auslöser für `prio/3`:** ein Korpus, an dem `rg` tatsächlich nicht mehr trägt. Vorher ist das eine Wette auf eine Anforderung, die noch niemand gemessen hat. **Verwandt:** #19 (der residente Prozess, in dem der Index lebt), #6 (Ranking-Signale). ## Hinweis zum Nachzug Am 2026-09-15 gegen den Baum geprüft und nachgezogen: § Ausgabevertrag ist neu (Folge von #100); die Zeilenzahl des Suchmoduls und die Korpusgröße sind als Werte mit Datum stehen geblieben statt ersetzt zu werden. Der `registry.py`-Zitatblock wurde gegen die Quelle geprüft und stimmt weiterhin wörtlich; `registry.py`, `base.py` und `fuse.py` sind seit Anlage des Issues inhaltlich unverändert.
torben added the prio/waitingsize/M labels 2026-09-01 21:11:29 +00:00
torben added the area/kbkind/decision labels 2026-09-02 21:24:45 +00:00
Author
Owner

Changelog: Nachzug nach #100 (6.0.0-beta.8). Neu: § „Was ein neues Backend an Ausgabevertrag erbt" — Trefferzeile und Trunkierungsauskunft liegen jetzt backend-unabhängig in run_search/render_table. Die eine echte neue Randbedingung: ein Backend, das intern ein Top-k zieht, macht total still falsch; wer Chunks statt Seiten rankt, muss vor der Rückgabe auf die Seite zurückabbilden. Daraus je ein Punkt in § Offene Fragen (Chunking), § qmd prüfen (gibt es eine vollständige Rangliste her?) und ein neues Akzeptanzkriterium.

Korrigiert: „das gesamte Suchmodul sind 613 Zeilen" war schon vor #100 veraltet (772) und steht jetzt als datiertes Wertepaar 613 (Anlage) / 825 (2026-09-15), ebenso die Demo-Korpusgröße 188 → 182. Der registry.py-Zitatblock ist gegen die Quelle geprüft und weiterhin wörtlich korrekt; registry.py, base.py und fuse.py sind inhaltlich unverändert, die Grenze trägt also noch.

Keine Entscheidung, kein Label geändert — #35 bleibt kind/decision/prio/waiting, der Auslöser ist unverändert ein Korpus, an dem rg nicht mehr trägt. #6 wurde in derselben Prüfung angesehen und brauchte nichts: es zitiert fuse.pys Sortierschlüssel, der unverändert ist.

**Changelog:** Nachzug nach #100 (`6.0.0-beta.8`). Neu: § „Was ein neues Backend an Ausgabevertrag erbt" — Trefferzeile und Trunkierungsauskunft liegen jetzt backend-unabhängig in `run_search`/`render_table`. Die eine echte neue Randbedingung: ein Backend, das intern ein Top-k zieht, macht `total` still falsch; wer Chunks statt Seiten rankt, muss vor der Rückgabe auf die Seite zurückabbilden. Daraus je ein Punkt in § Offene Fragen (Chunking), § qmd prüfen (gibt es eine vollständige Rangliste her?) und ein neues Akzeptanzkriterium. Korrigiert: „das gesamte Suchmodul sind 613 Zeilen" war schon vor #100 veraltet (772) und steht jetzt als datiertes Wertepaar 613 (Anlage) / 825 (2026-09-15), ebenso die Demo-Korpusgröße 188 → 182. Der `registry.py`-Zitatblock ist gegen die Quelle geprüft und weiterhin wörtlich korrekt; `registry.py`, `base.py` und `fuse.py` sind inhaltlich unverändert, die Grenze trägt also noch. Keine Entscheidung, kein Label geändert — #35 bleibt `kind/decision`/`prio/waiting`, der Auslöser ist unverändert ein Korpus, an dem `rg` nicht mehr trägt. #6 wurde in derselben Prüfung angesehen und brauchte nichts: es zitiert `fuse.py`s Sortierschlüssel, der unverändert ist.
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: torben/chemenu#35