v1.0.1: Boost related notes by frontmatter properties and shared tags

- EmbedSearch.searchSimilarToFile: +0.15 for notes linked via contextProperties
  frontmatter fields (related, collection, up, tags), +0.05 per shared tag (max 3)
- SearchResult: add optional `linked` field
- RelatedNotesView: show "verknüpft" badge for property-linked notes
- main.ts: pass contextProperties to EmbedSearch on init

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
svemagie
2026-03-05 02:32:43 +01:00
parent 32006149e7
commit ba8d4a1da1
8 changed files with 114 additions and 11 deletions
+43 -4
View File
@@ -32293,6 +32293,8 @@ var EmbedSearch = class {
constructor(app, modelId) { constructor(app, modelId) {
this.excludeFolders = []; this.excludeFolders = [];
// vault folder prefixes to skip // vault folder prefixes to skip
this.contextProperties = [];
// frontmatter keys whose links get a score boost
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
this.pipe = null; this.pipe = null;
this.cache = /* @__PURE__ */ new Map(); this.cache = /* @__PURE__ */ new Map();
@@ -32508,18 +32510,51 @@ var EmbedSearch = class {
return []; return [];
} }
} }
const linkedPaths = /* @__PURE__ */ new Set();
if (this.contextProperties.length > 0) {
const meta = this.app.metadataCache.getFileCache(file);
const links = meta?.frontmatterLinks ?? [];
for (const link of links) {
if (this.contextProperties.includes(link.key.split(".")[0])) {
const resolved = this.app.metadataCache.getFirstLinkpathDest(link.link, file.path);
if (resolved)
linkedPaths.add(resolved.path);
}
}
}
const fileMeta = this.app.metadataCache.getFileCache(file);
const fileTags = new Set(
(fileMeta?.tags ?? []).map((t) => t.tag.toLowerCase())
);
const scores = []; const scores = [];
for (const [path3, { vec }] of this.vecs) { for (const [path3, { vec }] of this.vecs) {
if (path3 === file.path) if (path3 === file.path)
continue; continue;
const s = this.cosine(qvec, vec); let s = this.cosine(qvec, vec);
if (s > 0.2) if (s < 0.15)
continue;
if (linkedPaths.has(path3)) {
s = Math.min(1, s + 0.15);
}
if (fileTags.size > 0) {
const otherMeta = this.app.metadataCache.getFileCache(this.vecs.get(path3).file);
const otherTags = (otherMeta?.tags ?? []).map((t) => t.tag.toLowerCase());
let sharedTags = 0;
for (const tag of otherTags) {
if (fileTags.has(tag))
sharedTags++;
if (sharedTags >= 3)
break;
}
if (sharedTags > 0)
s = Math.min(1, s + sharedTags * 0.05);
}
scores.push([path3, s]); scores.push([path3, s]);
} }
scores.sort((a, b) => b[1] - a[1]); scores.sort((a, b) => b[1] - a[1]);
return scores.slice(0, topK).map(([path3, score]) => { return scores.slice(0, topK).map(([path3, score]) => {
const { file: f } = this.vecs.get(path3); const { file: f } = this.vecs.get(path3);
return { file: f, score, excerpt: "", title: f.basename }; return { file: f, score, excerpt: "", title: f.basename, linked: linkedPaths.has(path3) };
}); });
} }
async search(query, topK = 8) { async search(query, topK = 8) {
@@ -33199,7 +33234,10 @@ var RelatedNotesView = class extends import_obsidian4.ItemView {
for (const r of results) { for (const r of results) {
const item = list.createDiv("vc-related-item"); const item = list.createDiv("vc-related-item");
const info = item.createDiv("vc-related-info"); const info = item.createDiv("vc-related-info");
info.createDiv({ cls: "vc-related-name", text: r.title }); const nameRow = info.createDiv("vc-related-name-row");
nameRow.createSpan({ cls: "vc-related-name", text: r.title });
if (r.linked)
nameRow.createSpan({ cls: "vc-related-linked", text: "verkn\xFCpft" });
const folder = r.file.parent?.path; const folder = r.file.parent?.path;
if (folder && folder !== "/") { if (folder && folder !== "/") {
info.createDiv({ cls: "vc-related-folder", text: folder }); info.createDiv({ cls: "vc-related-folder", text: folder });
@@ -33334,6 +33372,7 @@ var MemexChatPlugin = class extends import_obsidian5.Plugin {
} }
this.embedSearch = new EmbedSearch(this.app, this.settings.embeddingModel); this.embedSearch = new EmbedSearch(this.app, this.settings.embeddingModel);
this.embedSearch.excludeFolders = this.settings.embedExcludeFolders ?? []; this.embedSearch.excludeFolders = this.settings.embedExcludeFolders ?? [];
this.embedSearch.contextProperties = this.settings.contextProperties ?? [];
this.registerEvent( this.registerEvent(
this.app.vault.on("modify", (file) => { this.app.vault.on("modify", (file) => {
if (this.embedSearch && file instanceof import_obsidian5.TFile && file.extension === "md") if (this.embedSearch && file instanceof import_obsidian5.TFile && file.extension === "md")
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"id": "memex-chat", "id": "memex-chat",
"name": "Memex Chat", "name": "Memex Chat",
"version": "1.0.0", "version": "1.0.1",
"minAppVersion": "1.4.0", "minAppVersion": "1.4.0",
"description": "Chat with your Obsidian vault using Claude AI — semantic context retrieval, @ mentions, thread history.", "description": "Chat with your Obsidian vault using Claude AI — semantic context retrieval, @ mentions, thread history.",
"author": "Sven", "author": "Sven",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "memex-chat", "name": "memex-chat",
"version": "1.0.0", "version": "1.0.1",
"description": "Obsidian plugin: Chat with your vault using Claude AI", "description": "Obsidian plugin: Chat with your vault using Claude AI",
"main": "main.js", "main": "main.js",
"scripts": { "scripts": {
+41 -3
View File
@@ -27,6 +27,7 @@ export class EmbedSearch {
private app: App; private app: App;
private modelId: string; private modelId: string;
excludeFolders: string[] = []; // vault folder prefixes to skip excludeFolders: string[] = []; // vault folder prefixes to skip
contextProperties: string[] = []; // frontmatter keys whose links get a score boost
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
private pipe: ((text: string, opts: object) => Promise<{ data: Float32Array }>) | null = null; private pipe: ((text: string, opts: object) => Promise<{ data: Float32Array }>) | null = null;
private cache: Map<string, EmbedCacheEntry> = new Map(); // vaultPath → entry private cache: Map<string, EmbedCacheEntry> = new Map(); // vaultPath → entry
@@ -278,16 +279,53 @@ export class EmbedSearch {
qvec = await this.embedWithTimeout(text); qvec = await this.embedWithTimeout(text);
} catch { return []; } } catch { return []; }
} }
// Collect paths explicitly linked via contextProperty frontmatter fields
const linkedPaths = new Set<string>();
if (this.contextProperties.length > 0) {
const meta = this.app.metadataCache.getFileCache(file);
const links = meta?.frontmatterLinks ?? [];
for (const link of links) {
if (this.contextProperties.includes(link.key.split(".")[0])) {
const resolved = this.app.metadataCache.getFirstLinkpathDest(link.link, file.path);
if (resolved) linkedPaths.add(resolved.path);
}
}
}
// Collect tags of the current file
const fileMeta = this.app.metadataCache.getFileCache(file);
const fileTags = new Set<string>(
(fileMeta?.tags ?? []).map((t) => t.tag.toLowerCase())
);
const scores: Array<[string, number]> = []; const scores: Array<[string, number]> = [];
for (const [path, { vec }] of this.vecs) { for (const [path, { vec }] of this.vecs) {
if (path === file.path) continue; if (path === file.path) continue;
const s = this.cosine(qvec, vec); let s = this.cosine(qvec, vec);
if (s > 0.2) scores.push([path, s]); if (s < 0.15) continue; // broader pre-filter to allow boosted notes through
if (linkedPaths.has(path)) {
s = Math.min(1.0, s + 0.15);
}
if (fileTags.size > 0) {
const otherMeta = this.app.metadataCache.getFileCache(this.vecs.get(path)!.file);
const otherTags = (otherMeta?.tags ?? []).map((t) => t.tag.toLowerCase());
let sharedTags = 0;
for (const tag of otherTags) {
if (fileTags.has(tag)) sharedTags++;
if (sharedTags >= 3) break;
}
if (sharedTags > 0) s = Math.min(1.0, s + sharedTags * 0.05);
}
scores.push([path, s]);
} }
scores.sort((a, b) => b[1] - a[1]); scores.sort((a, b) => b[1] - a[1]);
return scores.slice(0, topK).map(([path, score]) => { return scores.slice(0, topK).map(([path, score]) => {
const { file: f } = this.vecs.get(path)!; const { file: f } = this.vecs.get(path)!;
return { file: f, score, excerpt: "", title: f.basename }; return { file: f, score, excerpt: "", title: f.basename, linked: linkedPaths.has(path) };
}); });
} }
+3 -1
View File
@@ -68,7 +68,9 @@ export class RelatedNotesView extends ItemView {
const item = list.createDiv("vc-related-item"); const item = list.createDiv("vc-related-item");
const info = item.createDiv("vc-related-info"); const info = item.createDiv("vc-related-info");
info.createDiv({ cls: "vc-related-name", text: r.title }); const nameRow = info.createDiv("vc-related-name-row");
nameRow.createSpan({ cls: "vc-related-name", text: r.title });
if (r.linked) nameRow.createSpan({ cls: "vc-related-linked", text: "verknüpft" });
// Folder path (dimmed) // Folder path (dimmed)
const folder = r.file.parent?.path; const folder = r.file.parent?.path;
+2
View File
@@ -5,6 +5,8 @@ export interface SearchResult {
score: number; score: number;
excerpt: string; excerpt: string;
title: string; title: string;
/** True when the note is explicitly linked via a contextProperty frontmatter field */
linked?: boolean;
} }
/** Minimal TF-IDF search engine over the Obsidian vault */ /** Minimal TF-IDF search engine over the Obsidian vault */
+1
View File
@@ -146,6 +146,7 @@ export default class MemexChatPlugin extends Plugin {
} }
this.embedSearch = new EmbedSearch(this.app, this.settings.embeddingModel); this.embedSearch = new EmbedSearch(this.app, this.settings.embeddingModel);
this.embedSearch.excludeFolders = this.settings.embedExcludeFolders ?? []; this.embedSearch.excludeFolders = this.settings.embedExcludeFolders ?? [];
this.embedSearch.contextProperties = this.settings.contextProperties ?? [];
// Re-embed modified notes as they change // Re-embed modified notes as they change
this.registerEvent( this.registerEvent(
+21
View File
@@ -51,6 +51,13 @@
min-width: 0; min-width: 0;
} }
.vc-related-name-row {
display: flex;
align-items: baseline;
gap: 5px;
min-width: 0;
}
.vc-related-name { .vc-related-name {
font-size: 12px; font-size: 12px;
font-weight: 500; font-weight: 500;
@@ -58,6 +65,20 @@
white-space: nowrap; white-space: nowrap;
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
min-width: 0;
}
.vc-related-linked {
font-size: 9px;
font-weight: 600;
color: var(--color-accent);
background: color-mix(in srgb, var(--color-accent) 15%, transparent);
border-radius: 3px;
padding: 0 4px;
white-space: nowrap;
flex-shrink: 0;
text-transform: uppercase;
letter-spacing: 0.03em;
} }
.vc-related-folder { .vc-related-folder {