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:
@@ -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)
|
||||||
scores.push([path3, s]);
|
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.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
@@ -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
@@ -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
@@ -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) };
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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 */
|
||||||
|
|||||||
@@ -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
@@ -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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user