embed-partial-chunks.test.ts 7.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176
  1. /**
  2. * embed-partial-chunks.test.ts — a document that embedded chunk 0 and lost its
  3. * later chunks must be re-selected on the next pass (i-xeekgx6h).
  4. *
  5. * Before the fix, "does this document need embedding?" was asked as "is there a
  6. * vector at seq 0?", while chunk inserts are per-chunk best-effort. Compose the
  7. * two and a document whose first chunk landed during an upstream outage is
  8. * complete FOREVER: the 30-minute embed cron never selects it again, and its
  9. * missing chunks are unreachable. Nothing errors — semantic search just answers
  10. * from a document with holes in it.
  11. *
  12. * The negative control matters as much as the fix here: a fully embedded corpus
  13. * must NOT be re-selected, or the repair costs a full re-embed every pass.
  14. *
  15. * In-memory SQLite + stub provider — no node-llama-cpp.
  16. */
  17. import { describe, test, expect, beforeEach, afterEach } from "vitest";
  18. import { mkdtempSync, rmSync } from "node:fs";
  19. import { tmpdir } from "node:os";
  20. import { join } from "node:path";
  21. import {
  22. createStore,
  23. generateEmbeddings,
  24. getHashesNeedingEmbedding,
  25. type Store,
  26. } from "../src/store.js";
  27. import type {
  28. EmbeddingProvider,
  29. ProviderEmbedding,
  30. ProviderHealth,
  31. } from "../src/embedding/provider.js";
  32. /**
  33. * Stub provider that can drop chunks the way an unreachable upstream does.
  34. *
  35. * `failBatchTextsFrom` is an index into the texts handed to `embedBatch` across
  36. * the whole run: everything at or after it comes back `null`, which is exactly
  37. * the contract `generateEmbeddings` treats as "this chunk has no embedding" and
  38. * skips inserting. `embed()` (the single-text dimension probe) always succeeds —
  39. * a failing probe aborts the run before any chunk is written, which is a
  40. * different, already-safe path.
  41. */
  42. class DroppingProvider implements EmbeddingProvider {
  43. readonly kind = "openai" as const;
  44. readonly modelId = "stub-embed";
  45. readonly dim = 4;
  46. batchTextsSeen = 0;
  47. constructor(private readonly failBatchTextsFrom = Number.POSITIVE_INFINITY) {}
  48. getModelId(): string { return this.modelId; }
  49. getDimensions(): number | undefined { return this.dim; }
  50. async healthcheck(): Promise<ProviderHealth> {
  51. return { ok: true, model: this.modelId, dimensions: this.dim };
  52. }
  53. async embed(text: string): Promise<ProviderEmbedding | null> {
  54. return { embedding: this.fakeEmbed(text), model: this.modelId };
  55. }
  56. async embedBatch(texts: string[]): Promise<(ProviderEmbedding | null)[]> {
  57. return texts.map((t) => {
  58. const index = this.batchTextsSeen++;
  59. if (index >= this.failBatchTextsFrom) return null;
  60. return { embedding: this.fakeEmbed(t), model: this.modelId };
  61. });
  62. }
  63. async dispose(): Promise<void> {}
  64. private fakeEmbed(text: string): number[] {
  65. return Array.from({ length: this.dim }, (_, i) => (text.length + i) * 0.01);
  66. }
  67. }
  68. let workDir: string;
  69. let store: Store;
  70. /** Long enough to chunk into several pieces (~900 tokens/chunk, ~3 chars/token). */
  71. function multiChunkBody(marker: string): string {
  72. const paragraph = `${marker} paragraph about indexing, retrieval and embeddings. `.repeat(40);
  73. return Array.from({ length: 8 }, (_, i) => `## Section ${i}\n\n${paragraph}`).join("\n\n");
  74. }
  75. function vectorCount(hash: string): number {
  76. return (store.db.prepare(`SELECT COUNT(*) as n FROM content_vectors WHERE hash = ?`).get(hash) as { n: number }).n;
  77. }
  78. function recordedChunkCount(hash: string): number | undefined {
  79. const row = store.db.prepare(`SELECT chunks FROM document_chunk_counts WHERE hash = ?`).get(hash) as { chunks: number } | undefined;
  80. return row?.chunks;
  81. }
  82. beforeEach(() => {
  83. workDir = mkdtempSync(join(tmpdir(), "qmd-partial-chunks-test-"));
  84. process.env.INDEX_PATH = join(workDir, "index.sqlite");
  85. store = createStore(process.env.INDEX_PATH);
  86. const now = "2026-08-15T00:00:00Z";
  87. store.db
  88. .prepare(`INSERT INTO content (hash, doc, created_at) VALUES (?, ?, ?)`)
  89. .run("hashPartial", multiChunkBody("alpha"), now);
  90. store.db
  91. .prepare(`INSERT INTO documents (hash, collection, path, title, created_at, modified_at, active) VALUES (?, ?, ?, ?, ?, ?, ?)`)
  92. .run("hashPartial", "alpha", "a.md", "a.md", now, now, 1);
  93. });
  94. afterEach(() => {
  95. try { store.close(); } catch { /* ignore */ }
  96. delete process.env.INDEX_PATH;
  97. rmSync(workDir, { recursive: true, force: true });
  98. });
  99. describe("partially embedded documents are re-selected (i-xeekgx6h)", () => {
  100. test("chunk 0 embedded + later chunks dropped => document is STILL pending", async () => {
  101. // Upstream dies after the first chunk of the document.
  102. const dropping = new DroppingProvider(1);
  103. await generateEmbeddings(store, { embedProvider: dropping });
  104. const expected = recordedChunkCount("hashPartial");
  105. expect(expected).toBeDefined();
  106. expect(expected!).toBeGreaterThan(1); // the body really did chunk into several pieces
  107. expect(vectorCount("hashPartial")).toBe(1); // ... and only chunk 0 survived
  108. // THE assertion. Before the fix this was 0: chunk 0 exists, so the document
  109. // read as complete and its missing chunks were unreachable forever.
  110. expect(getHashesNeedingEmbedding(store.db)).toBe(1);
  111. });
  112. test("a later pass with a healthy upstream fills the missing chunks", async () => {
  113. await generateEmbeddings(store, { embedProvider: new DroppingProvider(1) });
  114. const expected = recordedChunkCount("hashPartial")!;
  115. expect(vectorCount("hashPartial")).toBe(1);
  116. const healed = await generateEmbeddings(store, { embedProvider: new DroppingProvider() });
  117. expect(healed.docsProcessed).toBe(1);
  118. expect(vectorCount("hashPartial")).toBe(expected);
  119. expect(getHashesNeedingEmbedding(store.db)).toBe(0);
  120. });
  121. test("CONTROL: a fully embedded document is NOT re-selected", async () => {
  122. const first = await generateEmbeddings(store, { embedProvider: new DroppingProvider() });
  123. expect(first.docsProcessed).toBe(1);
  124. expect(getHashesNeedingEmbedding(store.db)).toBe(0);
  125. // The whole point of the control: without it, "re-select partial documents"
  126. // could be satisfied by re-selecting EVERYTHING, i.e. re-embedding the corpus
  127. // on every cron tick.
  128. const second = await generateEmbeddings(store, { embedProvider: new DroppingProvider() });
  129. expect(second.docsProcessed).toBe(0);
  130. expect(second.chunksEmbedded).toBe(0);
  131. });
  132. test("CONTROL: a legacy document with no recorded chunk count is left alone", async () => {
  133. await generateEmbeddings(store, { embedProvider: new DroppingProvider() });
  134. // Simulate a document embedded before document_chunk_counts existed, whose
  135. // later chunks are missing: no expectation is recorded, so it cannot be
  136. // detected as partial. It is deliberately NOT re-selected — a whole-corpus
  137. // re-embed on upgrade would cost more than the holes it heals; `--force`
  138. // (or the next re-chunk) is the repair path.
  139. store.db.prepare(`DELETE FROM document_chunk_counts WHERE hash = ?`).run("hashPartial");
  140. store.db.prepare(`DELETE FROM content_vectors WHERE hash = ? AND seq > 0`).run("hashPartial");
  141. expect(vectorCount("hashPartial")).toBe(1);
  142. expect(recordedChunkCount("hashPartial")).toBeUndefined();
  143. expect(getHashesNeedingEmbedding(store.db)).toBe(0);
  144. });
  145. test("the collection-filtered count agrees with the unfiltered one", async () => {
  146. // The two queries drifting apart is what produced this bug: they each
  147. // carried their own copy of the pending predicate.
  148. await generateEmbeddings(store, { embedProvider: new DroppingProvider(1) });
  149. expect(getHashesNeedingEmbedding(store.db)).toBe(1);
  150. expect(getHashesNeedingEmbedding(store.db, "alpha")).toBe(1);
  151. expect(getHashesNeedingEmbedding(store.db, "nonexistent")).toBe(0);
  152. });
  153. });