| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391 |
- /**
- * embedding-vsearch.test.ts — Query-side EmbeddingProvider integration
- * (issue i-loazq6ze).
- *
- * Verifies that `searchVec`, `structuredSearch`, and `vectorSearchQuery`
- * route query encoding through the supplied `EmbeddingProvider` instead
- * of the local `node-llama-cpp` model when one is configured. Also covers
- * the AutoFallback path so a transient remote outage degrades to local
- * instead of throwing.
- *
- * 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 {
- AutoFallbackEmbeddingProvider,
- 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 fallback chain 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);
- // Sanity: store.llm is not set; if searchVec touched local llama-cpp
- // it would fail (no model loaded). 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 local llama-cpp instance", 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("survives transient primary failure via AutoFallback", async () => {
- const primary = new CircuitOpenProvider("embeddinggemma");
- const fallback = new FixedProvider("embeddinggemma", FIXED_VEC);
- const wrapped = new AutoFallbackEmbeddingProvider({
- primary,
- fallback,
- warn: () => { /* swallow noisy WARN in tests */ },
- });
- const results = await searchVec(
- store.db, "fallback test", "embeddinggemma", 10,
- undefined, undefined, undefined, wrapped,
- );
- expect(primary.embedCalls).toBe(1);
- expect(fallback.embedCalls).toBe(1);
- expect(results.length).toBeGreaterThan(0);
- });
- test("surfaces error when both primary AND fallback fail", async () => {
- const primary = new AlwaysFailProvider("embeddinggemma");
- const fallback = new AlwaysFailProvider("embeddinggemma");
- const wrapped = new AutoFallbackEmbeddingProvider({
- primary,
- fallback,
- warn: () => { /* swallow */ },
- });
- await expect(
- searchVec(
- store.db, "doomed", "embeddinggemma", 10,
- undefined, undefined, undefined, wrapped,
- ),
- ).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 local llama-cpp — proves the provider path is exclusive.
- 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 fallback chain about" },
- { type: "hyde", query: "Fallback chains route around primary failure transparently." },
- ];
- const results = await structuredSearch(store, queries, {
- skipRerank: true, // reranker uses local llm — skip in this isolation test
- 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("AutoFallback covers structuredSearch query batch", async () => {
- const primary = new CircuitOpenProvider("embeddinggemma");
- const fallback = new FixedProvider("embeddinggemma", FIXED_VEC);
- const wrapped = new AutoFallbackEmbeddingProvider({
- primary,
- fallback,
- warn: () => { /* swallow */ },
- });
- const queries: ExpandedQuery[] = [
- { type: "vec", query: "fallback test" },
- ];
- const results = await structuredSearch(store, queries, {
- skipRerank: true,
- embedProvider: wrapped,
- });
- expect(primary.embedBatchCalls).toBe(1);
- expect(fallback.embedBatchCalls).toBe(1);
- expect(results.length).toBeGreaterThan(0);
- });
- test("structuredSearch degrades to empty results when both providers fail (batch path)", async () => {
- // AutoFallback.embedBatch is contract-bound to return nulls on total
- // failure (graceful degradation in batch mode — see autofallback.ts
- // onTotalFail). structuredSearch then has no embeddings to query
- // sqlite-vec with and returns []. This is the documented behavior;
- // searchVec (single-embed path) is the one that surfaces a thrown
- // error to the caller, see the test above.
- const primary = new AlwaysFailProvider("embeddinggemma");
- const fallback = new AlwaysFailProvider("embeddinggemma");
- const wrapped = new AutoFallbackEmbeddingProvider({
- primary,
- fallback,
- warn: () => { /* swallow */ },
- });
- const queries: ExpandedQuery[] = [
- { type: "vec", query: "doomed" },
- ];
- const results = await structuredSearch(store, queries, {
- skipRerank: true,
- embedProvider: wrapped,
- });
- expect(results).toEqual([]);
- });
- });
- // ─────────────────────────── 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("AutoFallback rescues vectorSearchQuery from primary failure", async () => {
- const primary = new CircuitOpenProvider("embeddinggemma");
- const fallback = new FixedProvider("embeddinggemma", FIXED_VEC);
- const wrapped = new AutoFallbackEmbeddingProvider({
- primary,
- fallback,
- warn: () => { /* swallow */ },
- });
- store.expandQuery = async () => [];
- const results = await vectorSearchQuery(store, "fallback path", {
- minScore: 0,
- embedProvider: wrapped,
- });
- expect(primary.embedCalls).toBeGreaterThanOrEqual(1);
- expect(fallback.embedCalls).toBeGreaterThanOrEqual(1);
- expect(results.length).toBeGreaterThan(0);
- });
- });
- // ─────────────────────────── Backward compat ────────────────────────────────
- describe("backward compat — no provider supplied", () => {
- test("searchVec without provider uses precomputed embedding path (no llm needed)", async () => {
- // When the caller passes `precomputedEmbedding`, searchVec must not
- // touch any embedding backend at all — neither local nor provider.
- // This is the cheapest backward-compat smoke test we can run without
- // loading node-llama-cpp.
- 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);
- });
- });
|