@remit/search-service 0.0.20 → 0.0.21

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@remit/search-service",
3
- "version": "0.0.20",
3
+ "version": "0.0.21",
4
4
  "type": "module",
5
5
  "main": "src/index.ts",
6
6
  "types": "src/index.ts",
@@ -10,10 +10,16 @@
10
10
  * npm run test:integ:local -w packages/search-service
11
11
  */
12
12
  import assert from "node:assert";
13
+ import { existsSync, mkdtempSync, rmSync } from "node:fs";
14
+ import { tmpdir } from "node:os";
15
+ import { join } from "node:path";
13
16
  import { after, before, describe, test } from "node:test";
14
17
  import type { ChunkMetadata, VectorRecord } from "../types.js";
15
18
  import type { VectorStoreService } from "./memory.js";
16
- import { createSqliteVectorStore } from "./sqlite-vec.js";
19
+ import {
20
+ createSqliteVectorStore,
21
+ readSqliteIndexProvenance,
22
+ } from "./sqlite-vec.js";
17
23
 
18
24
  const RUN = process.env.RUN_INTEG_TESTS === "1";
19
25
  const DIMENSIONS = 4;
@@ -197,3 +203,63 @@ describe("sqlite-vec store — SQLITE_VEC_EXTENSION_PATH override (integration)"
197
203
  assert.ok(matches[0].score > 0.99);
198
204
  });
199
205
  });
206
+
207
+ // #455. The report reads the stored index rather than the store's query path,
208
+ // so it has to be proved against a real vec0 table on disk: an in-memory store
209
+ // would never show that a report can read a file it did not create.
210
+ describe("index provenance (integration)", { skip: !RUN }, () => {
211
+ const dir = mkdtempSync(join(tmpdir(), "remit-index-report-"));
212
+ const path = join(dir, "vec.db");
213
+ let store: VectorStoreService;
214
+
215
+ before(async () => {
216
+ store = createSqliteVectorStore({ path, dimensions: DIMENSIONS });
217
+ await store.upsert([
218
+ record("p-1", [1, 0, 0, 0], {
219
+ messageId: "m-1",
220
+ embeddingId: "current@4",
221
+ }),
222
+ record("p-2", [0, 1, 0, 0], {
223
+ messageId: "m-1",
224
+ embeddingId: "current@4",
225
+ }),
226
+ record("p-3", [0, 0, 1, 0], {
227
+ messageId: "m-2",
228
+ embeddingId: "older@4",
229
+ }),
230
+ record("p-4", [0, 0, 0, 1], { messageId: "m-3" }),
231
+ ]);
232
+ });
233
+
234
+ after(async () => {
235
+ await store.close?.();
236
+ rmSync(dir, { recursive: true, force: true });
237
+ });
238
+
239
+ test("counts the stored vectors by the embedder that wrote them", async () => {
240
+ const report = await readSqliteIndexProvenance({
241
+ path,
242
+ configuredEmbeddingId: "current@4",
243
+ });
244
+ assert.equal(report.chunks, 4);
245
+ assert.equal(report.messages, 3);
246
+ assert.deepEqual(report.groups, [
247
+ { embeddingId: "current@4", chunks: 2, messages: 1, current: true },
248
+ { embeddingId: "older@4", chunks: 1, messages: 1, current: false },
249
+ { embeddingId: "unknown", chunks: 1, messages: 1, current: false },
250
+ ]);
251
+ });
252
+
253
+ // A box that has never indexed anything must not grow a vector database from
254
+ // being asked about one.
255
+ test("reports an empty index without creating the database", async () => {
256
+ const absent = join(dir, "missing.db");
257
+ const report = await readSqliteIndexProvenance({
258
+ path: absent,
259
+ configuredEmbeddingId: "current@4",
260
+ });
261
+ assert.deepEqual(report.groups, []);
262
+ assert.equal(report.chunks, 0);
263
+ assert.equal(existsSync(absent), false);
264
+ });
265
+ });
@@ -1,6 +1,11 @@
1
- import { mkdirSync } from "node:fs";
1
+ import { existsSync, mkdirSync } from "node:fs";
2
2
  import { dirname } from "node:path";
3
3
  import type SqliteDatabase from "better-sqlite3";
4
+ import {
5
+ type IndexedChunkProvenance,
6
+ type IndexProvenance,
7
+ summarizeIndexProvenance,
8
+ } from "../index-report.js";
4
9
  import type {
5
10
  ChunkMetadata,
6
11
  VectorMatch,
@@ -14,7 +19,7 @@ import { runtimeImport } from "./runtime-import.js";
14
19
  type Database = SqliteDatabase.Database;
15
20
 
16
21
  type BetterSqlite3Module = {
17
- default: new (path: string) => Database;
22
+ default: new (path: string, options?: SqliteDatabase.Options) => Database;
18
23
  };
19
24
 
20
25
  type SqliteVecModule = {
@@ -127,6 +132,63 @@ const buildFilterClause = (
127
132
  return { sql: sql.length > 0 ? ` AND ${sql.join(" AND ")}` : "", params };
128
133
  };
129
134
 
135
+ /**
136
+ * Count the stored index by the embedder that wrote each vector (#455), read
137
+ * straight off the vec0 table rather than through the store: a report is not a
138
+ * search, and the caller has no query to run.
139
+ *
140
+ * Reads only. `fileMustExist` keeps a report on a box that has never indexed
141
+ * anything from creating the vector database as a side effect of asking about
142
+ * it, and an absent vec0 table — the window between the first boot and the
143
+ * first upsert — is an empty index rather than an error. The connection is not
144
+ * opened read-only: these files are WAL, and a read-only connection cannot
145
+ * initialize the shared-memory index when no writer is attached.
146
+ *
147
+ * The rows are streamed into the summary. One row per chunk means several per
148
+ * message, and a full mailbox's worth of metadata JSON must not be materialized
149
+ * to be counted.
150
+ */
151
+ export const readSqliteIndexProvenance = async (config: {
152
+ path: string;
153
+ configuredEmbeddingId: string;
154
+ }): Promise<IndexProvenance> => {
155
+ if (!existsSync(config.path)) {
156
+ return summarizeIndexProvenance(config.configuredEmbeddingId, []);
157
+ }
158
+ const { default: Database } =
159
+ await runtimeImport<BetterSqlite3Module>("better-sqlite3");
160
+ const db = new Database(config.path, { fileMustExist: true });
161
+ try {
162
+ await loadSqliteVec(db);
163
+ const table = db
164
+ .prepare(
165
+ "SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'vec_chunks'",
166
+ )
167
+ .get();
168
+ if (!table) {
169
+ return summarizeIndexProvenance(config.configuredEmbeddingId, []);
170
+ }
171
+ const rows = db
172
+ .prepare("SELECT message_id AS messageId, meta FROM vec_chunks")
173
+ .iterate() as IterableIterator<{ messageId: string; meta: string }>;
174
+ return summarizeIndexProvenance(
175
+ config.configuredEmbeddingId,
176
+ provenanceOf(rows),
177
+ );
178
+ } finally {
179
+ db.close();
180
+ }
181
+ };
182
+
183
+ function* provenanceOf(
184
+ rows: Iterable<{ messageId: string; meta: string }>,
185
+ ): Generator<IndexedChunkProvenance> {
186
+ for (const row of rows) {
187
+ const metadata = JSON.parse(row.meta) as ChunkMetadata;
188
+ yield { messageId: row.messageId, embeddingId: metadata.embeddingId };
189
+ }
190
+ }
191
+
130
192
  export interface SqliteVectorStoreConfig {
131
193
  path: string;
132
194
  dimensions?: number;
@@ -0,0 +1,129 @@
1
+ import assert from "node:assert/strict";
2
+ import { describe, it } from "node:test";
3
+ import {
4
+ formatIndexProvenance,
5
+ type IndexedChunkProvenance,
6
+ summarizeIndexProvenance,
7
+ } from "./index-report.js";
8
+
9
+ const CURRENT = "local:multilingual-MiniLM:q8@384";
10
+ const OLDER = "local:MiniLM@384";
11
+
12
+ const chunk = (
13
+ messageId: string,
14
+ embeddingId: string | undefined,
15
+ ): IndexedChunkProvenance => ({ messageId, embeddingId });
16
+
17
+ describe("summarizeIndexProvenance", () => {
18
+ it("counts vectors and the messages they belong to per embedder", () => {
19
+ const report = summarizeIndexProvenance(CURRENT, [
20
+ chunk("m1", CURRENT),
21
+ chunk("m1", CURRENT),
22
+ chunk("m2", OLDER),
23
+ ]);
24
+ assert.equal(report.chunks, 3);
25
+ assert.equal(report.messages, 2);
26
+ assert.deepEqual(report.groups, [
27
+ { embeddingId: CURRENT, chunks: 2, messages: 1, current: true },
28
+ { embeddingId: OLDER, chunks: 1, messages: 1, current: false },
29
+ ]);
30
+ });
31
+
32
+ // The state the report exists to make visible: a switched embedder leaves the
33
+ // index holding two vector spaces, and every query compares them as one.
34
+ it("separates the configured embedder from every other one", () => {
35
+ const report = summarizeIndexProvenance(CURRENT, [
36
+ chunk("m1", OLDER),
37
+ chunk("m2", OLDER),
38
+ chunk("m3", CURRENT),
39
+ ]);
40
+ assert.deepEqual(
41
+ report.groups.map((group) => [group.embeddingId, group.current]),
42
+ [
43
+ [OLDER, false],
44
+ [CURRENT, true],
45
+ ],
46
+ );
47
+ });
48
+
49
+ // Vectors written before metadata carried an embeddingId are a third state,
50
+ // not "the current model": nothing recorded what wrote them.
51
+ it("buckets vectors with no recorded embedder as unknown", () => {
52
+ const report = summarizeIndexProvenance(CURRENT, [
53
+ chunk("m1", undefined),
54
+ chunk("m2", CURRENT),
55
+ ]);
56
+ const unknown = report.groups.find(
57
+ (group) => group.embeddingId === "unknown",
58
+ );
59
+ assert.deepEqual(unknown, {
60
+ embeddingId: "unknown",
61
+ chunks: 1,
62
+ messages: 1,
63
+ current: false,
64
+ });
65
+ });
66
+
67
+ // Two runs over an unchanged index have to print the same report, or a diff
68
+ // between them is noise rather than a change.
69
+ it("orders groups by size and then by id", () => {
70
+ const report = summarizeIndexProvenance(CURRENT, [
71
+ chunk("m1", "b"),
72
+ chunk("m2", "a"),
73
+ chunk("m3", "c"),
74
+ chunk("m4", "c"),
75
+ ]);
76
+ assert.deepEqual(
77
+ report.groups.map((group) => group.embeddingId),
78
+ ["c", "a", "b"],
79
+ );
80
+ });
81
+
82
+ it("reports an empty index as empty rather than as unknown", () => {
83
+ const report = summarizeIndexProvenance(CURRENT, []);
84
+ assert.equal(report.chunks, 0);
85
+ assert.equal(report.messages, 0);
86
+ assert.deepEqual(report.groups, []);
87
+ });
88
+ });
89
+
90
+ describe("formatIndexProvenance", () => {
91
+ it("names the configured embedder and every one in the index", () => {
92
+ const text = formatIndexProvenance(
93
+ summarizeIndexProvenance(CURRENT, [
94
+ chunk("m1", CURRENT),
95
+ chunk("m2", OLDER),
96
+ ]),
97
+ );
98
+ assert.match(text, new RegExp(`Configured embedder: ${CURRENT}`));
99
+ assert.match(text, /2 vectors over 2 messages/);
100
+ assert.match(text, /\(current\)/);
101
+ assert.match(text, /\(older model\)/);
102
+ });
103
+
104
+ // An empty index and an index on a stale model both answer every query with
105
+ // nothing, so the report has to say which one this is.
106
+ it("says an empty index is empty", () => {
107
+ const text = formatIndexProvenance(summarizeIndexProvenance(CURRENT, []));
108
+ assert.match(text, /Nothing is indexed/);
109
+ });
110
+
111
+ it("counts the messages a switched embedder left behind", () => {
112
+ const text = formatIndexProvenance(
113
+ summarizeIndexProvenance(CURRENT, [
114
+ chunk("m1", OLDER),
115
+ chunk("m2", OLDER),
116
+ chunk("m3", CURRENT),
117
+ ]),
118
+ );
119
+ assert.match(text, /2 messages carry vectors from an embedder/);
120
+ });
121
+
122
+ it("says nothing about older models when every vector is current", () => {
123
+ const text = formatIndexProvenance(
124
+ summarizeIndexProvenance(CURRENT, [chunk("m1", CURRENT)]),
125
+ );
126
+ assert.ok(!text.includes("older model"), text);
127
+ assert.ok(!text.includes("no longer configured"), text);
128
+ });
129
+ });
@@ -0,0 +1,129 @@
1
+ import { UNKNOWN_CHUNK_EMBEDDING_ID } from "./anchor.js";
2
+
3
+ /**
4
+ * One indexed chunk, reduced to the two facts a provenance report is about: the
5
+ * message it belongs to, and the embedder that produced its vector. Vectors
6
+ * written before `metadata.embeddingId` existed carry no id at all (RFC 039 /
7
+ * #349), which is a third state and not "the current model".
8
+ */
9
+ export interface IndexedChunkProvenance {
10
+ messageId: string;
11
+ embeddingId: string | undefined;
12
+ }
13
+
14
+ export interface IndexProvenanceGroup {
15
+ embeddingId: string;
16
+ chunks: number;
17
+ messages: number;
18
+ /** Whether this group's vectors came from the embedder configured now. */
19
+ current: boolean;
20
+ }
21
+
22
+ export interface IndexProvenance {
23
+ configuredEmbeddingId: string;
24
+ chunks: number;
25
+ messages: number;
26
+ groups: IndexProvenanceGroup[];
27
+ }
28
+
29
+ /**
30
+ * Count an index by the embedder its vectors came from, against the one this
31
+ * deployment is configured with (#455). Switching the embedder re-embeds a
32
+ * message only when something touches it, so an index that has seen a switch
33
+ * holds two vector spaces whose distances are not comparable — a silent ranking
34
+ * loss that no query result distinguishes from having nothing indexed.
35
+ *
36
+ * The chunks are an `Iterable` rather than an array so a caller can stream a
37
+ * cursor through it: an index is one row per chunk, several per message, and a
38
+ * corpus-sized report must not materialize all of them to count them.
39
+ */
40
+ export const summarizeIndexProvenance = (
41
+ configuredEmbeddingId: string,
42
+ chunks: Iterable<IndexedChunkProvenance>,
43
+ ): IndexProvenance => {
44
+ const groups = new Map<string, { chunks: number; messages: Set<string> }>();
45
+ const messages = new Set<string>();
46
+ let total = 0;
47
+ for (const chunk of chunks) {
48
+ const embeddingId = chunk.embeddingId ?? UNKNOWN_CHUNK_EMBEDDING_ID;
49
+ const group = groups.get(embeddingId) ?? {
50
+ chunks: 0,
51
+ messages: new Set<string>(),
52
+ };
53
+ group.chunks += 1;
54
+ group.messages.add(chunk.messageId);
55
+ groups.set(embeddingId, group);
56
+ messages.add(chunk.messageId);
57
+ total += 1;
58
+ }
59
+ return {
60
+ configuredEmbeddingId,
61
+ chunks: total,
62
+ messages: messages.size,
63
+ groups: [...groups]
64
+ .map(([embeddingId, group]) => ({
65
+ embeddingId,
66
+ chunks: group.chunks,
67
+ messages: group.messages.size,
68
+ current: embeddingId === configuredEmbeddingId,
69
+ }))
70
+ // Largest first, then by id, so two runs over an unchanged index print
71
+ // the same report and a diff between two runs is a real change.
72
+ .sort(
73
+ (a, b) =>
74
+ b.chunks - a.chunks || a.embeddingId.localeCompare(b.embeddingId),
75
+ ),
76
+ };
77
+ };
78
+
79
+ const plural = (count: number, noun: string): string =>
80
+ `${count} ${noun}${count === 1 ? "" : "s"}`;
81
+
82
+ /**
83
+ * The report as an operator reads it, one fact per line. Every line states what
84
+ * it means for search rather than leaving a number to be interpreted: a count
85
+ * under an older model is vectors that still answer queries, from a space of
86
+ * their own.
87
+ */
88
+ export const formatIndexProvenance = (report: IndexProvenance): string => {
89
+ const lines = [
90
+ `Configured embedder: ${report.configuredEmbeddingId}`,
91
+ `Indexed: ${plural(report.chunks, "vector")} over ${plural(
92
+ report.messages,
93
+ "message",
94
+ )}`,
95
+ ];
96
+ if (report.groups.length === 0) {
97
+ lines.push(
98
+ "",
99
+ "Nothing is indexed. Semantic filters and the Organize semantic widen match",
100
+ "nothing until the worker has embedded this mailbox.",
101
+ );
102
+ return `${lines.join("\n")}\n`;
103
+ }
104
+ lines.push("", "By the embedder that wrote them:");
105
+ for (const group of report.groups) {
106
+ const mark = group.current ? "current" : "older model";
107
+ lines.push(
108
+ ` ${group.embeddingId} ${plural(group.chunks, "vector")} over ${plural(
109
+ group.messages,
110
+ "message",
111
+ )} (${mark})`,
112
+ );
113
+ }
114
+ const stale = report.groups.filter((group) => !group.current);
115
+ if (stale.length > 0) {
116
+ const staleMessages = stale.reduce(
117
+ (total, group) => total + group.messages,
118
+ 0,
119
+ );
120
+ lines.push(
121
+ "",
122
+ `${plural(staleMessages, "message")} carry vectors from an embedder this`,
123
+ "deployment is no longer configured with. They are compared against current",
124
+ "vectors as though they shared a vector space, which they do not, and they",
125
+ "are re-embedded only when something touches the message.",
126
+ );
127
+ }
128
+ return `${lines.join("\n")}\n`;
129
+ };
package/src/index.ts CHANGED
@@ -44,6 +44,13 @@ export {
44
44
  type LocalEmbeddingConfig,
45
45
  LocalEmbeddingService,
46
46
  } from "./embeddings.js";
47
+ export {
48
+ formatIndexProvenance,
49
+ type IndexedChunkProvenance,
50
+ type IndexProvenance,
51
+ type IndexProvenanceGroup,
52
+ summarizeIndexProvenance,
53
+ } from "./index-report.js";
47
54
  export {
48
55
  createSearchService,
49
56
  DefaultSearchService,
package/src/sqlite-vec.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  export {
2
2
  createSqliteVectorStore,
3
+ readSqliteIndexProvenance,
3
4
  type SqliteVectorStoreConfig,
4
5
  } from "./backends/sqlite-vec.js";