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.
@@ -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 stripped = stripMarkdownImages(stripHtmlTags(stripLeadingBlockquoteCallout(line)) // blockquote/callout markers and HTML tags
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)) {
@@ -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, '', '', '...', ?) AS snippet,
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 (const result of results) {
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 BM25 snippet (positioned near the match) for matchText, not the full chunk.
160
- // Strip SQLite snippet ellipsis markers and HTML tags before extracting match text.
161
- // Remove all SQLite snippet ellipsis markers (may appear anywhere in the text)
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: buildMatchText(snippetForMatch),
172
+ matchText: buildMatchTextFromMarkedSnippet(rawSnippet, SNIPPET_MARK_START, SNIPPET_MARK_END),
167
173
  charStart: data.charStart,
168
174
  charEnd: data.charEnd,
169
175
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "obsidian-hybrid-search",
3
- "version": "0.13.21",
3
+ "version": "0.13.22",
4
4
  "mcpName": "io.github.flowing-abyss/obsidian-hybrid-search",
5
5
  "type": "module",
6
6
  "bin": {