@cachly-dev/mcp-server 0.10.124 → 0.10.125

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (85) hide show
  1. package/dist/index.d.ts +1 -0
  2. package/dist/index.js +7 -0
  3. package/dist/index.js.map +1 -0
  4. package/dist/src/aussetzer.d.ts +67 -0
  5. package/dist/src/aussetzer.js +82 -0
  6. package/dist/src/aussetzer.js.map +1 -0
  7. package/dist/src/autor-abzeichen.d.ts +41 -0
  8. package/dist/src/autor-abzeichen.js +56 -0
  9. package/dist/src/autor-abzeichen.js.map +1 -0
  10. package/dist/src/autor.d.ts +101 -0
  11. package/dist/src/autor.js +136 -0
  12. package/dist/src/autor.js.map +1 -0
  13. package/dist/src/bauabdruck-daten.generiert.json +5 -0
  14. package/dist/src/bauabdruck.d.ts +38 -0
  15. package/dist/src/bauabdruck.js +93 -0
  16. package/dist/src/bauabdruck.js.map +1 -0
  17. package/dist/src/bedeutung.d.ts +175 -0
  18. package/dist/src/bedeutung.js +254 -0
  19. package/dist/src/bedeutung.js.map +1 -0
  20. package/dist/src/brain-export.d.ts +24 -0
  21. package/dist/src/brain-export.js +31 -1
  22. package/dist/src/brain-export.js.map +1 -1
  23. package/dist/src/deutsch.d.ts +98 -0
  24. package/dist/src/deutsch.js +223 -0
  25. package/dist/src/deutsch.js.map +1 -0
  26. package/dist/src/eingaenge-nachruesten.d.ts +41 -0
  27. package/dist/src/eingaenge-nachruesten.js +290 -0
  28. package/dist/src/eingaenge-nachruesten.js.map +1 -0
  29. package/dist/src/eingaenge.d.ts +135 -0
  30. package/dist/src/eingaenge.js +228 -0
  31. package/dist/src/eingaenge.js.map +1 -0
  32. package/dist/src/geliefert.d.ts +63 -0
  33. package/dist/src/geliefert.js +114 -0
  34. package/dist/src/geliefert.js.map +1 -0
  35. package/dist/src/handlers/advanced.js +7 -3
  36. package/dist/src/handlers/advanced.js.map +1 -1
  37. package/dist/src/handlers/brain.d.ts +19 -0
  38. package/dist/src/handlers/brain.js +492 -48
  39. package/dist/src/handlers/brain.js.map +1 -1
  40. package/dist/src/handlers/instances.d.ts +24 -0
  41. package/dist/src/handlers/instances.js +49 -1
  42. package/dist/src/handlers/instances.js.map +1 -1
  43. package/dist/src/handlers/team.js +105 -0
  44. package/dist/src/handlers/team.js.map +1 -1
  45. package/dist/src/index.d.ts +20 -0
  46. package/dist/src/index.js +272 -26
  47. package/dist/src/index.js.map +1 -1
  48. package/dist/src/lesson-preview.d.ts +81 -0
  49. package/dist/src/lesson-preview.js +133 -0
  50. package/dist/src/lesson-preview.js.map +1 -0
  51. package/dist/src/rangfolge.d.ts +169 -0
  52. package/dist/src/rangfolge.js +214 -0
  53. package/dist/src/rangfolge.js.map +1 -0
  54. package/dist/src/raumkorrektur.d.ts +100 -0
  55. package/dist/src/raumkorrektur.js +197 -0
  56. package/dist/src/raumkorrektur.js.map +1 -0
  57. package/dist/src/recall-tiefe.d.ts +48 -0
  58. package/dist/src/recall-tiefe.js +42 -0
  59. package/dist/src/recall-tiefe.js.map +1 -0
  60. package/dist/src/rerank.d.ts +0 -14
  61. package/dist/src/rerank.js +39 -2
  62. package/dist/src/rerank.js.map +1 -1
  63. package/dist/src/search.d.ts +56 -17
  64. package/dist/src/search.js +335 -73
  65. package/dist/src/search.js.map +1 -1
  66. package/dist/src/seltenheitsbestand.d.ts +53 -0
  67. package/dist/src/seltenheitsbestand.js +92 -0
  68. package/dist/src/seltenheitsbestand.js.map +1 -0
  69. package/dist/src/spuren.d.ts +97 -0
  70. package/dist/src/spuren.js +150 -0
  71. package/dist/src/spuren.js.map +1 -0
  72. package/dist/src/tools.js +1 -1
  73. package/dist/src/tools.js.map +1 -1
  74. package/dist/src/vorspann.d.ts +73 -0
  75. package/dist/src/vorspann.js +146 -0
  76. package/dist/src/vorspann.js.map +1 -0
  77. package/dist/src/werkzeug-nutzung.d.ts +62 -0
  78. package/dist/src/werkzeug-nutzung.js +72 -0
  79. package/dist/src/werkzeug-nutzung.js.map +1 -0
  80. package/dist/src/wertbeitrag.d.ts +57 -0
  81. package/dist/src/wertbeitrag.js +73 -0
  82. package/dist/src/wertbeitrag.js.map +1 -0
  83. package/glama.json +1 -1
  84. package/package.json +12 -4
  85. package/server.json +2 -2
@@ -597,6 +597,7 @@ function preprocessText(text) {
597
597
  // so "deploy" finds documents containing "デプロイ" / "部署" / "배포", and vice versa.
598
598
  //
599
599
  // Keys must be lowercase. CJK values will be bigram-expanded at use time.
600
+ import { ohneUmlaute, deutscherStamm, ohneRueckumlaut, zerlegeKompositum, DEUTSCHE_FACHWOERTER, } from './deutsch.js';
600
601
  const CROSS_LINGUAL_MAP = new Map([
601
602
  // ── Tech / Ecosystem synonyms (language-agnostic) ─────────────────────────
602
603
  ['javascript', ['js', 'node', 'nodejs', 'ecmascript']],
@@ -1278,8 +1279,45 @@ const CROSS_LINGUAL_MAP = new Map([
1278
1279
  * Returns synonyms to be added to the token stream (pre-tokenized).
1279
1280
  * CJK synonyms are NOT pre-bigram'd here — caller handles that.
1280
1281
  */
1282
+ /**
1283
+ * Die deutschen Eintraege werden NACH dem Aufbau der Haupttabelle ergaenzt und
1284
+ * dabei umlautfrei geschrieben.
1285
+ *
1286
+ * Warum umlautfrei: die vorhandenen deutschen Eintraege standen mit echten
1287
+ * Umlauten (`loesung` als `lösung`), waehrend Lektionen und Fragen oft `ae/oe/ue`
1288
+ * tippen. Damit fand `ueberwachung` den Eintrag `überwachung` nie — zwei
1289
+ * Schreibweisen desselben Wortes, und die Suche hielt sie fuer verschieden.
1290
+ * Gemessen am 19.08.2026.
1291
+ *
1292
+ * Der bestehende Schluessel bleibt stehen; es kommt nur die umlautfreie Form
1293
+ * dazu. Wer `überwachung` tippt, wird beim Zerlegen ohnehin auf `ueberwachung`
1294
+ * gebracht.
1295
+ */
1296
+ for (const [wort, synonyme] of CROSS_LINGUAL_MAP) {
1297
+ const ohne = ohneUmlaute(wort);
1298
+ if (ohne !== wort && !CROSS_LINGUAL_MAP.has(ohne))
1299
+ CROSS_LINGUAL_MAP.set(ohne, synonyme);
1300
+ }
1301
+ for (const [wort, synonyme] of DEUTSCHE_FACHWOERTER) {
1302
+ const vorhanden = CROSS_LINGUAL_MAP.get(wort);
1303
+ CROSS_LINGUAL_MAP.set(wort, vorhanden ? [...new Set([...vorhanden, ...synonyme])] : [...synonyme]);
1304
+ }
1305
+ /**
1306
+ * Grundwoerter fuer die Zerlegung zusammengesetzter Woerter.
1307
+ *
1308
+ * Es ist die Wortliste selbst — bewusst KEINE zweite Liste. Wer ein Wortpaar
1309
+ * eintraegt, macht es damit auch als Grundwort verfuegbar. Eine Wahrheit an
1310
+ * zwei Orten zu pflegen ist die Fehlerklasse, die uns hier am haeufigsten
1311
+ * trifft.
1312
+ */
1313
+ const GRUNDWOERTER = new Set([...CROSS_LINGUAL_MAP.keys()].filter(k => k.length >= 4 && /^[a-z]+$/.test(ohneUmlaute(k))).map(ohneUmlaute));
1281
1314
  function expandCrossLingual(token) {
1282
- return CROSS_LINGUAL_MAP.get(token) ?? [];
1315
+ const direkt = CROSS_LINGUAL_MAP.get(token);
1316
+ if (direkt)
1317
+ return direkt;
1318
+ // Umlautfreie Schreibweise probieren, bevor aufgegeben wird.
1319
+ const ohne = ohneUmlaute(token);
1320
+ return ohne !== token ? (CROSS_LINGUAL_MAP.get(ohne) ?? []) : [];
1283
1321
  }
1284
1322
  /**
1285
1323
  * Tokenize text into meaningful keywords.
@@ -1413,6 +1451,39 @@ function tokenize(text, options) {
1413
1451
  }
1414
1452
  }
1415
1453
  tokens.push(w);
1454
+ // ── Deutsch ──────────────────────────────────────────────────────────
1455
+ //
1456
+ // Drei Schritte, alle am 19.08.2026 an echten Fragen belegt. Fuer
1457
+ // sieben Sprachen gab es hier einen Stemmer, fuer Deutsch nicht.
1458
+ //
1459
+ // Wichtig: alles wird ZUSAETZLICH ausgegeben, nie ersetzend. Das
1460
+ // Originalwort bleibt der staerkste Treffer; die Ableitungen sind
1461
+ // Bruecken, keine Umleitungen.
1462
+ // 1. Umlaute vereinheitlichen. `Loesung` und `Lösung` sind dasselbe
1463
+ // Wort — vorher waren es zwei.
1464
+ const ohneU = ohneUmlaute(w);
1465
+ if (ohneU !== w && !STOPWORDS.has(ohneU))
1466
+ tokens.push(ohneU);
1467
+ // 2. Wortform. `Abhaengigkeiten` und `Abhaengigkeit` sollen sich
1468
+ // finden.
1469
+ const stamm = deutscherStamm(w);
1470
+ if (stamm !== w && stamm !== ohneU && stamm.length > 3 && !STOPWORDS.has(stamm)) {
1471
+ tokens.push(stamm);
1472
+ // Rueckumlautung: `Eintraege` -> `eintraeg` -> `eintrag`.
1473
+ const zurueck = ohneRueckumlaut(stamm);
1474
+ if (zurueck && zurueck.length > 3 && !STOPWORDS.has(zurueck))
1475
+ tokens.push(zurueck);
1476
+ }
1477
+ // 3. Zusammengesetzte Woerter. Die deutsche Besonderheit und die
1478
+ // groesste Luecke: `Arbeitsspeicher` enthaelt `speicher`, und
1479
+ // `speicher` stand laengst in der Wortliste — nur kam niemand
1480
+ // dort an.
1481
+ if (ohneU.length >= 10) {
1482
+ for (const teil of zerlegeKompositum(ohneU, GRUNDWOERTER)) {
1483
+ if (!STOPWORDS.has(teil))
1484
+ tokens.push(teil);
1485
+ }
1486
+ }
1416
1487
  }
1417
1488
  }
1418
1489
  }
@@ -1541,7 +1612,31 @@ function splitMultiQuery(query) {
1541
1612
  /** BM25+ parameters */
1542
1613
  const BM25_K1 = 1.2; // term frequency saturation
1543
1614
  const BM25_B = 0.75; // length normalization
1544
- const BM25_DELTA = 1.0; // BM25+ lower-bound guarantee (0 = classic BM25)
1615
+ // BM25+ gibt jedem gefundenen Wort einen Sockelbetrag, damit lange Dokumente
1616
+ // nicht ganz durchfallen. Bei 1.0 belohnte das Breite statt Genauigkeit: ein
1617
+ // Datensatz, der zehn allgemeine Woerter traf, schlug einen, der zwei genaue
1618
+ // traf. Gemessen 19.08.2026 an 498 Lektionen — Median-Platz 101 bei 1.0,
1619
+ // 95 bei 0.25, 77 bei 0.
1620
+ const BM25_DELTA = 0.25;
1621
+ // Wie stark Frische die Rangfolge bewegen darf: ±5 Prozent. Begruendung und
1622
+ // Messreihe stehen bei recencyBoost().
1623
+ const RECENCY_SPANNE = 0.05;
1624
+ // Wie stark ein von uns ergaenztes Synonym zaehlt, verglichen mit einem Wort,
1625
+ // das der Mensch getippt hat.
1626
+ //
1627
+ // Gemessen 19.08.2026 an 499 Lektionen mit 100 Fragen:
1628
+ //
1629
+ // Gewicht Platz 1 Top 3 Median
1630
+ // 1.0 20 31 14
1631
+ // 0.5 21 33 12
1632
+ // 0.35 22 31 13
1633
+ //
1634
+ // 0.5 ist auf drei von vier Massen vorn. Der Unterschied ist EINE Frage — das
1635
+ // ist kein Beleg, sondern eine Richtung. Wer hier dreht, misst nach.
1636
+ const SYNONYM_GEWICHT = 0.5;
1637
+ // Wie viele vorhandene Woerter je Fragewort als unscharfer Treffer infrage
1638
+ // kommen. Messreihe steht bei fuzzyKandidaten().
1639
+ const FUZZY_KANDIDATEN = 64;
1545
1640
  /** Recency boost: half-life in days. Entry from 7 days ago gets 0.5× boost. */
1546
1641
  const RECENCY_HALF_LIFE_DAYS = 7;
1547
1642
  const ZERO_RESULTS_LOG = [];
@@ -1601,29 +1696,104 @@ function extractTimestamp(content) {
1601
1696
  // contention. This is bench-tuned; swapping in the sharp confidence curve here
1602
1697
  // regressed every Cachly-Bench floor and was reverted (PR #228). If you touch
1603
1698
  // this, run `npm run bench:gate` — the rerank.test.ts guard alone is too loose.
1699
+ /**
1700
+ * Frische als STICHENTSCHEID, nicht als Treiber.
1701
+ *
1702
+ * ── Was hier schiefging, und wie lange ──────────────────────────────────────
1703
+ *
1704
+ * Bis zum 19.08.2026 lag der Bereich bei [0.5, 1.5]. Eine ganz frische Lektion
1705
+ * bekam damit das DREIFACHE einer 79 Tage alten — unabhaengig davon, wie gut
1706
+ * sie zur Frage passt. Bei einem Bestand, in dem viele Datensaetze aehnlich
1707
+ * mittelmaessig passen, entscheidet ein solcher Faktor die Rangfolge allein.
1708
+ *
1709
+ * Gemessen an 498 echten Lektionen mit 20 Fragen. Nur diesen Bereich zu
1710
+ * verschmaelern, sonst nichts:
1711
+ *
1712
+ * Bereich auf Platz 1 Median-Platz
1713
+ * [0.5, 1.5] 1/20 101
1714
+ * [0.75, 1.25] 2/20 53
1715
+ * [0.9, 1.1] 3/20 39
1716
+ * [0.95, 1.05] 5/20 36
1717
+ * ohne Frische 6/20 29
1718
+ *
1719
+ * ── Warum trotzdem nicht ganz weg ───────────────────────────────────────────
1720
+ *
1721
+ * Weil Lektionen einander korrigieren. kanzlei:coach-timeout-root-cause traegt
1722
+ * woertlich "korrigiert eine frueher falsch gespeicherte Lesson". Ohne jede
1723
+ * Frische gewinnt bei gleichwertigem Text die falsche alte Fassung, und zwar
1724
+ * fuer immer.
1725
+ *
1726
+ * ±5 Prozent heisst in der Praxis: die Reihenfolge aendert sich nur zwischen
1727
+ * Datensaetzen, die ohnehin fast gleich gut passen. Genau das soll ein
1728
+ * Stichentscheid tun.
1729
+ *
1730
+ * Ohne Frische mass sich um EINE Frage besser. Bei 20 Fragen ist das kein
1731
+ * Unterschied, auf den man eine Entscheidung stuetzt.
1732
+ *
1733
+ * ── Warum es Monate unentdeckt blieb ────────────────────────────────────────
1734
+ *
1735
+ * Der Fixture-Bench hat 17 Lektionen, mit Zeitstempeln und fast derselben
1736
+ * Altersspanne (2,7x gegen 3,0x). Am fehlenden Zeitstempel lag es also NICHT.
1737
+ * Es lag an der Menge: bei 16 Mitbewerbern gewinnt der Textabgleich auch gegen
1738
+ * den Faktor 3, weil ein seltenes Wort rund zehnmal so schwer wiegt wie ein
1739
+ * haeufiges. Bei 497 Mitbewerbern gibt es genug Datensaetze mit aehnlichem
1740
+ * Textwert — und dann entscheidet der Faktor.
1741
+ *
1742
+ * Ein Fehler, der erst ab einer gewissen Menge auftritt, ist auf einem kleinen
1743
+ * Pruefstand unsichtbar. Nicht schwer zu finden. Unsichtbar.
1744
+ */
1604
1745
  function recencyBoost(timestampMs) {
1605
1746
  if (!timestampMs)
1606
- return 1.0; // no timestamp → neutral
1747
+ return 1.0;
1607
1748
  const ageDays = (Date.now() - timestampMs) / (1000 * 60 * 60 * 24);
1608
- if (ageDays <= 0)
1609
- return 1.5; // future/just-now → max boost
1610
- return Math.pow(0.5, ageDays / RECENCY_HALF_LIFE_DAYS) + 0.5; // range: [0.5, 1.5]
1749
+ const frisch = ageDays <= 0 ? 1 : Math.pow(0.5, ageDays / RECENCY_HALF_LIFE_DAYS);
1750
+ return 1 - RECENCY_SPANNE + 2 * RECENCY_SPANNE * frisch;
1611
1751
  }
1752
+ const WORTBESTAND_FRISCHE_MS = 60_000;
1612
1753
  /**
1613
- * BM25+ with Bigram Proximity, Recency Boost, and Fuzzy Matching.
1754
+ * Ein Bestand JE VERBINDUNG, nicht je Muster.
1614
1755
  *
1615
- * Architecture:
1616
- * ┌─────────────────────────────────────────────────────────────────┐
1617
- * │ Query: "deploy API fix routing; check auth" │
1618
- * │ ↓ splitMultiQuery │
1619
- * │ Sub-queries: ["deploy API fix routing", "check auth"] │
1620
- * │ ↓ for each sub-query │
1621
- * │ Tokenize → BM25+ score per doc → Bigram boost → Fuzzy match │
1622
- * │ ↓ merge & deduplicate │
1623
- * │ Recency boost → Sort → Top-K │
1624
- * └─────────────────────────────────────────────────────────────────┘
1756
+ * Der erste Entwurf benannte den Zwischenspeicher nur nach dem Suchmuster.
1757
+ * Das war falsch, und zwei Tests haben es sofort gezeigt: derselbe
1758
+ * MCP-Prozess bedient MEHRERE Instanzen (`getConnection(instance_id)`), und
1759
+ * `cachly:lesson:best:*` heisst in jeder davon etwas anderes. Ein Bestand nur
1760
+ * nach Muster haette die Lektionen der einen Kanzlei an die naechste
1761
+ * ausgeliefert.
1762
+ *
1763
+ * Deshalb eine WeakMap auf das Verbindungsobjekt: geht die Verbindung, geht
1764
+ * der Bestand mit — ohne dass ihn jemand aufraeumen muesste.
1765
+ */
1766
+ const _wortbestand = new WeakMap();
1767
+ /** Nur fuer die Zaehlung in Tests — die WeakMap laesst sich nicht zaehlen. */
1768
+ let _wortbestandZaehler = 0;
1769
+ /**
1770
+ * Wirft alle Bestaende weg. Ruft der Schreibpfad auf, wenn eine Lektion neu
1771
+ * geschrieben wurde — ohne das waere sie bis zu eine Minute unsichtbar.
1772
+ *
1773
+ * Absichtlich ALLE und nicht nur die betroffene Verbindung: der Aufrufer
1774
+ * kennt die Verbindung an dieser Stelle nicht immer, und ein Bestand zu viel
1775
+ * neu zu bauen kostet eine Sekunde. Einen zu wenig zu entwerten kostet eine
1776
+ * Minute Unsichtbarkeit.
1625
1777
  */
1778
+ export function wortindexEntwerten() {
1779
+ _entwertetAb = Date.now();
1780
+ _wortbestandZaehler = 0;
1781
+ }
1782
+ /** Alles, was vor diesem Zeitpunkt gebaut wurde, gilt als veraltet. */
1783
+ let _entwertetAb = 0;
1784
+ /** Nur fuer Tests: wie viele Bestaende seit dem letzten Entwerten stehen. */
1785
+ export function wortindexStand() {
1786
+ return _wortbestandZaehler;
1787
+ }
1626
1788
  async function keywordSearch(redis, patterns, query, topK = 10) {
1789
+ const bestandsSchluessel = patterns.slice().sort().join('|');
1790
+ const jeVerbindung = _wortbestand.get(redis);
1791
+ const stehend = jeVerbindung?.get(bestandsSchluessel);
1792
+ if (stehend
1793
+ && stehend.gebaut > _entwertetAb
1794
+ && Date.now() - stehend.gebaut < WORTBESTAND_FRISCHE_MS) {
1795
+ return keywordSearchMitBestand(stehend, query, topK);
1796
+ }
1627
1797
  // ── Step 1: Collect all matching keys — patterns scanned in parallel ──
1628
1798
  const keyBatches = await Promise.all(patterns.map(pattern => {
1629
1799
  const keys = [];
@@ -1651,6 +1821,25 @@ async function keywordSearch(redis, patterns, query, topK = 10) {
1651
1821
  const content = results?.[i]?.[1];
1652
1822
  if (!content)
1653
1823
  continue;
1824
+ // Indiziert wird der ROHE Inhalt, nicht eine Auswahl von Feldern.
1825
+ //
1826
+ // Am 19.08.2026 wurde genau das versucht: nur die bedeutungstragenden
1827
+ // Felder indizieren (Thema dreifach, dann what_worked, what_failed, tags,
1828
+ // commands, file_paths) und Pruefspur, Zahlen und Feldnamen weglassen. Das
1829
+ // Argument dafuer war gut: die Dokumentlaenge sollte nicht von einer langen
1830
+ // Pruefspur aufgeblaeht werden.
1831
+ //
1832
+ // Gemessen an 499 Lektionen mit 100 Fragen kostete es in JEDER Einstellung
1833
+ // einen Treffer:
1834
+ //
1835
+ // Synonymgewicht mit Feldauswahl ohne
1836
+ // 1.0 19 20
1837
+ // 0.5 20 21
1838
+ // 0.35 21 22
1839
+ //
1840
+ // Steht hier, damit es niemand — auch ich nicht — ein zweites Mal fuer eine
1841
+ // gute Idee haelt. Das Argument war plausibel und falsch, und genau diese
1842
+ // Sorte Aenderung hat uns die `score^0.3`-Stauchung eingebracht.
1654
1843
  const tokens = tokenize(`${allKeys[i]} ${content}`, { crossLingualExpand: false });
1655
1844
  if (tokens.length === 0)
1656
1845
  continue;
@@ -1684,6 +1873,23 @@ async function keywordSearch(redis, patterns, query, topK = 10) {
1684
1873
  }
1685
1874
  }
1686
1875
  }
1876
+ const gebauter = { docs, avgDL, docFreq, gebaut: Date.now() };
1877
+ const fach = _wortbestand.get(redis) ?? new Map();
1878
+ if (!fach.has(bestandsSchluessel))
1879
+ _wortbestandZaehler++;
1880
+ fach.set(bestandsSchluessel, gebauter);
1881
+ _wortbestand.set(redis, fach);
1882
+ return keywordSearchMitBestand(gebauter, query, topK);
1883
+ }
1884
+ /**
1885
+ * Die Bewertung selbst — alles, was von der FRAGE abhaengt.
1886
+ *
1887
+ * Unveraendert aus keywordSearch herausgeloest: dieselbe Reihenfolge,
1888
+ * dieselben Formeln. Getrennt wurde nur, was je Bestand gilt, von dem, was je
1889
+ * Frage gilt.
1890
+ */
1891
+ function keywordSearchMitBestand(bestand, query, topK) {
1892
+ const { docs, avgDL, docFreq } = bestand;
1687
1893
  const N = docs.length;
1688
1894
  function idf(term) {
1689
1895
  const df = docFreq.get(term) ?? 0;
@@ -1702,51 +1908,14 @@ async function keywordSearch(redis, patterns, query, topK = 10) {
1702
1908
  const tfNorm = (tf * (BM25_K1 + 1)) / (tf + BM25_K1 * (1 - BM25_B + BM25_B * (dl / avgDL)));
1703
1909
  return idf(term) * (tfNorm + BM25_DELTA);
1704
1910
  }
1705
- /**
1706
- * Fuzzy match: tries exact → prefix → substring → Levenshtein ≤ 2.
1707
- * Returns [matchedTerm, weight] or null.
1708
- *
1709
- * Weights:
1710
- * 1.0 — exact match
1711
- * 0.85 — doc token starts with query (e.g. "dockerf" → "dockerfile")
1712
- * 0.75 — query starts with doc token (e.g. "kubernetes" → "kube")
1713
- * 0.6 — substring (either direction)
1714
- * 0.4 — Levenshtein ≤ 2 (typo tolerance)
1715
- */
1716
- function fuzzyMatch(qt, docTermSet) {
1717
- // Exact
1718
- if (docTermSet.has(qt))
1719
- return [qt, 1.0];
1720
- // Prefix: query is prefix of a doc token (user typed partial word)
1721
- if (qt.length >= 4) {
1722
- for (const dt of docTermSet) {
1723
- if (dt.length > qt.length && dt.startsWith(qt))
1724
- return [dt, 0.85];
1725
- }
1726
- }
1727
- // Reverse-prefix: doc token is prefix of query (doc has abbreviated form)
1728
- if (qt.length >= 4) {
1729
- for (const dt of docTermSet) {
1730
- if (dt.length >= 4 && dt.length < qt.length && qt.startsWith(dt))
1731
- return [dt, 0.75];
1732
- }
1733
- }
1734
- // Substring (partial)
1735
- for (const dt of docTermSet) {
1736
- if (dt.length > 3 && qt.length > 3 && (dt.includes(qt) || qt.includes(dt))) {
1737
- return [dt, 0.6];
1738
- }
1739
- }
1740
- // Levenshtein ≤ 2 (typo tolerance) — only for tokens ≥ 4 chars
1741
- if (qt.length >= 4) {
1742
- for (const dt of docTermSet) {
1743
- if (dt.length >= 4 && levenshtein(qt, dt) <= 2) {
1744
- return [dt, 0.4];
1745
- }
1746
- }
1747
- }
1748
- return null;
1749
- }
1911
+ // Die frueher hier stehende Funktion `fuzzyMatch(qt, docTermSet)` ist am
1912
+ // 19.08.2026 entfallen. Sie lief je Frage UND je Dokument und durchsuchte den
1913
+ // Wortvorrat des Dokuments bis zu viermal — bei 499 Lektionen rund 540 der
1914
+ // 788 Millisekunden je Frage.
1915
+ //
1916
+ // An ihre Stelle tritt `fuzzyKandidaten(qt)` weiter unten: die passenden
1917
+ // Woerter werden EINMAL je Frage gegen den Gesamtwortschatz bestimmt, danach
1918
+ // ist die Pruefung je Dokument ein Nachschlagen.
1750
1919
  // ── Step 3: Score each sub-query independently ──
1751
1920
  const subQueries = splitMultiQuery(query);
1752
1921
  const allMatches = new Map();
@@ -1754,6 +1923,78 @@ async function keywordSearch(redis, patterns, query, topK = 10) {
1754
1923
  const queryTokens = tokenize(sq);
1755
1924
  if (queryTokens.length === 0)
1756
1925
  continue;
1926
+ // ── Unscharfe Treffer einmal aufloesen, nicht je Dokument ──────────────
1927
+ //
1928
+ // Vorher stand im Bewertungsteil `fuzzyMatch(qt, new Set(doc.tokens))` —
1929
+ // pro Frage UND pro Dokument. Bei 499 Lektionen mit je rund 300 Woertern
1930
+ // und 40 Fragewoertern waren das ueber zwanzig Millionen Zeichenvergleiche,
1931
+ // viele davon Levenshtein.
1932
+ //
1933
+ // Gemessen am 19.08.2026: von 788 ms je Frage entfielen 249 ms auf den
1934
+ // Indexaufbau und rund 540 ms auf genau diese Schleife.
1935
+ //
1936
+ // Der Wortschatz ist fuer alle Dokumente derselbe. Also wird je Fragewort
1937
+ // EINMAL bestimmt, welche vorhandenen Woerter dazu passen — danach ist die
1938
+ // Pruefung je Dokument ein Nachschlagen in einer Map.
1939
+ //
1940
+ // Nebeneffekt: das Ergebnis wird VERLAESSLICHER. Vorher nahm fuzzyMatch das
1941
+ // erste passende Wort in der Reihenfolge, in der die Woerter eines
1942
+ // Dokuments zufaellig standen — dasselbe Fragewort konnte in zwei
1943
+ // Dokumenten auf verschiedene Woerter treffen.
1944
+ const fuzzySpeicher = new Map();
1945
+ const fuzzyKandidaten = (qt) => {
1946
+ const da = fuzzySpeicher.get(qt);
1947
+ if (da)
1948
+ return da;
1949
+ // Alle Kandidaten je Stufe sammeln — NICHT nach den ersten acht
1950
+ // abbrechen.
1951
+ //
1952
+ // Die erste Fassung tat genau das und kostete 15 von 100 Fragen: sie
1953
+ // nahm die ersten acht Woerter in der Reihenfolge, in der sie zufaellig
1954
+ // im Wortschatz standen, und die passten meist nicht zu dem Dokument,
1955
+ // um das es ging. Schneller und schlechter ist kein Fortschritt.
1956
+ const sammle = (passt, gewichtung) => {
1957
+ const aus = [];
1958
+ for (const dt of docFreq.keys())
1959
+ if (passt(dt))
1960
+ aus.push([dt, gewichtung]);
1961
+ // Seltene Woerter zuerst: sie unterscheiden. Ein Wort, das in 300 von
1962
+ // 499 Lektionen steht, sagt nichts darueber, welche gemeint ist.
1963
+ aus.sort((a, b) => (docFreq.get(a[0]) ?? 0) - (docFreq.get(b[0]) ?? 0));
1964
+ return aus.slice(0, FUZZY_KANDIDATEN);
1965
+ };
1966
+ let aus = [];
1967
+ if (qt.length >= 4) {
1968
+ aus = sammle((dt) => dt.length > qt.length && dt.startsWith(qt), 0.85);
1969
+ }
1970
+ if (aus.length === 0 && qt.length >= 4) {
1971
+ aus = sammle((dt) => dt.length >= 4 && dt.length < qt.length && qt.startsWith(dt), 0.75);
1972
+ }
1973
+ if (aus.length === 0 && qt.length > 3) {
1974
+ aus = sammle((dt) => dt.length > 3 && (dt.includes(qt) || qt.includes(dt)), 0.6);
1975
+ }
1976
+ if (aus.length === 0 && qt.length >= 4) {
1977
+ aus = sammle((dt) => dt.length >= 4 && levenshtein(qt, dt) <= 2, 0.4);
1978
+ }
1979
+ fuzzySpeicher.set(qt, aus);
1980
+ return aus;
1981
+ };
1982
+ // Die Woerter, die der Mensch WIRKLICH getippt hat — ohne Synonyme.
1983
+ //
1984
+ // Gebraucht wird das fuer den Titel-Bonus weiter unten. Der teilte bisher
1985
+ // durch die ERWEITERTE Fragelaenge, und das ist ein Fehler mit Ansage:
1986
+ // eine deutsche Frage wird auf ein Vielfaches aufgeblasen (Englisch,
1987
+ // Japanisch, Chinesisch, Koreanisch, Arabisch, Russisch ...), also sank
1988
+ // der Bonus mit jedem Synonym, das wir der Tabelle hinzufuegten.
1989
+ //
1990
+ // Gemessen am 19.08.2026: "Jeder Build laedt alle Abhaengigkeiten neu ..."
1991
+ // ergab 26 Tokens fuer 12 getippte Woerter. Ein Titeltreffer zaehlte damit
1992
+ // 3/26 statt 3/12 — der Bonus fiel von 1,13 auf 1,06.
1993
+ //
1994
+ // Das ist die unangenehme Sorte Fehler: die Suche zu verbessern (mehr
1995
+ // Synonyme) machte den Titel-Bonus schwaecher. Zwei Stellschrauben, die
1996
+ // gegeneinander arbeiteten, ohne dass es jemandem auffiel.
1997
+ const kernTokens = new Set(tokenize(sq, { crossLingualExpand: false }));
1757
1998
  // Pre-compute query bigrams for proximity boost
1758
1999
  const queryBigrams = new Set();
1759
2000
  for (let j = 0; j < queryTokens.length - 1; j++) {
@@ -1762,20 +2003,29 @@ async function keywordSearch(redis, patterns, query, topK = 10) {
1762
2003
  for (const doc of docs) {
1763
2004
  let score = 0;
1764
2005
  const matchedWords = [];
1765
- const docTermSet = new Set(doc.tokens);
1766
2006
  for (const qt of queryTokens) {
1767
- // Try exact BM25+ first
2007
+ // Ein Wort, das der Mensch getippt hat, zaehlt voll. Ein Synonym, das
2008
+ // WIR ergaenzt haben, zaehlt weniger.
2009
+ //
2010
+ // Grund: die Bruecke von einer Sprache in die andere landet meistens
2011
+ // bei allgemeinen Woertern. `Zugriffsprotokoll` wird ueber `protokoll`
2012
+ // zu `log`, und `log` steht in hunderten Lektionen. Zaehlt das genauso
2013
+ // viel wie ein getipptes seltenes Wort, gewinnt die Breite gegen die
2014
+ // Genauigkeit.
2015
+ const gewicht = kernTokens.has(qt) ? 1 : SYNONYM_GEWICHT;
1768
2016
  const exactScore = bm25PlusTerm(qt, doc);
1769
2017
  if (exactScore > 0) {
1770
- score += exactScore;
2018
+ score += exactScore * gewicht;
1771
2019
  matchedWords.push(qt);
1772
2020
  continue;
1773
2021
  }
1774
- // Fuzzy match (substring or Levenshtein)
1775
- const fuzz = fuzzyMatch(qt, docTermSet);
1776
- if (fuzz) {
1777
- score += bm25PlusTerm(fuzz[0], doc) * fuzz[1];
2022
+ // Unscharfer Treffer — die Kandidaten stehen schon fest, siehe unten.
2023
+ for (const [term, gewichtung] of fuzzyKandidaten(qt)) {
2024
+ if (!doc.tokenFreq.has(term))
2025
+ continue;
2026
+ score += bm25PlusTerm(term, doc) * gewichtung * gewicht;
1778
2027
  matchedWords.push(`~${qt}`);
2028
+ break;
1779
2029
  }
1780
2030
  }
1781
2031
  // Bigram proximity boost: +50% for each adjacent query term pair found in doc
@@ -1791,19 +2041,31 @@ async function keywordSearch(redis, patterns, query, topK = 10) {
1791
2041
  }
1792
2042
  // Phrase-match boost: if the raw (lowercased) query appears verbatim in content, 2× boost
1793
2043
  // This rewards docs where the exact phrase exists (vs scattered tokens).
1794
- if (sq.length >= 4 && doc.content.toLowerCase().includes(sq.toLowerCase())) {
2044
+ if (!process.env.AUS_PHRASE && sq.length >= 4 && doc.content.toLowerCase().includes(sq.toLowerCase())) {
1795
2045
  score *= 2.0;
1796
2046
  }
1797
2047
  // Key/title boost: if query terms appear in the Redis key (=title), 1.5× boost
1798
2048
  // Keys often encode the primary topic (e.g. "deploy:api:server"), so key hits are high-precision.
2049
+ // Der Titel (Redis-Schluessel) ist das dichteste Feld, das wir haben:
2050
+ // `ci:node3-disk-full-zombie-deploy` traegt die Antwort im Namen. Ein
2051
+ // Treffer dort ist mehr wert als drei im Fliesstext.
2052
+ //
2053
+ // Bezugsgroesse sind deshalb die GETIPPTEN Woerter, nicht die erweiterten
2054
+ // — siehe kernTokens oben. Und die Obergrenze ist hoeher (2,0 statt 1,5),
2055
+ // weil der bisherige Deckel bei langen Fragen nie erreicht wurde.
1799
2056
  if (score > 0) {
1800
2057
  let keyHits = 0;
2058
+ let kernHits = 0;
1801
2059
  for (const qt of queryTokens) {
1802
- if (doc.keyTokens.has(qt))
1803
- keyHits++;
2060
+ if (!doc.keyTokens.has(qt))
2061
+ continue;
2062
+ keyHits++;
2063
+ if (kernTokens.has(qt))
2064
+ kernHits++;
1804
2065
  }
1805
2066
  if (keyHits > 0) {
1806
- score *= 1 + 0.5 * (keyHits / queryTokens.length);
2067
+ const anteil = kernHits / Math.max(1, kernTokens.size);
2068
+ score *= 1 + 1.0 * Math.min(1, anteil * 2);
1807
2069
  }
1808
2070
  }
1809
2071
  // Recency boost