obsidian-hybrid-search 0.13.21 → 0.13.22
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/src/chunker.js +40 -9
- package/dist/src/searcher.js +16 -10
- package/package.json +1 -1
package/dist/src/chunker.js
CHANGED
|
@@ -184,6 +184,18 @@ export function slidingWindow(text, contextLength, overlap, headingChain = [], s
|
|
|
184
184
|
? chunks
|
|
185
185
|
: [createSourceChunk(text, 0, text.length, headingChain, sectionOffset)];
|
|
186
186
|
}
|
|
187
|
+
/** Strip markdown syntax from a single line so it matches the textContent of a rendered DOM block. */
|
|
188
|
+
function normalizeMatchLine(line) {
|
|
189
|
+
const stripped = stripMarkdownImages(stripHtmlTags(stripLeadingBlockquoteCallout(line)) // blockquote/callout markers and HTML tags
|
|
190
|
+
.replace(/\[\^[^\]]+\]/g, '') // footnote references ([^1])
|
|
191
|
+
.replace(/!\[\[[^\]]+\]\]/g, ''));
|
|
192
|
+
return replaceMarkdownInlineLinks(replaceWikilinkLabels(stripped))
|
|
193
|
+
.replace(/[*_]{1,2}([^*_]+)[*_]{1,2}/g, '$1') // bold / italic
|
|
194
|
+
.replace(/`([^`]+)`/g, '$1') // inline code
|
|
195
|
+
.replace(/^(?:[-*+]|\d+[.)]) \s*/m, '') // list markers
|
|
196
|
+
.replace(/^\[[xX ]\]\s*/m, '') // task checkboxes
|
|
197
|
+
.trim();
|
|
198
|
+
}
|
|
187
199
|
/**
|
|
188
200
|
* Build a DOM-matchable string from chunk text.
|
|
189
201
|
* Strips heading lines and markdown syntax so the result matches
|
|
@@ -210,20 +222,39 @@ export function buildMatchText(chunkText) {
|
|
|
210
222
|
for (const line of [...bodyLines, fallback]) {
|
|
211
223
|
if (!line.trim())
|
|
212
224
|
continue;
|
|
213
|
-
const
|
|
214
|
-
.replace(/\[\^[^\]]+\]/g, '') // footnote references ([^1])
|
|
215
|
-
.replace(/!\[\[[^\]]+\]\]/g, ''));
|
|
216
|
-
const result = replaceMarkdownInlineLinks(replaceWikilinkLabels(stripped))
|
|
217
|
-
.replace(/[*_]{1,2}([^*_]+)[*_]{1,2}/g, '$1') // bold / italic
|
|
218
|
-
.replace(/`([^`]+)`/g, '$1') // inline code
|
|
219
|
-
.replace(/^(?:[-*+]|\d+[.)]) \s*/m, '') // list markers
|
|
220
|
-
.replace(/^\[[xX ]\]\s*/m, '') // task checkboxes
|
|
221
|
-
.trim();
|
|
225
|
+
const result = normalizeMatchLine(line);
|
|
222
226
|
if (result)
|
|
223
227
|
return result.slice(0, 80);
|
|
224
228
|
}
|
|
225
229
|
return '';
|
|
226
230
|
}
|
|
231
|
+
/** Remove sentinel markers from a snippet returned by SQLite's snippet() function. */
|
|
232
|
+
export function stripSnippetMarkers(text, markStart, markEnd) {
|
|
233
|
+
return text.split(markStart).join('').split(markEnd).join('');
|
|
234
|
+
}
|
|
235
|
+
/**
|
|
236
|
+
* Build matchText from a BM25 snippet whose real FTS match is wrapped in sentinel
|
|
237
|
+
* markers (see searchBm25's snippet() call). A multi-line snippet window doesn't
|
|
238
|
+
* necessarily have the match on its first line, so — unlike buildMatchText, which
|
|
239
|
+
* assumes the chunk text itself starts at the relevant content — this picks the
|
|
240
|
+
* line that actually contains the marked match.
|
|
241
|
+
*/
|
|
242
|
+
export function buildMatchTextFromMarkedSnippet(markedSnippet, markStart, markEnd) {
|
|
243
|
+
const markIndex = markedSnippet.indexOf(markStart);
|
|
244
|
+
if (markIndex === -1) {
|
|
245
|
+
return buildMatchText(stripSnippetMarkers(markedSnippet, markStart, markEnd));
|
|
246
|
+
}
|
|
247
|
+
const lineStart = markedSnippet.lastIndexOf('\n', markIndex) + 1;
|
|
248
|
+
const lineEndRaw = markedSnippet.indexOf('\n', markIndex);
|
|
249
|
+
const lineEnd = lineEndRaw === -1 ? markedSnippet.length : lineEndRaw;
|
|
250
|
+
const line = stripSnippetMarkers(markedSnippet.slice(lineStart, lineEnd), markStart, markEnd);
|
|
251
|
+
const result = normalizeMatchLine(line);
|
|
252
|
+
if (result)
|
|
253
|
+
return result.slice(0, 80);
|
|
254
|
+
// Marked line stripped to nothing (e.g. a callout-type-only line) — fall back to
|
|
255
|
+
// the ordinary first-usable-line heuristic over the whole (unmarked) snippet.
|
|
256
|
+
return buildMatchText(stripSnippetMarkers(markedSnippet, markStart, markEnd));
|
|
257
|
+
}
|
|
227
258
|
function stripLeadingBlockquoteCallout(value) {
|
|
228
259
|
let index = stripLeadingQuoteMarkers(value, 0);
|
|
229
260
|
if (value.startsWith('[!', index)) {
|
package/dist/src/searcher.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import path from 'node:path';
|
|
2
|
-
import { buildMatchText } from './chunker.js';
|
|
2
|
+
import { buildMatchText, buildMatchTextFromMarkedSnippet, stripSnippetMarkers } from './chunker.js';
|
|
3
3
|
import { config } from './config.js';
|
|
4
4
|
import { filterNotePathsByFrontmatter, filterNotePathsByTag, getBacklinksForPaths, getChunkEmbeddingsByPath, getDb, getDbVersion, getLinksForPaths, getMarkdownLinksForPaths, getMatchingNotesByFrontmatter, getNoteByPath, getOutgoingLinks, getOutgoingLinksForPaths, getUrlsForPaths, hasVecTable, resolveNotePath, } from './db.js';
|
|
5
5
|
import { embed } from './embedder.js';
|
|
@@ -114,12 +114,18 @@ function toFtsQuery(query, operator = 'AND') {
|
|
|
114
114
|
return '""';
|
|
115
115
|
return words.map((w) => `"${w}"*`).join(` ${operator} `);
|
|
116
116
|
}
|
|
117
|
+
// Sentinel markers bracketing the actual FTS match inside snippet() output. A snippet
|
|
118
|
+
// window spans several sentences, so the true match isn't necessarily on its first
|
|
119
|
+
// line — these let buildMatchTextFromMarkedSnippet find the right one. Stripped from
|
|
120
|
+
// every snippet before it's exposed on RawResult; control chars won't appear in notes.
|
|
121
|
+
const SNIPPET_MARK_START = '';
|
|
122
|
+
const SNIPPET_MARK_END = '';
|
|
117
123
|
export function searchBm25(query, limit, snippetLength = 300, buildAnchors = false) {
|
|
118
124
|
const db = getDb();
|
|
119
125
|
const numTokens = Math.max(10, Math.ceil(snippetLength / 4));
|
|
120
126
|
const stmt = db.prepare(`
|
|
121
127
|
SELECT n.path, n.title, n.tags, n.aliases,
|
|
122
|
-
snippet(notes_fts_bm25, 2,
|
|
128
|
+
snippet(notes_fts_bm25, 2, ?, ?, '...', ?) AS snippet,
|
|
123
129
|
bm25(notes_fts_bm25, 10.0, 5.0, 1.0) AS rank
|
|
124
130
|
FROM notes_fts_bm25
|
|
125
131
|
JOIN notes n ON n.id = notes_fts_bm25.rowid
|
|
@@ -133,13 +139,13 @@ export function searchBm25(query, limit, snippetLength = 300, buildAnchors = fal
|
|
|
133
139
|
// relevant documents whenever a single query word (e.g. "between", "organize")
|
|
134
140
|
// was absent from the document—even if that document was the best conceptual
|
|
135
141
|
// match for every other term in the query.
|
|
136
|
-
const rows = stmt.all(numTokens, toFtsQuery(query, 'OR'), limit);
|
|
142
|
+
const rows = stmt.all(SNIPPET_MARK_START, SNIPPET_MARK_END, numTokens, toFtsQuery(query, 'OR'), limit);
|
|
137
143
|
const results = rows.map((row) => ({
|
|
138
144
|
path: row.path,
|
|
139
145
|
title: row.title ?? '',
|
|
140
146
|
tags: row.tags ?? '[]',
|
|
141
147
|
aliases: row.aliases,
|
|
142
|
-
snippet: row.snippet ?? '',
|
|
148
|
+
snippet: stripSnippetMarkers(row.snippet ?? '', SNIPPET_MARK_START, SNIPPET_MARK_END),
|
|
143
149
|
score: Math.max(0, Math.abs(row.rank) / (1 + Math.abs(row.rank))),
|
|
144
150
|
scores: {
|
|
145
151
|
bm25: Math.max(0, Math.abs(row.rank) / (1 + Math.abs(row.rank))),
|
|
@@ -149,21 +155,21 @@ export function searchBm25(query, limit, snippetLength = 300, buildAnchors = fal
|
|
|
149
155
|
// Skip when neither is needed (snippetLength=0 and buildAnchors=false).
|
|
150
156
|
if (snippetLength > 0 || buildAnchors) {
|
|
151
157
|
const chunkDataMap = getChunkDataForBm25Results(results);
|
|
152
|
-
for (
|
|
158
|
+
for (let i = 0; i < results.length; i++) {
|
|
159
|
+
const result = results[i];
|
|
153
160
|
const data = chunkDataMap.get(result.path);
|
|
154
161
|
if (data) {
|
|
155
162
|
if (snippetLength > 0 && data.headingPath) {
|
|
156
163
|
result.snippet = `${data.headingPath}\n${result.snippet}`;
|
|
157
164
|
}
|
|
158
165
|
if (buildAnchors) {
|
|
159
|
-
// Use the
|
|
160
|
-
//
|
|
161
|
-
|
|
162
|
-
const snippetForMatch = result.snippet.replace(/\.\.\./g, '');
|
|
166
|
+
// Use the still-marked raw snippet (positioned near the match) for matchText,
|
|
167
|
+
// not the full chunk — the sentinel markers pin down which line is the real hit.
|
|
168
|
+
const rawSnippet = (rows[i].snippet ?? '').replace(/\.\.\./g, '');
|
|
163
169
|
result.bm25Anchor = {
|
|
164
170
|
kind: 'bm25',
|
|
165
171
|
headingPath: data.headingPath,
|
|
166
|
-
matchText:
|
|
172
|
+
matchText: buildMatchTextFromMarkedSnippet(rawSnippet, SNIPPET_MARK_START, SNIPPET_MARK_END),
|
|
167
173
|
charStart: data.charStart,
|
|
168
174
|
charEnd: data.charEnd,
|
|
169
175
|
};
|