Skip to content

Commit bb866a2

Browse files
Desktop, Mobile, Cli: Fixes #16792: Avoid loading every matching note body in search_notes
Load bodies only for the limited search results while keeping the full match count. Match bodies by ID while iterating the ranked results because byIds does not guarantee row order. Validation could not run: dependency installation failed with a server-aborted request, leaving Yarn without a node_modules state file. Assisted-by: GPT-6.1 Sol
1 parent b0affc0 commit bb866a2

4 files changed

Lines changed: 78 additions & 3 deletions

File tree

‎.gitignore‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1672,6 +1672,7 @@ packages/lib/services/ai/tools/global/manageTags.js
16721672
packages/lib/services/ai/tools/global/readImage.js
16731673
packages/lib/services/ai/tools/global/readNote.js
16741674
packages/lib/services/ai/tools/global/searchNotes.js
1675+
packages/lib/services/ai/tools/global/searchNotes.test.js
16751676
packages/lib/services/ai/tools/global/semanticSearchNotes.js
16761677
packages/lib/services/ai/tools/global/updateNote.js
16771678
packages/lib/services/ai/tools/types.js

‎.ignore.eslint‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1698,6 +1698,7 @@ packages/lib/services/ai/tools/global/manageTags.js
16981698
packages/lib/services/ai/tools/global/readImage.js
16991699
packages/lib/services/ai/tools/global/readNote.js
17001700
packages/lib/services/ai/tools/global/searchNotes.js
1701+
packages/lib/services/ai/tools/global/searchNotes.test.js
17011702
packages/lib/services/ai/tools/global/semanticSearchNotes.js
17021703
packages/lib/services/ai/tools/global/updateNote.js
17031704
packages/lib/services/ai/tools/types.js
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
import Note from '../../../../models/Note';
2+
import { db, setupDatabaseAndSynchronizer, switchClient } from '../../../../testing/test-utils';
3+
import SearchEngine from '../../../search/SearchEngine';
4+
import SearchEngineUtils from '../../../search/SearchEngineUtils';
5+
import searchNotes from './searchNotes';
6+
7+
interface SearchOutput {
8+
results: { id: string; snippet: string }[];
9+
total: number;
10+
}
11+
12+
const runSearch = async (limit = 20) => await searchNotes.handler({ query: 'keyword', limit }, {}) as SearchOutput;
13+
14+
describe('search_notes', () => {
15+
beforeEach(async () => {
16+
await setupDatabaseAndSynchronizer(1);
17+
await switchClient(1);
18+
SearchEngine.instance().setDb(db());
19+
});
20+
21+
afterEach(() => {
22+
jest.restoreAllMocks();
23+
});
24+
25+
const createNotes = async () => {
26+
const notes = [];
27+
for (const [title, body] of [['Other', 'keyword once'], ['keyword', 'keyword keyword repeated'], ['Last', 'keyword last']]) {
28+
notes.push(await Note.save({ id: String(notes.length + 1).padStart(32, '0'), title, body }));
29+
}
30+
await SearchEngine.instance().syncTables();
31+
return notes;
32+
};
33+
34+
test('preserves ranking and matches snippets to bodies returned in a different order', async () => {
35+
const notes = await createNotes();
36+
const ranked = (await SearchEngineUtils.notesForQuery('keyword', false, { fields: ['id'] })).notes;
37+
expect(ranked.map(n => n.id)).not.toEqual(notes.map(n => n.id));
38+
expect(ranked.map(n => n.id)).not.toEqual(notes.map(n => n.id).sort());
39+
const byIds = Note.byIds.bind(Note);
40+
jest.spyOn(Note, 'byIds').mockImplementation(async (ids, options) => {
41+
const rows = await byIds(ids, options);
42+
return ids.slice().reverse().map(id => rows.find(n => n.id === id));
43+
});
44+
const output = await runSearch();
45+
expect(output.results.map(n => n.id)).toEqual(ranked.map(n => n.id));
46+
for (const result of output.results) {
47+
expect(result.snippet).toBe(notes.find(n => n.id === result.id).body);
48+
}
49+
});
50+
51+
test('limits results while keeping the full match count', async () => {
52+
await createNotes();
53+
const output = await runSearch(1);
54+
expect(output.results).toHaveLength(1);
55+
expect(output.total).toBe(3);
56+
});
57+
58+
test('fetches bodies only for the limited results and handles a missing body', async () => {
59+
await createNotes();
60+
const querySpy = jest.spyOn(SearchEngineUtils, 'notesForQuery');
61+
const bodySpy = jest.spyOn(Note, 'byIds').mockResolvedValue([]);
62+
const output = await runSearch(1);
63+
expect(querySpy.mock.calls[0][2].fields).not.toContain('body');
64+
expect(bodySpy).toHaveBeenCalledWith([output.results[0].id], { fields: ['id', 'body'] });
65+
expect(output.results[0].snippet).toBe('');
66+
expect(output.total).toBe(3);
67+
});
68+
});

‎packages/lib/services/ai/tools/global/searchNotes.ts‎

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,13 +3,14 @@ import { NoteEntity } from '../../../database/types';
33
import { _ } from '../../../../locale';
44
import buildTool from '../utils/buildTool';
55
import { ToolError } from '../types';
6+
import Note from '../../../../models/Note';
67

78
interface Input {
89
query?: string;
910
limit?: number;
1011
}
1112

12-
const fields = ['id', 'title', 'parent_id', 'updated_time', 'body'];
13+
const fields = ['id', 'title', 'parent_id', 'updated_time'];
1314
const defaultLimit = 20;
1415
const maxLimit = 100;
1516
const snippetChars = 240;
@@ -57,6 +58,10 @@ const tool = buildTool({
5758
const limit = Math.min(Math.max(input.limit ?? defaultLimit, 1), maxLimit);
5859
const { notes } = await SearchEngineUtils.notesForQuery(input.query, false, { fields });
5960

61+
const limitedNotes = notes.slice(0, limit);
62+
const bodies = await Note.byIds(limitedNotes.map(n => n.id), { fields: ['id', 'body'] });
63+
const bodyById = new Map(bodies.map(n => [n.id, n.body]));
64+
6065
// Pull keywords out of the query so we can anchor the snippet near a
6166
// match. Filters like `notebook:"X"` aren't useful for that.
6267
const keywords = input.query
@@ -65,12 +70,12 @@ const tool = buildTool({
6570
.map(t => t.replace(/^["*]+|["*]+$/g, '').toLowerCase())
6671
.filter(Boolean);
6772

68-
const results = notes.slice(0, limit).map((n: NoteEntity) => ({
73+
const results = limitedNotes.map((n: NoteEntity) => ({
6974
id: n.id,
7075
title: n.title,
7176
notebook_id: n.parent_id,
7277
updated_time: n.updated_time,
73-
snippet: makeSnippet(n.body ?? '', keywords),
78+
snippet: makeSnippet(bodyById.get(n.id) ?? '', keywords),
7479
}));
7580

7681
return { results, total: notes.length };

0 commit comments

Comments
 (0)