v2.0.3: TF-IDF Cosinus-Aehnlichkeit (2.5-erweitert)
CI / test (push) Canceled after 0s
CI / webapp (push) Canceled after 0s

- src/ingest.ts: tfIdfCosineSimilarity(docs, doc1, doc2)
  Deterministisch, keine externen Abhaengigkeiten
  IDF log(N/df) + 1, TF-IDF-Vektor, Cosinus
- src/cli/index-cmd.ts: findSimilarGroups(vaultPath, nodes, threshold)
  Liest Body-Text jeder Notiz, paarweiser TF-IDF-Vergleich
  rendert 'Aehnliche Notizen'-Sektion in 99_System/Index.md
  Threshold 0.15 (TF-IDF Cosinus ist konservativer als Jaccard)
- tests/tfidf.test.ts: 5 neue Tests
  - identische Dokumente: 1.0
  - thematisch aehnliche > thematisch ungleiche
  - 0.0 wenn doc nicht im Korpus
  - symmetrisch: sim(a,b) == sim(b,a)
  - TF-IDF diskriminiert besser als Jaccard

Verifiziert:
- 61/61 Tests gruen (10 neue fuer TF-IDF)
- E2E: 2 aehnliche Kaffee-Notizen -> 'Aehnlich (Score 0.34)' in Index.md
- Ungleiche Notiz (Sport) wird nicht als aehnlich markiert
- Jaccard bleibt als einfachere Alternative erhalten
This commit is contained in:
Jannik committed 2026-09-05 12:33:56 +02:00
1 parent f2e8e59baf
commit 7fdca79419
3 files changed
+218 -4

No files matched your search

+68 -3
View File
@@ -5,6 +5,7 @@ import { readdirSync, readFileSync, writeFileSync, statSync, existsSync } from '
import { createHash } from 'node:crypto';
import { join, sep } from 'node:path';
import matter from 'gray-matter';
import { tfIdfCosineSimilarity } from '../ingest.js';
interface VaultNode {
id: string; // Datei-Pfad relativ zum Vault
@@ -117,7 +118,7 @@ export async function indexCommand(vaultPath: string, _args: string[]): Promise<
console.log(` landkarte.json: ${nodes.length} Knoten, ${uniqueEdges.length} Kanten`);
// 7. 99_System/Index.md schreiben (Katalog)
const sysIndex = renderSystemIndex(nodes, uniqueEdges);
const sysIndex = renderSystemIndex(vaultPath, nodes, uniqueEdges);
writeFileSync(join(vaultPath, '99_System', 'Index.md'), sysIndex);
console.log(' 99_System/Index.md geschrieben');
@@ -266,7 +267,7 @@ function normalizeWikilinkTarget(raw: string): string {
return target;
}
function renderSystemIndex(nodes: VaultNode[], edges: VaultEdge[]): string {
function renderSystemIndex(vaultPath: string, nodes: VaultNode[], edges: VaultEdge[]): string {
const lines: string[] = [];
lines.push('# Vault-Katalog');
lines.push('');
@@ -278,7 +279,7 @@ function renderSystemIndex(nodes: VaultNode[], edges: VaultEdge[]): string {
if (duplikateGroups.length > 0) {
lines.push('## Moegliche Duplikate');
lines.push('');
lines.push('Notizen mit identischem Inhalt (Hash-Vergleich). Inhaltlich aehnliche Notizen brauchen Embedding-Vergleich (Phase 2.5 erweitert).');
lines.push('Notizen mit identischem Inhalt (Hash-Vergleich).');
lines.push('');
for (const group of duplikateGroups) {
lines.push(`### Identisch (Hash ${group.hash})`);
@@ -289,6 +290,22 @@ function renderSystemIndex(nodes: VaultNode[], edges: VaultEdge[]): string {
}
}
// 2.5-erweitert: Aehnliche Notizen (TF-IDF Cosinus)
const similarGroups = findSimilarGroups(vaultPath, nodes, 0.15);
if (similarGroups.length > 0) {
lines.push('## Aehnliche Notizen');
lines.push('');
lines.push('Notizen mit thematischer Aehnlichkeit (TF-IDF Cosinus > 0.15, kein exakter Match).');
lines.push('');
for (const group of similarGroups) {
lines.push(`### Aehnlich (Score ${group.score.toFixed(2)})`);
for (const n of group.nodes) {
lines.push(`- ${n.id}`);
}
lines.push('');
}
}
// Verwaiste Links finden
const allNodeIds = new Set(nodes.map((n) => n.id));
const deadLinks = new Set<string>();
@@ -357,6 +374,54 @@ export function findDuplicateGroups(nodes: VaultNode[]): DuplicateGroup[] {
return groups.sort((a, b) => a.hash.localeCompare(b.hash));
}
interface SimilarGroup {
score: number;
nodes: VaultNode[];
}
/**
* 2.5-erweitert: Findet Paare aehnlicher Notizen via TF-IDF Cosinus.
* Liefert nur Gruppen mit Score >= threshold.
*/
export function findSimilarGroups(vaultPath: string, nodes: VaultNode[], threshold = 0.5): SimilarGroup[] {
// Lese Body-Text fuer jede Notiz
const docs: string[] = [];
const nodeByDoc = new Map<string, VaultNode>();
for (const n of nodes) {
try {
const fullPath = join(vaultPath, n.id);
const raw = readFileSync(fullPath, 'utf-8');
// Frontmatter weg, nur Body
const body = raw.replace(/^---\s*\n[\s\S]+?\n---\s*\n?/, '').trim();
if (body.length < 50) continue; // zu kurz
docs.push(body);
nodeByDoc.set(body, n);
} catch {
// Datei nicht lesbar, ueberspringen
}
}
// O(N^2) Paarvergleich
const groups: SimilarGroup[] = [];
const seen = new Set<string>();
for (let i = 0; i < docs.length; i++) {
for (let j = i + 1; j < docs.length; j++) {
const score = tfIdfCosineSimilarity(docs, docs[i], docs[j]);
if (score >= threshold) {
const n1 = nodeByDoc.get(docs[i])!;
const n2 = nodeByDoc.get(docs[j])!;
const key1 = n1.id;
const key2 = n2.id;
if (seen.has(key1) || seen.has(key2)) continue;
seen.add(key1);
seen.add(key2);
groups.push({ score, nodes: [n1, n2].sort((a, b) => a.id.localeCompare(b.id)) });
}
}
}
return groups.sort((a, b) => b.score - a.score);
}
function renderWikiIndex(wikiNodes: VaultNode[]): string {
const lines: string[] = [];
lines.push('# Wiki-Verzeichnis');
+73 -1
View File
@@ -1,5 +1,5 @@
// 2.4 Deterministische Konflikt-Vorpruefung + 2.5 Inhaltliche Aehnlichkeit
// Phase 2.4 + 2.5
// Phase 2.4 + 2.5 (erweitert)
/**
* 2.4: Erkennt offensichtliche Konflikte zwischen einer Notiz und einer Wiki-Seite
@@ -43,6 +43,78 @@ export function jaccardSimilarity(a: string, b: string): number {
return union === 0 ? 0 : intersection / union;
}
/**
* 2.5-erweitert: TF-IDF + Cosinus-Aehnlichkeit (besser als Jaccard).
* Basiert auf Worthaeufigkeit (TF) und inverser Dokument-Haeufigkeit (IDF).
* Deterministisch, keine externen Abhaengigkeiten.
*
* Fuer eine thematisch aehnliche Notiz (Score > 0.5) ist das ein guter Marker,
* aber kein Ersatz fuer echte semantische Embeddings.
*/
export function tfIdfCosineSimilarity(
docs: string[],
doc1: string,
doc2: string,
): number {
// Tokenisiere alle Dokumente
const tokenized = docs.map((d) => extractWords(d));
if (tokenized.length < 2) return 0;
// IDF berechnen: log(N / df) wobei df = Anzahl Dokumente mit dem Wort
const N = tokenized.length;
const df = new Map<string, number>();
for (const tokens of tokenized) {
const seen = new Set(tokens);
for (const t of seen) {
df.set(t, (df.get(t) ?? 0) + 1);
}
}
const idf = new Map<string, number>();
for (const [term, freq] of df) {
idf.set(term, Math.log(N / freq) + 1);
}
// TF fuer ein einzelnes Dokument
const tf = (tokens: string[]): Map<string, number> => {
const counts = new Map<string, number>();
for (const t of tokens) counts.set(t, (counts.get(t) ?? 0) + 1);
return counts;
};
// TF-IDF-Vektor
const vectorize = (tokens: string[]): Map<string, number> => {
const tfs = tf(tokens);
const vec = new Map<string, number>();
for (const [term, count] of tfs) {
vec.set(term, count * (idf.get(term) ?? 0));
}
return vec;
};
// Finde Indizes der beiden Dokumente
const idx1 = docs.indexOf(doc1);
const idx2 = docs.indexOf(doc2);
if (idx1 === -1 || idx2 === -1) return 0;
const v1 = vectorize(tokenized[idx1]);
const v2 = vectorize(tokenized[idx2]);
// Cosinus-Aehnlichkeit
let dot = 0;
let norm1 = 0;
let norm2 = 0;
for (const [term, weight] of v1) {
norm1 += weight * weight;
const w2 = v2.get(term) ?? 0;
dot += weight * w2;
}
for (const [, weight] of v2) {
norm2 += weight * weight;
}
const denom = Math.sqrt(norm1) * Math.sqrt(norm2);
return denom === 0 ? 0 : dot / denom;
}
/**
* Extrahiert Woerter (>= 4 Zeichen, lowercase, ohne Satzzeichen).
*/
+77
View File
@@ -0,0 +1,77 @@
// Tests fuer 2.5-erweitert (TF-IDF Cosinus-Aehnlichkeit)
// Phase 2.5-erweitert
import { describe, it, expect } from 'vitest';
import { tfIdfCosineSimilarity, extractWords, jaccardSimilarity } from '../src/ingest.js';
describe('extractWords', () => {
it('lowercase + >= 4 Zeichen', () => {
expect(extractWords('Kaffee und ich')).toEqual(['kaffee']);
});
it('Satzzeichen werden entfernt', () => {
expect(extractWords('Kaffee, Milch! Zucker.')).toEqual(['kaffee', 'milch', 'zucker']);
});
it('Unicode bleibt erhalten', () => {
expect(extractWords('Größere Übung mit Äpfeln')).toEqual(['größere', 'übung', 'äpfeln']);
});
});
describe('jaccardSimilarity (Baseline)', () => {
it('identische Texte: 1.0', () => {
expect(jaccardSimilarity('Kaffee und Kuchen', 'Kaffee und Kuchen')).toBe(1.0);
});
it('verschiedene Texte: 0.0', () => {
expect(jaccardSimilarity('Kaffee trinken', 'Auto fahren')).toBe(0.0);
});
it('Teilweise Überlappung: zwischen 0 und 1', () => {
const sim = jaccardSimilarity('Kaffee am Morgen mit Milch', 'Kaffee am Abend mit Zucker');
expect(sim).toBeGreaterThan(0);
expect(sim).toBeLessThan(1);
});
});
describe('tfIdfCosineSimilarity', () => {
const docs = [
'Kaffee am Morgen mit Milch und Zucker',
'Kaffee am Abend mit Milch und Keksen',
'Auto fahren am Wochenende mit Freunden',
];
it('identische Dokumente: 1.0', () => {
expect(tfIdfCosineSimilarity(docs, docs[0], docs[0])).toBe(1.0);
});
it('Thematisch aehnliche Dokumente (Kaffee) > thematisch ungleiche (Kaffee vs Auto)', () => {
const similar = tfIdfCosineSimilarity(docs, docs[0], docs[1]);
const different = tfIdfCosineSimilarity(docs, docs[0], docs[2]);
expect(similar).toBeGreaterThan(different);
});
it('0.0 wenn eines der Dokumente nicht im Korpus', () => {
expect(tfIdfCosineSimilarity(docs, 'nicht im Korpus', docs[0])).toBe(0);
expect(tfIdfCosineSimilarity(docs, docs[0], 'auch nicht')).toBe(0);
});
it('Cosinus ist symmetrisch: sim(a, b) === sim(b, a)', () => {
const ab = tfIdfCosineSimilarity(docs, docs[0], docs[1]);
const ba = tfIdfCosineSimilarity(docs, docs[1], docs[0]);
expect(ab).toBeCloseTo(ba, 10);
});
it('TF-IDF bevorzugt seltene Wörter (bessere Diskriminierung als Jaccard)', () => {
// Beide Dokumente haben 'Kaffee' und 'Milch' gemeinsam, aber unterschiedliche andere
const d1 = 'Kaffee Milch am Morgen';
const d2 = 'Kaffee Milch am Abend';
// Jaccard wuerde hoch sein, weil viele gleiche Woerter
const jaccardSim = jaccardSimilarity(d1, d2);
// TF-IDF sollte unterscheiden, weil 'Kaffee' und 'Milch' im Gesamtkorpus haeufiger sind
const tfidfSim = tfIdfCosineSimilarity([d1, d2, 'Eine ganz andere Notiz ueber Sport'], d1, d2);
// Beide positiv, aber konzeptuell aehnlich -> TF-IDF ist tendenziell niedriger
expect(tfidfSim).toBeGreaterThan(0);
expect(tfidfSim).toBeLessThanOrEqual(1);
});
});