diff --git a/docs/r42-spec.md b/docs/r42-spec.md
index a9643b5..ff3d05c 100644
--- a/docs/r42-spec.md
+++ b/docs/r42-spec.md
@@ -18,6 +18,8 @@ client-side and is grounded by local TF-IDF retrieval — **no vector database**
context is truncated with a notice.
- A greeting message seeds an empty thread.
- Each turn calls `callLLM` (fast/standard Claude tier — low latency matters for chat).
+- The chat header has a **clear** button (trash icon). It confirms, then wipes
+ `chat:thread:{userId}` and reseeds the greeting via `clearThread` in `useChat.js`.
---
@@ -25,13 +27,21 @@ client-side and is grounded by local TF-IDF retrieval — **no vector database**
`buildKbContext` in `rag.js`:
1. Build / reuse the TF-IDF index over `topics` (`src/lib/retrieval.js`).
-2. Retrieve the top **10** topics for the user's message.
+2. Retrieve the top **10** topics for the user's message. Scoring is exact-token
+ TF-IDF **plus a compound-word fallback**: an unmatched query token (≥6 chars)
+ also matches a document term when they share a ≥6-char stem or one contains
+ the other, at a reduced weight. This recovers Dutch compounds — e.g. a
+ `pensioen` query matches `pensioenregeling` and `partnerpensioen`.
3. Always include topics whose `id` or `label` appears verbatim in the message.
4. Include relations only when **both** endpoints are in the retrieved set.
-5. For explicitly mentioned topics, inject up to ~1200 chars of their generated
- content.
+5. Inject up to ~1000 chars of generated content for up to **5** topics —
+ verbatim-mentioned first, then the highest-ranked retrieved ones — so a query
+ that never names a topic exactly still gets rich content for what it matched.
6. Append a short KB hash so the cached context busts when topics change.
+If the summarised context is still too thin, R42 can call the `lookup_topic`
+tool to pull a topic's full description and learning content on demand.
+
The system prompt (`prompts.js`) is assembled as cacheable blocks: a stable
preamble (role, tasks, style, "answer only from the KB"), the KB context block, and
a per-turn tail with the user's name and admin/non-admin flag.
diff --git a/knowledge-base/pensioenpremie-proforma-berekening.md b/knowledge-base/pensioenpremie-proforma-berekening.md
new file mode 100644
index 0000000..fbec0ae
--- /dev/null
+++ b/knowledge-base/pensioenpremie-proforma-berekening.md
@@ -0,0 +1,118 @@
+# pensioenpremie berekenen en verwerken in een proforma-loonstrook
+
+## doel
+
+Handleiding voor het handmatig berekenen en verwerken van de pensioenpremie bij het
+opstellen van een proforma-salarisspecificatie in NMBRS, wanneer de pensioengrondslag
+niet automatisch op de proforma-berekening verschijnt.
+
+## wanneer van toepassing
+
+Bij het opstellen van een proforma-loonstrook (bijvoorbeeld ter voorbereiding van een
+aanbod aan een kandidaat) toont NMBRS geen automatische pensioenpremie-regel. Deze moet
+dan handmatig worden toegevoegd volgens onderstaande methode.
+
+## benodigde gegevens vooraf
+
+Voordat je begint moet je de volgende vier parameters hebben. Geen van deze staat op de
+proforma zelf.
+
+| parameter | waar te vinden |
+| --- | --- |
+| pensioenfranchise (Respellion / a.s.r. Doen Pensioen) | `pension-scheme-and-benefits.md` of het a.s.r.-polisblad; kan afwijken van het wettelijk minimum |
+| verdeelsleutel werkgever/werknemer | `pension-scheme-and-benefits.md` of het a.s.r.-contract |
+| definitie pensioengevend salaris | a.s.r.-polisvoorwaarden — bevestig welke looncomponenten meetellen (onkostenvergoedingen in elk geval niet) |
+| fiscaal maximum pensioengevend salaris (aftoppingsgrens) | jaarlijkse publicatie belastingdienst/SZW, zie tabel hieronder |
+
+Deze vier punten waren bij het opstellen van dit document nog niet allemaal bevestigd
+voor Respellion specifiek — zie "openstaande vragen" onderaan.
+
+## rekenstappen
+
+1. **pensioengevend salaris (jaarbasis) bepalen**
+ Standaard: 12 × maandsalaris + vakantietoeslag (8%).
+ `pensioengevend salaris = 12 × bruto maandsalaris × 1,08`
+ Onkostenvergoedingen (reiskosten, telefoonvergoeding) tellen niet mee.
+
+2. **aftoppen op het fiscaal maximum**
+ `gemaximeerd pensioengevend salaris = min(pensioengevend salaris, aftoppingsgrens)`
+
+3. **franchise aftrekken**
+ `pensioengrondslag = gemaximeerd pensioengevend salaris − franchise`
+
+4. **parttimefactor toepassen**
+ `pensioengrondslag (parttime) = pensioengrondslag × parttimefactor`
+ Parttimefactor = contracturen / 40 (of de fulltime-norm die Respellion hanteert).
+
+5. **premie berekenen**
+ `jaarpremie = pensioengrondslag (parttime) × 5%`
+ `maandpremie = jaarpremie / 12`
+
+6. **verdelen werkgever/werknemer**
+ `werknemersdeel = maandpremie × werknemerspercentage`
+ `werkgeversdeel = maandpremie − werknemersdeel`
+
+## fiscale kernbedragen 2026
+
+| bedrag | waarde 2026 | bron |
+| --- | --- | --- |
+| aftoppingsgrens pensioengevend salaris | € 137.800 | belastingdienst/SZW, voorlopige bedragen 2026 |
+| minimale AOW-franchise (Wtp-regelingen / beschikbare premieregelingen) | € 19.172 | belastingdienst/SZW, voorlopige bedragen 2026 |
+
+Deze twee bedragen worden jaarlijks (eind december) opnieuw gepubliceerd. Controleer bij
+elke proforma-berekening of de bedragen nog actueel zijn.
+
+## verwerking in NMBRS — fiscale nuance
+
+Het werknemersdeel van de pensioenpremie is een pre-tax inhouding:
+
+- het verlaagt de grondslag voor de loonheffing (de kolom "Tabel" op de proforma-uitdraai)
+- het verlaagt **niet** het SV-loon (de kolom "SVW" blijft ongewijzigd)
+
+Trek het werknemersdeel dus niet rechtstreeks af van het bestaande netto-bedrag. Voer de
+premie in met dezelfde paycode die in de reguliere (niet-proforma) loonrun voor
+pensioenpremie werknemer/werkgever is ingericht — controleer de codeconfiguratie in
+NMBRS (niet de bedragen) op de vlag "aftrekbaar voor loonheffing, niet voor SVW". Laat
+NMBRS de loonheffing zelf herberekenen over de verlaagde grondslag; reken dit niet
+handmatig na.
+
+Het werkgeversdeel is een werkgeverslast (vergelijkbaar met de bestaande WGA/Aof-regels
+op de proforma) en raakt het netto van de werknemer niet, wel de totale loonkosten.
+
+## rekenvoorbeeld
+
+Uitgangspunt: proforma fulltime (40 uur/week), bruto maandsalaris € 6.700, 100%
+dienstverband.
+
+1. Pensioengevend salaris (jaar): 12 × 6.700 × 1,08 = € 86.832
+2. Aftopping: 86.832 < 137.800 → geen aftopping
+3. Franchise (voorbeeld met het wettelijk minimum € 19.172 — niet bevestigd als
+ Respellion-waarde): 86.832 − 19.172 = € 67.660
+4. Parttimefactor: 100% → geen aanpassing
+5. Premie: 67.660 × 5% = € 3.383 per jaar = € 281,92 per maand
+6. Verdeling (voorbeeld 50/50 — verdeelsleutel niet bevestigd): € 140,96 werknemer,
+ € 140,96 werkgever
+
+Dit rekenvoorbeeld gebruikt aannames voor franchise en verdeelsleutel die nog niet
+bevestigd zijn voor Respellion — zie "openstaande vragen".
+
+## openstaande vragen
+
+- Exacte pensioenfranchise van de Respellion a.s.r. Doen Pensioen-regeling (mogelijk
+ hoger dan het wettelijk minimum van € 19.172).
+- Verdeelsleutel werkgever/werknemer voor de 5% premie.
+- Of vakantietoeslag standaard meetelt in het pensioengevend salaris volgens de
+ a.s.r.-polis, of dat er een afwijkende definitie geldt.
+- Exacte NMBRS-paycode voor pensioenpremie werknemer/werkgever (te vinden in een
+ bestaande productierun of de paycode-lijst).
+
+Zodra deze vier punten bevestigd zijn, kan dit document worden aangevuld met de
+definitieve waarden in plaats van de wettelijke minimum-aannames.
+
+## bronnen
+
+- Proforma-salarisspecificatie Respellion B.V. (voorbeeldcasus, dit document)
+- Belastingdienst/SZW: voorlopige bedragen AOW-franchise en maximum pensioengevend
+ salaris 2026
+- `pension-scheme-and-benefits.md` (Respellion-handboek) — te raadplegen voor de
+ resterende openstaande vragen
diff --git a/src/components/chat/ChatWindow.jsx b/src/components/chat/ChatWindow.jsx
index 9e64c13..8b47b3d 100644
--- a/src/components/chat/ChatWindow.jsx
+++ b/src/components/chat/ChatWindow.jsx
@@ -1,4 +1,5 @@
import { useCallback, useEffect, useRef, useState } from 'react';
+import { Trash2 } from 'lucide-react';
import Mark from '../ui/Mark';
import ChatMessage from './ChatMessage';
import { useChat } from './useChat';
@@ -6,7 +7,7 @@ import { kbStore } from '../../lib/kbStore';
import { BOT_NAME, STRINGS } from './prompts';
export default function ChatWindow({ user, isAdmin, onClose }) {
- const { messages, isThinking, send } = useChat({ user, isAdmin });
+ const { messages, isThinking, send, clearThread } = useChat({ user, isAdmin });
const [draft, setDraft] = useState('');
const bodyRef = useRef(null);
const inputRef = useRef(null);
@@ -60,6 +61,14 @@ export default function ChatWindow({ user, isAdmin, onClose }) {
setDecided(prev => ({ ...prev, [msgId]: 'rejected' }));
}, []);
+ const handleClear = useCallback(() => {
+ if (isThinking) return;
+ if (!window.confirm(STRINGS.clearConfirm)) return;
+ setDecided({});
+ clearThread();
+ inputRef.current?.focus();
+ }, [isThinking, clearThread]);
+
const renderedMessages = messages.map(m => {
if (!m.suggestion) return m;
const status = decided[m.id] || m.suggestion.status || 'pending';
@@ -81,14 +90,26 @@ export default function ChatWindow({ user, isAdmin, onClose }) {
{BOT_NAME}
{STRINGS.status}
-
+
+
+
+
diff --git a/src/components/chat/__tests__/rag.test.js b/src/components/chat/__tests__/rag.test.js
new file mode 100644
index 0000000..a87289f
--- /dev/null
+++ b/src/components/chat/__tests__/rag.test.js
@@ -0,0 +1,63 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+
+// In-memory KB the mocked db serves from.
+const store = {
+ topics: [],
+ relations: [],
+ content: new Map(),
+};
+
+vi.mock('../../../lib/db', () => ({
+ getTopics: vi.fn(async () => store.topics),
+ getRelations: vi.fn(async () => store.relations),
+ getContent: vi.fn(async (id) => store.content.get(id) ?? null),
+}));
+
+import { buildKbContext } from '../rag';
+
+beforeEach(() => {
+ store.topics = [];
+ store.relations = [];
+ store.content = new Map();
+});
+
+describe('buildKbContext', () => {
+ it('reports an empty graph', async () => {
+ const { context, allTopics } = await buildKbContext('pensioen');
+ expect(context).toMatch(/leeg/);
+ expect(allTopics).toEqual([]);
+ });
+
+ it('injects deep content for a retrieved topic that is not named verbatim', async () => {
+ store.topics = [
+ { id: 'pensioenregeling', label: 'Pensioenregeling', type: 'concept', description: 'De beschikbare premieregeling.' },
+ { id: 'onboarding-buddy', label: 'Onboarding Buddy', type: 'role', description: 'Begeleidt nieuwe medewerkers.' },
+ ];
+ store.content.set('pensioenregeling', {
+ article: 'De premie is 10% van de pensioengrondslag; werkgever en werknemer betalen elk 50%.',
+ });
+
+ // "pensioen" is never a verbatim topic id/label, but the compound-word
+ // matching should retrieve pensioenregeling and pull its article body in.
+ const { context, retrievedTopics } = await buildKbContext('wat dekt mijn pensioen?');
+
+ expect(retrievedTopics.map(t => t.id)).toContain('pensioenregeling');
+ expect(context).toMatch(/DIEPERE INHOUD/);
+ expect(context).toMatch(/10% van de pensioengrondslag/);
+ });
+
+ it('only includes relations whose endpoints are both in the selection', async () => {
+ store.topics = [
+ { id: 'pensioenregeling', label: 'Pensioenregeling', type: 'concept', description: 'De beschikbare premieregeling.' },
+ { id: 'partnerpensioen', label: 'Partnerpensioen', type: 'concept', description: 'Uitkering aan de partner.' },
+ ];
+ store.relations = [
+ { source: 'partnerpensioen', target: 'pensioenregeling', type: 'part_of' },
+ { source: 'pensioenregeling', target: 'iets-anders', type: 'related_to' },
+ ];
+
+ const { context } = await buildKbContext('pensioen');
+ expect(context).toMatch(/partnerpensioen --part_of--> pensioenregeling/);
+ expect(context).not.toMatch(/iets-anders/);
+ });
+});
diff --git a/src/components/chat/chat.css b/src/components/chat/chat.css
index e5a0691..230d741 100644
--- a/src/components/chat/chat.css
+++ b/src/components/chat/chat.css
@@ -87,8 +87,13 @@
border-radius: 999px;
background: var(--sage);
}
-.r42-window-hd-x {
+.r42-window-hd-actions {
margin-left: auto;
+ display: flex;
+ align-items: center;
+ gap: 2px;
+}
+.r42-window-hd-x {
color: rgba(236, 233, 233, 0.7);
background: transparent;
border: none;
@@ -99,6 +104,18 @@
border-radius: var(--r-sm);
}
.r42-window-hd-x:hover { background: rgba(236, 233, 233, 0.1); }
+.r42-window-hd-clear {
+ color: rgba(236, 233, 233, 0.7);
+ background: transparent;
+ border: none;
+ cursor: pointer;
+ padding: 6px;
+ border-radius: var(--r-sm);
+ display: grid;
+ place-items: center;
+}
+.r42-window-hd-clear:hover { background: rgba(236, 233, 233, 0.1); }
+.r42-window-hd-clear:disabled { opacity: 0.4; cursor: default; }
.r42-window-body {
flex: 1;
diff --git a/src/components/chat/prompts.js b/src/components/chat/prompts.js
index 040f44b..32463cb 100644
--- a/src/components/chat/prompts.js
+++ b/src/components/chat/prompts.js
@@ -21,6 +21,8 @@ export const STRINGS = {
suggestionDismissed: 'Oké, niets gedaan.',
closeAria: 'Sluit chatvenster',
openAria: 'Open R42 chatbot',
+ clearAria: 'Wis gesprek',
+ clearConfirm: 'Dit gesprek wissen? Dit kan niet ongedaan worden gemaakt.',
};
const STABLE_PREAMBLE = [
@@ -35,11 +37,16 @@ const STABLE_PREAMBLE = [
`JE KENNIS:`,
`Je kennis is beperkt tot de Respellion-kennisgraaf die hieronder volgt. Als een vraag duidelijk buiten dit bereik valt, zeg dat dan eerlijk en stel voor dat de gebruiker de bron toevoegt via Admin → Sources.`,
``,
+ `NAUWKEURIGHEID (belangrijk):`,
+ `- Baseer je antwoord uitsluitend op de KENNISGRAAF en DIEPERE INHOUD hieronder; verzin niets.`,
+ `- Gebruik ALLE relevante feiten die daar staan. Bij een vraag om details, bedragen, percentages, voorwaarden of een opsomming: noem elk relevant feit — vat niet samen ten koste van volledigheid.`,
+ `- Als de samenvattende KENNISGRAAF te dun is om de vraag volledig te beantwoorden, roep dan éérst de tool "lookup_topic" aan (met het exacte topic-id) voordat je concludeert dat je het niet weet.`,
+ ``,
`KENNISGRAAF VERFIJNEN:`,
`Wanneer de gebruiker iets noemt dat duidelijk een nieuw topic, nieuwe relatie, proces of rol is — en dat nog niet in de kennisgraaf staat — gebruik dan de tool "propose_graph_delta" om een voorstel te maken. Verzin niets: stel alleen iets voor als de gebruiker het concreet noemt. Stel maximaal 3 topics en 5 relaties per beurt voor.`,
``,
`STIJL:`,
- `- Houd antwoorden onder de 4 zinnen tenzij de gebruiker om uitleg vraagt.`,
+ `- Zo kort als kan, zo volledig als nodig: houd eenvoudige antwoorden onder de 4 zinnen, maar som bij details- of opsommingsvragen álle relevante feiten op (desnoods als korte lijst met streepjes).`,
`- Geen markdown-headers; gewone Nederlandse tekst.`,
`- Bij onzekerheid: "Ik weet het niet zeker — controleer dit in het handboek."`,
].join('\n');
diff --git a/src/components/chat/rag.js b/src/components/chat/rag.js
index 2fbb805..35c0344 100644
--- a/src/components/chat/rag.js
+++ b/src/components/chat/rag.js
@@ -2,6 +2,13 @@ import * as db from '../../lib/db';
import { buildIndex, retrieveTopK } from '../../lib/retrieval';
const TOP_K = 10;
+// How many topics get their full article body injected (not just the short
+// description). Verbatim-mentioned topics come first, then the highest-ranked
+// retrieved ones, so a query that never names a topic exactly still gets rich
+// content for what it matched.
+const DEEP_CONTENT_LIMIT = 5;
+const DEEP_SNIPPET_CHARS = 1000;
+const DESC_SNIPPET_CHARS = 320;
async function sha256Hex(input) {
const enc = new TextEncoder().encode(input);
@@ -71,7 +78,7 @@ export async function buildKbContext(userMessage = '') {
const included = [...includedById.values()];
const topicLines = included.map(t => {
- const desc = (t.description || '').replace(/\s+/g, ' ').trim().slice(0, 200);
+ const desc = (t.description || '').replace(/\s+/g, ' ').trim().slice(0, DESC_SNIPPET_CHARS);
return `- ${t.id} (${t.type || 'concept'}) "${t.label}": ${desc}`;
});
@@ -85,19 +92,30 @@ export async function buildKbContext(userMessage = '') {
}
}
- const mentionedDeepContent = [];
- for (const id of mentionedIds) {
- const t = includedById.get(id);
- if (!t) continue;
- const content = await db.getContent(t.id).catch(() => null);
- if (!content) continue;
- let raw;
- if (typeof content === 'string') raw = content;
- else if (content.article) raw = typeof content.article === 'string' ? content.article : JSON.stringify(content.article);
- else raw = JSON.stringify(content);
- const snippet = raw.replace(/\s+/g, ' ').trim().slice(0, 1200);
- mentionedDeepContent.push(`### ${t.label}\n${snippet}`);
+ // Pick which topics get their full article body: verbatim mentions first,
+ // then the highest-ranked retrieved topics, capped at DEEP_CONTENT_LIMIT.
+ const deepIds = [];
+ for (const id of mentionedIds) deepIds.push(id);
+ for (const t of retrieved) {
+ if (deepIds.length >= DEEP_CONTENT_LIMIT) break;
+ if (!mentionedIds.has(t.id)) deepIds.push(t.id);
}
+ const deepBlocks = await Promise.all(
+ deepIds.slice(0, DEEP_CONTENT_LIMIT).map(async (id) => {
+ const t = includedById.get(id);
+ if (!t) return null;
+ const content = await db.getContent(id).catch(() => null);
+ if (!content) return null;
+ let raw;
+ if (typeof content === 'string') raw = content;
+ else if (content.article) raw = typeof content.article === 'string' ? content.article : JSON.stringify(content.article);
+ else raw = JSON.stringify(content);
+ const snippet = raw.replace(/\s+/g, ' ').trim().slice(0, DEEP_SNIPPET_CHARS);
+ if (!snippet) return null;
+ return `### ${t.label}\n${snippet}`;
+ }),
+ );
+ const mentionedDeepContent = deepBlocks.filter(Boolean);
const context = [
`KENNISGRAAF — RELEVANTE TOPICS (top ${included.length} van ${allTopics.length}):`,
@@ -106,7 +124,7 @@ export async function buildKbContext(userMessage = '') {
`KENNISGRAAF — RELATIES (binnen deze selectie):`,
relLines.length ? relLines.join('\n') : '(geen relaties binnen deze selectie)',
mentionedDeepContent.length
- ? `\n\nDIEPERE INHOUD (voor genoemde topics):\n${mentionedDeepContent.join('\n\n')}`
+ ? `\n\nDIEPERE INHOUD (volledige leerinhoud van de meest relevante topics — gebruik álle feiten hieruit die de vraag beantwoorden):\n${mentionedDeepContent.join('\n\n')}`
: '',
``,
`Als de relevante context hierboven te beperkt is, gebruik dan de tool "lookup_topic" om de volledige beschrijving en eventuele leerinhoud van een specifiek topic op te halen.`,
diff --git a/src/components/chat/useChat.js b/src/components/chat/useChat.js
index 788bddd..25aea90 100644
--- a/src/components/chat/useChat.js
+++ b/src/components/chat/useChat.js
@@ -92,6 +92,19 @@ export function useChat({ user, isAdmin }) {
setMessages(prev => prev.map(m => (m.id === id ? { ...m, ...patch } : m)));
}, []);
+ /** Wipe the persisted thread and reset to a fresh greeting. */
+ const clearThread = useCallback(() => {
+ seenDeltaKeys.current = new Set();
+ const greeting = {
+ id: `m_${Date.now()}`,
+ role: 'assistant',
+ content: STRINGS.greeting(user?.name || 'daar'),
+ ts: Date.now(),
+ };
+ setMessages([greeting]);
+ if (threadKey) storage.set(threadKey, [greeting]);
+ }, [user, threadKey]);
+
const send = useCallback(async (text) => {
const trimmed = (text || '').trim();
if (!trimmed || !user) return;
@@ -225,5 +238,6 @@ export function useChat({ user, isAdmin }) {
errored,
send,
updateMessage,
+ clearThread,
};
}
diff --git a/src/lib/__tests__/retrieval.test.js b/src/lib/__tests__/retrieval.test.js
index 6079138..ff0ca42 100644
--- a/src/lib/__tests__/retrieval.test.js
+++ b/src/lib/__tests__/retrieval.test.js
@@ -50,6 +50,31 @@ describe('buildIndex / retrieveTopK', () => {
expect(retrieveTopK(idx, 'kwantumfysica raketten')).toEqual([]);
});
+ it('matches Dutch compound words on a shared stem', () => {
+ const pensionTopics = [
+ { id: 'pensioenregeling', label: 'Pensioenregeling', description: 'De beschikbare premieregeling bij a.s.r. Doen Pensioen.' },
+ { id: 'partnerpensioen', label: 'Partnerpensioen', description: 'Uitkering aan de partner bij overlijden.' },
+ { id: 'reiskostenvergoeding', label: 'Reiskostenvergoeding', description: 'EUR 0,23 per kilometer voor woon-werkverkeer.' },
+ ];
+ const idx = buildIndex(pensionTopics);
+ // "pensioen" never appears as a standalone token in a label, yet the stem is
+ // a prefix of "pensioenregeling" and an infix of "partnerpensioen".
+ const hits = retrieveTopK(idx, 'wat dekt mijn pensioen?', 3).map(h => h.id);
+ expect(hits).toContain('pensioenregeling');
+ expect(hits).toContain('partnerpensioen');
+ expect(hits).not.toContain('reiskostenvergoeding');
+ });
+
+ it('does not partial-match on short shared prefixes', () => {
+ const topics = [
+ { id: 'onderhoud', label: 'Onderhoud', description: 'Technisch beheer van systemen.' },
+ ];
+ const idx = buildIndex(topics);
+ // "onderneming" shares only "onder" (5) with "onderhoud" — below the overlap
+ // needed for a query token this size to count.
+ expect(retrieveTopK(idx, 'onderneming')).toEqual([]);
+ });
+
it('caches the index per topics array reference', () => {
const idx1 = buildIndex(sampleTopics);
const idx2 = buildIndex(sampleTopics);
diff --git a/src/lib/retrieval.js b/src/lib/retrieval.js
index 36f69e3..b76bbc4 100644
--- a/src/lib/retrieval.js
+++ b/src/lib/retrieval.js
@@ -63,6 +63,29 @@ export function buildIndex(topics) {
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);
@@ -80,8 +103,19 @@ export function retrieveTopK(index, query, k = 10) {
let s = 0;
for (const t of qTokens) {
const f = tf.get(t);
- if (!f) continue;
- s += (1 + Math.log(f)) * idf(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;
}