embedding-vsearch.test.ts 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391
  1. /**
  2. * embedding-vsearch.test.ts — Query-side EmbeddingProvider integration
  3. * (issue i-loazq6ze).
  4. *
  5. * Verifies that `searchVec`, `structuredSearch`, and `vectorSearchQuery`
  6. * route query encoding through the supplied `EmbeddingProvider` instead
  7. * of the local `node-llama-cpp` model when one is configured. Also covers
  8. * the AutoFallback path so a transient remote outage degrades to local
  9. * instead of throwing.
  10. *
  11. * The store is in-memory (sqlite + sqlite-vec); the provider is a stub
  12. * that records calls and returns deterministic vectors so we can verify
  13. * routing without standing up real services.
  14. */
  15. import { describe, test, expect, beforeEach, afterEach } from "vitest";
  16. import { mkdtempSync, rmSync } from "node:fs";
  17. import { tmpdir } from "node:os";
  18. import { join } from "node:path";
  19. import {
  20. createStore,
  21. searchVec,
  22. structuredSearch,
  23. vectorSearchQuery,
  24. type Store,
  25. type ExpandedQuery,
  26. } from "../src/store.js";
  27. import {
  28. AutoFallbackEmbeddingProvider,
  29. CircuitOpenError,
  30. type EmbeddingProvider,
  31. type ProviderEmbedding,
  32. type ProviderHealth,
  33. } from "../src/embedding/index.js";
  34. // ─────────────────────────── Stub providers ──────────────────────────────────
  35. /** Deterministic stub — returns a fixed embedding to match index vectors. */
  36. class FixedProvider implements EmbeddingProvider {
  37. readonly kind = "openai" as const;
  38. embedCalls = 0;
  39. embedBatchCalls = 0;
  40. lastEmbedTexts: string[] = [];
  41. constructor(
  42. private readonly modelId: string,
  43. private readonly embedding: number[],
  44. ) {}
  45. getModelId(): string { return this.modelId; }
  46. getDimensions(): number | undefined { return this.embedding.length; }
  47. async healthcheck(): Promise<ProviderHealth> {
  48. return { ok: true, model: this.modelId, dimensions: this.embedding.length };
  49. }
  50. async embed(text: string): Promise<ProviderEmbedding | null> {
  51. this.embedCalls++;
  52. this.lastEmbedTexts.push(text);
  53. return { embedding: this.embedding.slice(), model: this.modelId };
  54. }
  55. async embedBatch(texts: string[]): Promise<(ProviderEmbedding | null)[]> {
  56. this.embedBatchCalls++;
  57. this.lastEmbedTexts.push(...texts);
  58. return texts.map(() => ({ embedding: this.embedding.slice(), model: this.modelId }));
  59. }
  60. async dispose(): Promise<void> {}
  61. }
  62. /** Throws CircuitOpenError on every call — simulates "remote down". */
  63. class CircuitOpenProvider implements EmbeddingProvider {
  64. readonly kind = "openai" as const;
  65. embedCalls = 0;
  66. embedBatchCalls = 0;
  67. constructor(private readonly modelId: string = "embeddinggemma") {}
  68. getModelId(): string { return this.modelId; }
  69. getDimensions(): number | undefined { return undefined; }
  70. async healthcheck(): Promise<ProviderHealth> {
  71. return { ok: false, model: this.modelId, detail: "circuit open" };
  72. }
  73. async embed(): Promise<ProviderEmbedding | null> {
  74. this.embedCalls++;
  75. throw new CircuitOpenError("remote down");
  76. }
  77. async embedBatch(): Promise<(ProviderEmbedding | null)[]> {
  78. this.embedBatchCalls++;
  79. throw new CircuitOpenError("remote down");
  80. }
  81. async dispose(): Promise<void> {}
  82. }
  83. /** Throws a generic error on every call — simulates total backend failure. */
  84. class AlwaysFailProvider implements EmbeddingProvider {
  85. readonly kind = "openai" as const;
  86. constructor(private readonly modelId: string = "embeddinggemma") {}
  87. getModelId(): string { return this.modelId; }
  88. getDimensions(): number | undefined { return undefined; }
  89. async healthcheck(): Promise<ProviderHealth> {
  90. return { ok: false, model: this.modelId, detail: "always fail" };
  91. }
  92. async embed(): Promise<ProviderEmbedding | null> {
  93. throw new Error("backend unreachable");
  94. }
  95. async embedBatch(): Promise<(ProviderEmbedding | null)[]> {
  96. throw new Error("backend unreachable");
  97. }
  98. async dispose(): Promise<void> {}
  99. }
  100. // ─────────────────────────── Test setup ──────────────────────────────────────
  101. let workDir: string;
  102. let store: Store;
  103. const DIM = 4;
  104. // Fixed embedding used for both index vectors and query vectors so the
  105. // stub provider's response will match the indexed vector exactly (cosine
  106. // distance ≈ 0 → similarity ≈ 1).
  107. const FIXED_VEC = [0.1, 0.2, 0.3, 0.4];
  108. beforeEach(() => {
  109. workDir = mkdtempSync(join(tmpdir(), "qmd-vsearch-test-"));
  110. process.env.INDEX_PATH = join(workDir, "index.sqlite");
  111. store = createStore(process.env.INDEX_PATH);
  112. const now = "2026-04-28T00:00:00Z";
  113. store.db
  114. .prepare(`INSERT INTO content (hash, doc, created_at) VALUES (?, ?, ?)`)
  115. .run("hashA", "Alpha document body about query encoding via remote provider.", now);
  116. store.db
  117. .prepare(`INSERT INTO content (hash, doc, created_at) VALUES (?, ?, ?)`)
  118. .run("hashB", "Beta document body about fallback chain semantics.", now);
  119. store.db
  120. .prepare(`INSERT INTO documents (hash, collection, path, title, created_at, modified_at, active) VALUES (?, ?, ?, ?, ?, ?, ?)`)
  121. .run("hashA", "test", "alpha.md", "Alpha", now, now, 1);
  122. store.db
  123. .prepare(`INSERT INTO documents (hash, collection, path, title, created_at, modified_at, active) VALUES (?, ?, ?, ?, ?, ?, ?)`)
  124. .run("hashB", "test", "beta.md", "Beta", now, now, 1);
  125. // Seed vectors_vec with the same fixed vector so stub provider's query
  126. // embedding lines up with the index entries.
  127. store.ensureVecTable(DIM);
  128. store.db
  129. .prepare(`INSERT INTO content_vectors (hash, seq, pos, model, embedded_at) VALUES (?, 0, 0, 'embeddinggemma', ?)`)
  130. .run("hashA", now);
  131. store.db
  132. .prepare(`INSERT INTO content_vectors (hash, seq, pos, model, embedded_at) VALUES (?, 0, 0, 'embeddinggemma', ?)`)
  133. .run("hashB", now);
  134. store.db
  135. .prepare(`INSERT INTO vectors_vec (hash_seq, embedding) VALUES (?, ?)`)
  136. .run("hashA_0", new Float32Array(FIXED_VEC));
  137. store.db
  138. .prepare(`INSERT INTO vectors_vec (hash_seq, embedding) VALUES (?, ?)`)
  139. .run("hashB_0", new Float32Array(FIXED_VEC));
  140. });
  141. afterEach(() => {
  142. try { store.close(); } catch { /* ignore */ }
  143. delete process.env.INDEX_PATH;
  144. rmSync(workDir, { recursive: true, force: true });
  145. });
  146. // ─────────────────────────── searchVec ──────────────────────────────────────
  147. describe("searchVec with EmbeddingProvider", () => {
  148. test("encodes the query through the provider when supplied", async () => {
  149. const provider = new FixedProvider("embeddinggemma", FIXED_VEC);
  150. // Sanity: store.llm is not set; if searchVec touched local llama-cpp
  151. // it would fail (no model loaded). Provider routing must be exclusive.
  152. const results = await searchVec(
  153. store.db, "hello", "embeddinggemma", 10,
  154. undefined, undefined, undefined, provider,
  155. );
  156. expect(provider.embedCalls).toBe(1);
  157. expect(provider.embedBatchCalls).toBe(0);
  158. expect(results.length).toBeGreaterThan(0);
  159. // Both alpha + beta share the same vector — both should be returned.
  160. const filepaths = results.map((r) => r.filepath).sort();
  161. expect(filepaths).toEqual(["qmd://test/alpha.md", "qmd://test/beta.md"]);
  162. });
  163. test("provider mode does not access the local llama-cpp instance", async () => {
  164. const provider = new FixedProvider("embeddinggemma", FIXED_VEC);
  165. // If anything touches `store.llm` while the provider is set, the proxy
  166. // throws — proves the provider path is truly exclusive (mirrors the
  167. // i-08ovbvtb regression guard in embedding-store-integration.test.ts).
  168. store.llm = new Proxy({}, {
  169. get(_target, prop) {
  170. throw new Error(
  171. `store.llm.${String(prop)} accessed when embedProvider was supplied — DoD violation`,
  172. );
  173. },
  174. }) as never;
  175. const results = await searchVec(
  176. store.db, "hello", "embeddinggemma", 10,
  177. undefined, undefined, undefined, provider,
  178. );
  179. expect(results.length).toBeGreaterThan(0);
  180. });
  181. test("survives transient primary failure via AutoFallback", async () => {
  182. const primary = new CircuitOpenProvider("embeddinggemma");
  183. const fallback = new FixedProvider("embeddinggemma", FIXED_VEC);
  184. const wrapped = new AutoFallbackEmbeddingProvider({
  185. primary,
  186. fallback,
  187. warn: () => { /* swallow noisy WARN in tests */ },
  188. });
  189. const results = await searchVec(
  190. store.db, "fallback test", "embeddinggemma", 10,
  191. undefined, undefined, undefined, wrapped,
  192. );
  193. expect(primary.embedCalls).toBe(1);
  194. expect(fallback.embedCalls).toBe(1);
  195. expect(results.length).toBeGreaterThan(0);
  196. });
  197. test("surfaces error when both primary AND fallback fail", async () => {
  198. const primary = new AlwaysFailProvider("embeddinggemma");
  199. const fallback = new AlwaysFailProvider("embeddinggemma");
  200. const wrapped = new AutoFallbackEmbeddingProvider({
  201. primary,
  202. fallback,
  203. warn: () => { /* swallow */ },
  204. });
  205. await expect(
  206. searchVec(
  207. store.db, "doomed", "embeddinggemma", 10,
  208. undefined, undefined, undefined, wrapped,
  209. ),
  210. ).rejects.toThrow(/backend unreachable/);
  211. });
  212. });
  213. // ─────────────────────────── structuredSearch ───────────────────────────────
  214. describe("structuredSearch with EmbeddingProvider", () => {
  215. test("uses provider.embedBatch for vec/hyde sub-queries", async () => {
  216. const provider = new FixedProvider("embeddinggemma", FIXED_VEC);
  217. // Deny access to the local llama-cpp — proves the provider path is exclusive.
  218. store.llm = new Proxy({}, {
  219. get(_target, prop) {
  220. throw new Error(
  221. `store.llm.${String(prop)} accessed when embedProvider was supplied — DoD violation`,
  222. );
  223. },
  224. }) as never;
  225. const queries: ExpandedQuery[] = [
  226. { type: "vec", query: "what is the fallback chain about" },
  227. { type: "hyde", query: "Fallback chains route around primary failure transparently." },
  228. ];
  229. const results = await structuredSearch(store, queries, {
  230. skipRerank: true, // reranker uses local llm — skip in this isolation test
  231. embedProvider: provider,
  232. });
  233. // One batch call covering both vec/hyde queries.
  234. expect(provider.embedBatchCalls).toBe(1);
  235. expect(provider.lastEmbedTexts.length).toBe(2);
  236. expect(results.length).toBeGreaterThan(0);
  237. });
  238. test("AutoFallback covers structuredSearch query batch", async () => {
  239. const primary = new CircuitOpenProvider("embeddinggemma");
  240. const fallback = new FixedProvider("embeddinggemma", FIXED_VEC);
  241. const wrapped = new AutoFallbackEmbeddingProvider({
  242. primary,
  243. fallback,
  244. warn: () => { /* swallow */ },
  245. });
  246. const queries: ExpandedQuery[] = [
  247. { type: "vec", query: "fallback test" },
  248. ];
  249. const results = await structuredSearch(store, queries, {
  250. skipRerank: true,
  251. embedProvider: wrapped,
  252. });
  253. expect(primary.embedBatchCalls).toBe(1);
  254. expect(fallback.embedBatchCalls).toBe(1);
  255. expect(results.length).toBeGreaterThan(0);
  256. });
  257. test("structuredSearch degrades to empty results when both providers fail (batch path)", async () => {
  258. // AutoFallback.embedBatch is contract-bound to return nulls on total
  259. // failure (graceful degradation in batch mode — see autofallback.ts
  260. // onTotalFail). structuredSearch then has no embeddings to query
  261. // sqlite-vec with and returns []. This is the documented behavior;
  262. // searchVec (single-embed path) is the one that surfaces a thrown
  263. // error to the caller, see the test above.
  264. const primary = new AlwaysFailProvider("embeddinggemma");
  265. const fallback = new AlwaysFailProvider("embeddinggemma");
  266. const wrapped = new AutoFallbackEmbeddingProvider({
  267. primary,
  268. fallback,
  269. warn: () => { /* swallow */ },
  270. });
  271. const queries: ExpandedQuery[] = [
  272. { type: "vec", query: "doomed" },
  273. ];
  274. const results = await structuredSearch(store, queries, {
  275. skipRerank: true,
  276. embedProvider: wrapped,
  277. });
  278. expect(results).toEqual([]);
  279. });
  280. });
  281. // ─────────────────────────── vectorSearchQuery ──────────────────────────────
  282. describe("vectorSearchQuery with EmbeddingProvider", () => {
  283. test("encodes original query via provider, no local llm access", async () => {
  284. const provider = new FixedProvider("embeddinggemma", FIXED_VEC);
  285. // Stub expandQuery to return no expansions — this isolates the
  286. // embedding path from the LLM-driven query expansion path.
  287. store.expandQuery = async () => [];
  288. store.llm = new Proxy({}, {
  289. get(_target, prop) {
  290. throw new Error(
  291. `store.llm.${String(prop)} accessed when embedProvider was supplied — DoD violation`,
  292. );
  293. },
  294. }) as never;
  295. const results = await vectorSearchQuery(store, "vector search test", {
  296. limit: 5,
  297. minScore: 0,
  298. embedProvider: provider,
  299. });
  300. // vectorSearchQuery sequentializes — at minimum the original query
  301. // triggers one embed call via the provider.
  302. expect(provider.embedCalls).toBeGreaterThanOrEqual(1);
  303. expect(results.length).toBeGreaterThan(0);
  304. });
  305. test("AutoFallback rescues vectorSearchQuery from primary failure", async () => {
  306. const primary = new CircuitOpenProvider("embeddinggemma");
  307. const fallback = new FixedProvider("embeddinggemma", FIXED_VEC);
  308. const wrapped = new AutoFallbackEmbeddingProvider({
  309. primary,
  310. fallback,
  311. warn: () => { /* swallow */ },
  312. });
  313. store.expandQuery = async () => [];
  314. const results = await vectorSearchQuery(store, "fallback path", {
  315. minScore: 0,
  316. embedProvider: wrapped,
  317. });
  318. expect(primary.embedCalls).toBeGreaterThanOrEqual(1);
  319. expect(fallback.embedCalls).toBeGreaterThanOrEqual(1);
  320. expect(results.length).toBeGreaterThan(0);
  321. });
  322. });
  323. // ─────────────────────────── Backward compat ────────────────────────────────
  324. describe("backward compat — no provider supplied", () => {
  325. test("searchVec without provider uses precomputed embedding path (no llm needed)", async () => {
  326. // When the caller passes `precomputedEmbedding`, searchVec must not
  327. // touch any embedding backend at all — neither local nor provider.
  328. // This is the cheapest backward-compat smoke test we can run without
  329. // loading node-llama-cpp.
  330. store.llm = new Proxy({}, {
  331. get(_target, prop) {
  332. throw new Error(`store.llm.${String(prop)} accessed unexpectedly`);
  333. },
  334. }) as never;
  335. const results = await searchVec(
  336. store.db, "hello", "embeddinggemma", 10,
  337. undefined, undefined, FIXED_VEC, // precomputedEmbedding
  338. );
  339. expect(results.length).toBeGreaterThan(0);
  340. });
  341. });