
























@@ -3,7 +3,7 @@ import {
33loadSqliteVecExtension,
44requireNodeSqlite,
55} from "openclaw/plugin-sdk/memory-core-host-engine-storage";
6-import { describe, expect, it } from "vitest";
6+import { describe, expect, it, vi } from "vitest";
77import { bm25RankToScore, buildFtsQuery } from "./hybrid.js";
88import { searchKeyword, searchVector } from "./manager-search.js";
99@@ -182,6 +182,98 @@ describe("searchKeyword trigram fallback", () => {
182182describe("searchVector sqlite-vec KNN", () => {
183183const { DatabaseSync } = requireNodeSqlite();
184184185+it("streams fallback chunk scoring without materializing candidates", async () => {
186+type ChunkRow = {
187+id: string;
188+path: string;
189+start_line: number;
190+end_line: number;
191+text: string;
192+embedding: string;
193+source: string;
194+};
195+type StatementWithAll = {
196+all: (...params: unknown[]) => ChunkRow[];
197+};
198+199+const db = new DatabaseSync(":memory:");
200+try {
201+ensureMemoryIndexSchema({
202+ db,
203+embeddingCacheTable: "embedding_cache",
204+cacheEnabled: false,
205+ftsTable: "chunks_fts",
206+ftsEnabled: false,
207+});
208+209+const insertChunk = db.prepare(
210+"INSERT INTO chunks (id, path, source, start_line, end_line, hash, model, text, embedding, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
211+);
212+const addChunk = (params: { id: string; model: string; vector: [number, number] }) => {
213+insertChunk.run(
214+params.id,
215+`memory/${params.id}.md`,
216+"memory",
217+1,
218+1,
219+params.id,
220+params.model,
221+`chunk ${params.id}`,
222+JSON.stringify(params.vector),
223+1,
224+);
225+};
226+addChunk({ id: "target-1", model: "target-model", vector: [1, 0] });
227+addChunk({ id: "target-2", model: "target-model", vector: [0.8, 0.2] });
228+addChunk({ id: "target-3", model: "target-model", vector: [0, 1] });
229+addChunk({ id: "other-1", model: "other-model", vector: [1, 0] });
230+231+const prepareTarget = db as unknown as { prepare: (sql: string) => unknown };
232+const originalPrepare = prepareTarget.prepare.bind(db);
233+const chunkRows = (
234+originalPrepare(
235+"SELECT id, path, start_line, end_line, text, embedding, source\n" +
236+" FROM chunks\n" +
237+" WHERE model = ?",
238+) as StatementWithAll
239+).all("target-model");
240+const prepareSpy = vi.spyOn(prepareTarget, "prepare").mockImplementation((sql: string) => {
241+if (
242+sql.includes("SELECT id, path, start_line, end_line, text, embedding, source") &&
243+sql.includes("FROM chunks")
244+) {
245+return {
246+all: () => {
247+throw new Error("fallback vector search must stream rows via iterate()");
248+},
249+iterate: () => chunkRows[Symbol.iterator](),
250+};
251+}
252+return originalPrepare(sql);
253+});
254+255+try {
256+const results = await searchVector({
257+ db,
258+vectorTable: "chunks_vec",
259+providerModel: "target-model",
260+queryVec: [1, 0],
261+limit: 2,
262+snippetMaxChars: 200,
263+ensureVectorReady: async () => false,
264+sourceFilterVec: { sql: "", params: [] },
265+sourceFilterChunks: { sql: "", params: [] },
266+});
267+268+expect(results.map((row) => row.id)).toEqual(["target-1", "target-2"]);
269+} finally {
270+prepareSpy.mockRestore();
271+}
272+} finally {
273+db.close();
274+}
275+});
276+185277it("fills the requested limit after model filters prune nearest KNN candidates", async () => {
186278const db = new DatabaseSync(":memory:", { allowExtension: true });
187279try {
此内容由惯性聚合(RSS阅读器)自动聚合整理,仅供阅读参考。 原文来自 — 版权归原作者所有。