@cachly-dev/mcp-server 0.10.123 → 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.
- package/dist/index.d.ts +1 -0
- package/dist/index.js +7 -0
- package/dist/index.js.map +1 -0
- package/dist/src/aussetzer.d.ts +67 -0
- package/dist/src/aussetzer.js +82 -0
- package/dist/src/aussetzer.js.map +1 -0
- package/dist/src/autor-abzeichen.d.ts +41 -0
- package/dist/src/autor-abzeichen.js +56 -0
- package/dist/src/autor-abzeichen.js.map +1 -0
- package/dist/src/autor.d.ts +101 -0
- package/dist/src/autor.js +136 -0
- package/dist/src/autor.js.map +1 -0
- package/dist/src/bauabdruck-daten.generiert.json +5 -0
- package/dist/src/bauabdruck.d.ts +38 -0
- package/dist/src/bauabdruck.js +93 -0
- package/dist/src/bauabdruck.js.map +1 -0
- package/dist/src/bedeutung.d.ts +175 -0
- package/dist/src/bedeutung.js +254 -0
- package/dist/src/bedeutung.js.map +1 -0
- package/dist/src/brain-export.d.ts +24 -0
- package/dist/src/brain-export.js +31 -1
- package/dist/src/brain-export.js.map +1 -1
- package/dist/src/deutsch.d.ts +98 -0
- package/dist/src/deutsch.js +223 -0
- package/dist/src/deutsch.js.map +1 -0
- package/dist/src/eingaenge-nachruesten.d.ts +41 -0
- package/dist/src/eingaenge-nachruesten.js +290 -0
- package/dist/src/eingaenge-nachruesten.js.map +1 -0
- package/dist/src/eingaenge.d.ts +135 -0
- package/dist/src/eingaenge.js +228 -0
- package/dist/src/eingaenge.js.map +1 -0
- package/dist/src/geliefert.d.ts +63 -0
- package/dist/src/geliefert.js +114 -0
- package/dist/src/geliefert.js.map +1 -0
- package/dist/src/handlers/advanced.js +7 -3
- package/dist/src/handlers/advanced.js.map +1 -1
- package/dist/src/handlers/brain.d.ts +19 -0
- package/dist/src/handlers/brain.js +492 -48
- package/dist/src/handlers/brain.js.map +1 -1
- package/dist/src/handlers/instances.d.ts +24 -0
- package/dist/src/handlers/instances.js +49 -1
- package/dist/src/handlers/instances.js.map +1 -1
- package/dist/src/handlers/team.js +105 -0
- package/dist/src/handlers/team.js.map +1 -1
- package/dist/src/index.d.ts +20 -0
- package/dist/src/index.js +282 -36
- package/dist/src/index.js.map +1 -1
- package/dist/src/lesson-preview.d.ts +81 -0
- package/dist/src/lesson-preview.js +133 -0
- package/dist/src/lesson-preview.js.map +1 -0
- package/dist/src/rangfolge.d.ts +169 -0
- package/dist/src/rangfolge.js +214 -0
- package/dist/src/rangfolge.js.map +1 -0
- package/dist/src/raumkorrektur.d.ts +100 -0
- package/dist/src/raumkorrektur.js +197 -0
- package/dist/src/raumkorrektur.js.map +1 -0
- package/dist/src/recall-tiefe.d.ts +48 -0
- package/dist/src/recall-tiefe.js +42 -0
- package/dist/src/recall-tiefe.js.map +1 -0
- package/dist/src/rerank.d.ts +0 -14
- package/dist/src/rerank.js +39 -2
- package/dist/src/rerank.js.map +1 -1
- package/dist/src/search.d.ts +56 -17
- package/dist/src/search.js +335 -73
- package/dist/src/search.js.map +1 -1
- package/dist/src/seltenheitsbestand.d.ts +53 -0
- package/dist/src/seltenheitsbestand.js +92 -0
- package/dist/src/seltenheitsbestand.js.map +1 -0
- package/dist/src/spuren.d.ts +97 -0
- package/dist/src/spuren.js +150 -0
- package/dist/src/spuren.js.map +1 -0
- package/dist/src/tools.js +1 -1
- package/dist/src/tools.js.map +1 -1
- package/dist/src/vorspann.d.ts +73 -0
- package/dist/src/vorspann.js +146 -0
- package/dist/src/vorspann.js.map +1 -0
- package/dist/src/werkzeug-nutzung.d.ts +62 -0
- package/dist/src/werkzeug-nutzung.js +72 -0
- package/dist/src/werkzeug-nutzung.js.map +1 -0
- package/dist/src/wertbeitrag.d.ts +57 -0
- package/dist/src/wertbeitrag.js +73 -0
- package/dist/src/wertbeitrag.js.map +1 -0
- package/glama.json +1 -1
- package/package.json +12 -4
- package/server.json +2 -2
package/dist/src/search.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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;
|
|
1747
|
+
return 1.0;
|
|
1607
1748
|
const ageDays = (Date.now() - timestampMs) / (1000 * 60 * 60 * 24);
|
|
1608
|
-
|
|
1609
|
-
|
|
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
|
-
*
|
|
1754
|
+
* Ein Bestand JE VERBINDUNG, nicht je Muster.
|
|
1614
1755
|
*
|
|
1615
|
-
*
|
|
1616
|
-
*
|
|
1617
|
-
*
|
|
1618
|
-
*
|
|
1619
|
-
*
|
|
1620
|
-
*
|
|
1621
|
-
*
|
|
1622
|
-
*
|
|
1623
|
-
*
|
|
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
|
-
|
|
1707
|
-
|
|
1708
|
-
|
|
1709
|
-
|
|
1710
|
-
|
|
1711
|
-
|
|
1712
|
-
|
|
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
|
-
//
|
|
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
|
-
//
|
|
1775
|
-
const
|
|
1776
|
-
|
|
1777
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|