embedding-vsearch.test.ts 13 KB

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