| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342 |
- /**
- * embedding-vsearch.test.ts — Query-side EmbeddingProvider integration
- * (issue i-loazq6ze).
- *
- * Verifies that `searchVec`, `structuredSearch`, and `vectorSearchQuery`
- * route query encoding through the supplied commercial `EmbeddingProvider`.
- * Provider failures propagate without selecting a model fallback.
- *
- * The store is in-memory (sqlite + sqlite-vec); the provider is a stub
- * that records calls and returns deterministic vectors so we can verify
- * routing without standing up real services.
- */
- import { describe, test, expect, beforeEach, afterEach } from "vitest";
- import { mkdtempSync, rmSync } from "node:fs";
- import { tmpdir } from "node:os";
- import { join } from "node:path";
- import {
- createStore,
- searchVec,
- structuredSearch,
- vectorSearchQuery,
- type Store,
- type ExpandedQuery,
- } from "../src/store.js";
- import {
- CircuitOpenError,
- type EmbeddingProvider,
- type ProviderEmbedding,
- type ProviderHealth,
- } from "../src/embedding/index.js";
- // ─────────────────────────── Stub providers ──────────────────────────────────
- /** Deterministic stub — returns a fixed embedding to match index vectors. */
- class FixedProvider implements EmbeddingProvider {
- readonly kind = "openai" as const;
- embedCalls = 0;
- embedBatchCalls = 0;
- lastEmbedTexts: string[] = [];
- constructor(
- private readonly modelId: string,
- private readonly embedding: number[],
- ) {}
- getModelId(): string { return this.modelId; }
- getDimensions(): number | undefined { return this.embedding.length; }
- async healthcheck(): Promise<ProviderHealth> {
- return { ok: true, model: this.modelId, dimensions: this.embedding.length };
- }
- async embed(text: string): Promise<ProviderEmbedding | null> {
- this.embedCalls++;
- this.lastEmbedTexts.push(text);
- return { embedding: this.embedding.slice(), model: this.modelId };
- }
- async embedBatch(texts: string[]): Promise<(ProviderEmbedding | null)[]> {
- this.embedBatchCalls++;
- this.lastEmbedTexts.push(...texts);
- return texts.map(() => ({ embedding: this.embedding.slice(), model: this.modelId }));
- }
- async dispose(): Promise<void> {}
- }
- /** Throws CircuitOpenError on every call — simulates "remote down". */
- class CircuitOpenProvider implements EmbeddingProvider {
- readonly kind = "openai" as const;
- embedCalls = 0;
- embedBatchCalls = 0;
- constructor(private readonly modelId: string = "embeddinggemma") {}
- getModelId(): string { return this.modelId; }
- getDimensions(): number | undefined { return undefined; }
- async healthcheck(): Promise<ProviderHealth> {
- return { ok: false, model: this.modelId, detail: "circuit open" };
- }
- async embed(): Promise<ProviderEmbedding | null> {
- this.embedCalls++;
- throw new CircuitOpenError("remote down");
- }
- async embedBatch(): Promise<(ProviderEmbedding | null)[]> {
- this.embedBatchCalls++;
- throw new CircuitOpenError("remote down");
- }
- async dispose(): Promise<void> {}
- }
- /** Throws a generic error on every call — simulates total backend failure. */
- class AlwaysFailProvider implements EmbeddingProvider {
- readonly kind = "openai" as const;
- constructor(private readonly modelId: string = "embeddinggemma") {}
- getModelId(): string { return this.modelId; }
- getDimensions(): number | undefined { return undefined; }
- async healthcheck(): Promise<ProviderHealth> {
- return { ok: false, model: this.modelId, detail: "always fail" };
- }
- async embed(): Promise<ProviderEmbedding | null> {
- throw new Error("backend unreachable");
- }
- async embedBatch(): Promise<(ProviderEmbedding | null)[]> {
- throw new Error("backend unreachable");
- }
- async dispose(): Promise<void> {}
- }
- // ─────────────────────────── Test setup ──────────────────────────────────────
- let workDir: string;
- let store: Store;
- const DIM = 4;
- // Fixed embedding used for both index vectors and query vectors so the
- // stub provider's response will match the indexed vector exactly (cosine
- // distance ≈ 0 → similarity ≈ 1).
- const FIXED_VEC = [0.1, 0.2, 0.3, 0.4];
- beforeEach(() => {
- workDir = mkdtempSync(join(tmpdir(), "qmd-vsearch-test-"));
- process.env.INDEX_PATH = join(workDir, "index.sqlite");
- store = createStore(process.env.INDEX_PATH);
- const now = "2026-04-28T00:00:00Z";
- store.db
- .prepare(`INSERT INTO content (hash, doc, created_at) VALUES (?, ?, ?)`)
- .run("hashA", "Alpha document body about query encoding via remote provider.", now);
- store.db
- .prepare(`INSERT INTO content (hash, doc, created_at) VALUES (?, ?, ?)`)
- .run("hashB", "Beta document body about commercial API failure semantics.", now);
- store.db
- .prepare(`INSERT INTO documents (hash, collection, path, title, created_at, modified_at, active) VALUES (?, ?, ?, ?, ?, ?, ?)`)
- .run("hashA", "test", "alpha.md", "Alpha", now, now, 1);
- store.db
- .prepare(`INSERT INTO documents (hash, collection, path, title, created_at, modified_at, active) VALUES (?, ?, ?, ?, ?, ?, ?)`)
- .run("hashB", "test", "beta.md", "Beta", now, now, 1);
- // Seed vectors_vec with the same fixed vector so stub provider's query
- // embedding lines up with the index entries.
- store.ensureVecTable(DIM);
- store.db
- .prepare(`INSERT INTO content_vectors (hash, seq, pos, model, embedded_at) VALUES (?, 0, 0, 'embeddinggemma', ?)`)
- .run("hashA", now);
- store.db
- .prepare(`INSERT INTO content_vectors (hash, seq, pos, model, embedded_at) VALUES (?, 0, 0, 'embeddinggemma', ?)`)
- .run("hashB", now);
- store.db
- .prepare(`INSERT INTO vectors_vec (hash_seq, embedding) VALUES (?, ?)`)
- .run("hashA_0", new Float32Array(FIXED_VEC));
- store.db
- .prepare(`INSERT INTO vectors_vec (hash_seq, embedding) VALUES (?, ?)`)
- .run("hashB_0", new Float32Array(FIXED_VEC));
- });
- afterEach(() => {
- try { store.close(); } catch { /* ignore */ }
- delete process.env.INDEX_PATH;
- rmSync(workDir, { recursive: true, force: true });
- });
- // ─────────────────────────── searchVec ──────────────────────────────────────
- describe("searchVec with EmbeddingProvider", () => {
- test("encodes the query through the provider when supplied", async () => {
- const provider = new FixedProvider("embeddinggemma", FIXED_VEC);
- // Provider routing must be exclusive.
- const results = await searchVec(
- store.db, "hello", "embeddinggemma", 10,
- undefined, undefined, undefined, provider,
- );
- expect(provider.embedCalls).toBe(1);
- expect(provider.embedBatchCalls).toBe(0);
- expect(results.length).toBeGreaterThan(0);
- // Both alpha + beta share the same vector — both should be returned.
- const filepaths = results.map((r) => r.filepath).sort();
- expect(filepaths).toEqual(["qmd://test/alpha.md", "qmd://test/beta.md"]);
- });
- test("provider mode does not access the compatibility adapter", async () => {
- const provider = new FixedProvider("embeddinggemma", FIXED_VEC);
- // If anything touches `store.llm` while the provider is set, the proxy
- // throws — proves the provider path is truly exclusive (mirrors the
- // i-08ovbvtb regression guard in embedding-store-integration.test.ts).
- store.llm = new Proxy({}, {
- get(_target, prop) {
- throw new Error(
- `store.llm.${String(prop)} accessed when embedProvider was supplied — DoD violation`,
- );
- },
- }) as never;
- const results = await searchVec(
- store.db, "hello", "embeddinggemma", 10,
- undefined, undefined, undefined, provider,
- );
- expect(results.length).toBeGreaterThan(0);
- });
- test("does not select a model fallback when the provider circuit is open", async () => {
- const provider = new CircuitOpenProvider("embeddinggemma");
- await expect(searchVec(
- store.db, "provider failure", "embeddinggemma", 10,
- undefined, undefined, undefined, provider,
- )).rejects.toThrow(/remote down/);
- expect(provider.embedCalls).toBe(1);
- });
- test("surfaces a commercial provider error without fallback", async () => {
- const provider = new AlwaysFailProvider("embeddinggemma");
- await expect(
- searchVec(
- store.db, "doomed", "embeddinggemma", 10,
- undefined, undefined, undefined, provider,
- ),
- ).rejects.toThrow(/backend unreachable/);
- });
- });
- // ─────────────────────────── structuredSearch ───────────────────────────────
- describe("structuredSearch with EmbeddingProvider", () => {
- test("uses provider.embedBatch for vec/hyde sub-queries", async () => {
- const provider = new FixedProvider("embeddinggemma", FIXED_VEC);
- // Deny access to the compatibility adapter to prove provider exclusivity.
- store.llm = new Proxy({}, {
- get(_target, prop) {
- throw new Error(
- `store.llm.${String(prop)} accessed when embedProvider was supplied — DoD violation`,
- );
- },
- }) as never;
- const queries: ExpandedQuery[] = [
- { type: "vec", query: "what is the commercial API failure policy" },
- { type: "hyde", query: "Commercial provider failures remain explicit." },
- ];
- const results = await structuredSearch(store, queries, {
- skipRerank: true,
- embedProvider: provider,
- });
- // One batch call covering both vec/hyde queries.
- expect(provider.embedBatchCalls).toBe(1);
- expect(provider.lastEmbedTexts.length).toBe(2);
- expect(results.length).toBeGreaterThan(0);
- });
- test("structuredSearch propagates an open provider circuit", async () => {
- const provider = new CircuitOpenProvider("embeddinggemma");
- const queries: ExpandedQuery[] = [
- { type: "vec", query: "provider failure" },
- ];
- await expect(structuredSearch(store, queries, {
- skipRerank: true,
- embedProvider: provider,
- })).rejects.toThrow(/remote down/);
- expect(provider.embedBatchCalls).toBe(1);
- });
- test("structuredSearch surfaces a provider batch failure", async () => {
- const provider = new AlwaysFailProvider("embeddinggemma");
- const queries: ExpandedQuery[] = [
- { type: "vec", query: "doomed" },
- ];
- await expect(structuredSearch(store, queries, {
- skipRerank: true,
- embedProvider: provider,
- })).rejects.toThrow(/backend unreachable/);
- });
- });
- // ─────────────────────────── vectorSearchQuery ──────────────────────────────
- describe("vectorSearchQuery with EmbeddingProvider", () => {
- test("encodes original query via provider, no local llm access", async () => {
- const provider = new FixedProvider("embeddinggemma", FIXED_VEC);
- // Stub expandQuery to return no expansions — this isolates the
- // embedding path from the LLM-driven query expansion path.
- store.expandQuery = async () => [];
- store.llm = new Proxy({}, {
- get(_target, prop) {
- throw new Error(
- `store.llm.${String(prop)} accessed when embedProvider was supplied — DoD violation`,
- );
- },
- }) as never;
- const results = await vectorSearchQuery(store, "vector search test", {
- limit: 5,
- minScore: 0,
- embedProvider: provider,
- });
- // vectorSearchQuery sequentializes — at minimum the original query
- // triggers one embed call via the provider.
- expect(provider.embedCalls).toBeGreaterThanOrEqual(1);
- expect(results.length).toBeGreaterThan(0);
- });
- test("vectorSearchQuery does not select a model fallback", async () => {
- const provider = new CircuitOpenProvider("embeddinggemma");
- store.expandQuery = async () => [];
- await expect(vectorSearchQuery(store, "provider failure", {
- minScore: 0,
- embedProvider: provider,
- })).rejects.toThrow(/remote down/);
- expect(provider.embedCalls).toBeGreaterThanOrEqual(1);
- });
- });
- // ─────────────────────────── Backward compat ────────────────────────────────
- describe("precomputed vector path", () => {
- test("searchVec with a precomputed embedding needs no model operation", async () => {
- // When the caller passes `precomputedEmbedding`, searchVec must not
- // touch any embedding backend.
- store.llm = new Proxy({}, {
- get(_target, prop) {
- throw new Error(`store.llm.${String(prop)} accessed unexpectedly`);
- },
- }) as never;
- const results = await searchVec(
- store.db, "hello", "embeddinggemma", 10,
- undefined, undefined, FIXED_VEC, // precomputedEmbedding
- );
- expect(results.length).toBeGreaterThan(0);
- });
- });
|