R42 was missing knowledge-graph information (e.g. pension questions) because retrieval and context-building dropped relevant facts: - retrieval: exact-token TF-IDF could not match Dutch compound words, so a "pensioen" query scored 0 against "pensioenregeling" / "partnerpensioen" and never retrieved them. Add a compound-word fallback (shared >=6-char stem or containment, 0.4x weight) alongside exact matching. - rag: deep article content was only injected for verbatim-mentioned topics; retrieved topics contributed just a 200-char description. Inject ~1000 chars of content for up to 5 topics (mentions first, then top-ranked retrieved) and widen the description snippet to 320. - prompts: add a NAUWKEURIGHEID block (use all relevant facts, call lookup_topic before giving up) and relax the 4-sentence cap for detail/list answers so complete facts aren't summarised away. Also add a clear-history control: a trash button in the chat header (confirm dialog) wipes chat🧵{userId} and reseeds the greeting via clearThread() in useChat. Tests: compound-word matching + rag deep-content injection. Spec updated. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
130 lines
4.5 KiB
JavaScript
130 lines
4.5 KiB
JavaScript
/**
|
|
* Lightweight, dependency-free TF-IDF retrieval over the knowledge graph.
|
|
*
|
|
* `buildIndex(topics)` tokenises the `label + description` of each topic and
|
|
* computes document-frequency stats so queries can be scored with TF-IDF in
|
|
* `retrieveTopK`. The index is cached against the `topics` array reference,
|
|
* so repeated calls with the same array don't rebuild.
|
|
*
|
|
* Tokeniser: lowercase, split on `[^a-zA-Z0-9-]`, drop short tokens and a
|
|
* small Dutch/English stopword list.
|
|
*/
|
|
|
|
const STOPWORDS = new Set([
|
|
// English
|
|
'a', 'an', 'and', 'are', 'as', 'at', 'be', 'by', 'for', 'from', 'has', 'have',
|
|
'how', 'i', 'in', 'is', 'it', 'its', 'of', 'on', 'or', 'than', 'that', 'the',
|
|
'this', 'to', 'was', 'were', 'what', 'when', 'where', 'which', 'who', 'why',
|
|
'with', 'you', 'your', 'do', 'does', 'did',
|
|
// Dutch
|
|
'de', 'het', 'een', 'en', 'of', 'in', 'op', 'aan', 'bij', 'voor', 'naar',
|
|
'met', 'uit', 'om', 'door', 'over', 'tegen', 'ook', 'er', 'is', 'zijn',
|
|
'was', 'waren', 'wat', 'wie', 'hoe', 'waar', 'wanneer', 'welke', 'die',
|
|
'dat', 'deze', 'dit', 'ik', 'jij', 'hij', 'zij', 'we', 'wij', 'jullie',
|
|
'als', 'dan', 'maar', 'want', 'omdat', 'niet', 'wel', 'heeft', 'hebben',
|
|
'word', 'wordt', 'worden', 'kan', 'kunnen', 'mag', 'moet', 'moeten',
|
|
'zal', 'zou', 'zouden', 'al', 'ook', 'nog', 'naar',
|
|
]);
|
|
|
|
export function tokenize(text) {
|
|
if (!text) return [];
|
|
return String(text)
|
|
.toLowerCase()
|
|
.split(/[^a-z0-9-]+/i)
|
|
.filter(t => t.length >= 2 && !STOPWORDS.has(t));
|
|
}
|
|
|
|
const indexCache = new WeakMap();
|
|
|
|
export function buildIndex(topics) {
|
|
if (!Array.isArray(topics) || topics.length === 0) {
|
|
return { topics: [], docFreq: new Map(), termsByDoc: [], N: 0 };
|
|
}
|
|
const cached = indexCache.get(topics);
|
|
if (cached) return cached;
|
|
|
|
const termsByDoc = topics.map(t => {
|
|
const text = `${t.label || ''} ${t.description || ''}`;
|
|
const tokens = tokenize(text);
|
|
const tf = new Map();
|
|
for (const tk of tokens) tf.set(tk, (tf.get(tk) || 0) + 1);
|
|
return tf;
|
|
});
|
|
|
|
const docFreq = new Map();
|
|
for (const tf of termsByDoc) {
|
|
for (const term of tf.keys()) {
|
|
docFreq.set(term, (docFreq.get(term) || 0) + 1);
|
|
}
|
|
}
|
|
|
|
const index = { topics, docFreq, termsByDoc, N: topics.length };
|
|
indexCache.set(topics, index);
|
|
return index;
|
|
}
|
|
|
|
// Compound-word matching. Dutch is heavily compounding, so a user's word
|
|
// (`pensioenafspraken`) is a *different* token than the graph's labels
|
|
// (`pensioenregeling`, `partnerpensioen`), even though they share the stem
|
|
// `pensioen`. Exact TF-IDF scores those pairs at 0, so the relevant topics are
|
|
// never retrieved. These heuristics recover that recall at a reduced weight,
|
|
// so exact matches still dominate the ranking.
|
|
const PARTIAL_MIN_QUERY_LEN = 6; // only expand meaty query tokens
|
|
const PARTIAL_MIN_OVERLAP = 6; // shared stem / substring must be this long
|
|
const PARTIAL_WEIGHT = 0.4; // discount vs. an exact term hit
|
|
|
|
/** True when two distinct tokens share a long stem or one contains the other. */
|
|
function partialMatch(q, d) {
|
|
if (q === d) return false;
|
|
const shorter = q.length <= d.length ? q : d;
|
|
const longer = q.length <= d.length ? d : q;
|
|
if (shorter.length < PARTIAL_MIN_OVERLAP) return false;
|
|
if (longer.includes(shorter)) return true;
|
|
let n = 0;
|
|
const m = shorter.length;
|
|
while (n < m && q[n] === d[n]) n++;
|
|
return n >= PARTIAL_MIN_OVERLAP;
|
|
}
|
|
|
|
export function retrieveTopK(index, query, k = 10) {
|
|
if (!index || !index.N || !query) return [];
|
|
const qTokens = tokenize(query);
|
|
if (qTokens.length === 0) return [];
|
|
|
|
const idf = (term) => {
|
|
const df = index.docFreq.get(term) || 0;
|
|
if (df === 0) return 0;
|
|
return Math.log((index.N + 1) / (df + 1)) + 1;
|
|
};
|
|
|
|
const scores = new Array(index.N);
|
|
for (let i = 0; i < index.N; i++) {
|
|
const tf = index.termsByDoc[i];
|
|
let s = 0;
|
|
for (const t of qTokens) {
|
|
const f = tf.get(t);
|
|
if (f) {
|
|
s += (1 + Math.log(f)) * idf(t);
|
|
continue;
|
|
}
|
|
// No exact hit — try a compound-word match against this doc's terms.
|
|
if (t.length < PARTIAL_MIN_QUERY_LEN) continue;
|
|
let best = 0;
|
|
for (const [term, tf2] of tf) {
|
|
if (!partialMatch(t, term)) continue;
|
|
const w = PARTIAL_WEIGHT * (1 + Math.log(tf2)) * idf(term);
|
|
if (w > best) best = w;
|
|
}
|
|
s += best;
|
|
}
|
|
scores[i] = s;
|
|
}
|
|
|
|
const ranked = [];
|
|
for (let i = 0; i < index.N; i++) {
|
|
if (scores[i] > 0) ranked.push({ i, s: scores[i] });
|
|
}
|
|
ranked.sort((a, b) => b.s - a.s);
|
|
return ranked.slice(0, k).map(r => index.topics[r.i]);
|
|
}
|