@remit/search-service 0.0.11 → 0.0.13
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 +4 -2
- package/src/anchor.test.ts +104 -7
- package/src/anchor.ts +42 -5
- package/src/backends/pgvector.integ.test.ts +1 -1
- package/src/backends/s3-vectors.test.ts +52 -0
- package/src/backends/s3-vectors.ts +7 -0
- package/src/backends/sqlite-vec.integ.test.ts +1 -1
- package/src/search.test.ts +31 -0
- package/src/search.ts +2 -0
- package/src/semantic-search.integ.test.ts +1 -1
- package/src/types.ts +10 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@remit/search-service",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.13",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"main": "src/index.ts",
|
|
6
6
|
"types": "src/index.ts",
|
|
@@ -33,7 +33,9 @@
|
|
|
33
33
|
"scripts": {
|
|
34
34
|
"test:typecheck": "tsgo --noEmit",
|
|
35
35
|
"test:run": "node --env-file=../../localhost-test-unit.env --import tsx --experimental-test-coverage --test-coverage-include='src/**' --test-coverage-exclude='src/**/*.test.ts' --test-coverage-exclude='src/**/*.test.tsx' --test-coverage-lines=82 --test 'src/**/*.test.ts'",
|
|
36
|
-
"test:integ": "RUN_INTEG_TESTS=1 node --env-file=../../localhost-test-unit.env --import tsx --test 'src
|
|
36
|
+
"test:integ:local": "RUN_INTEG_TESTS=1 node --env-file=../../localhost-test-unit.env --import tsx --test 'src/backends/sqlite-vec.integ.test.ts'",
|
|
37
|
+
"test:integ:pg": "RUN_INTEG_TESTS=1 node --env-file=../../localhost-test-unit.env --import tsx --test 'src/backends/pgvector.integ.test.ts'",
|
|
38
|
+
"test:integ:model": "RUN_INTEG_TESTS=1 node --env-file=../../localhost-test-unit.env --import tsx --test 'src/semantic-search.integ.test.ts'",
|
|
37
39
|
"test": "npm run test:typecheck && npm run test:run"
|
|
38
40
|
},
|
|
39
41
|
"dependencies": {
|
package/src/anchor.test.ts
CHANGED
|
@@ -3,15 +3,18 @@ import { describe, it } from "node:test";
|
|
|
3
3
|
import {
|
|
4
4
|
buildAnchorSourceText,
|
|
5
5
|
buildMessageAnchor,
|
|
6
|
+
deriveAnchorEmbeddingId,
|
|
6
7
|
poolChunkVectors,
|
|
8
|
+
UNKNOWN_CHUNK_EMBEDDING_ID,
|
|
7
9
|
} from "./anchor.js";
|
|
8
10
|
import { createMemoryVectorStore } from "./backends/memory.js";
|
|
9
|
-
import { createDeterministicEmbeddingService } from "./embeddings.js";
|
|
10
11
|
import type { ChunkMetadata, VectorRecord } from "./types.js";
|
|
11
12
|
|
|
12
13
|
const l2Norm = (vector: number[]): number =>
|
|
13
14
|
Math.sqrt(vector.reduce((sum, value) => sum + value * value, 0));
|
|
14
15
|
|
|
16
|
+
const CURRENT_MODEL = "deterministic@8";
|
|
17
|
+
|
|
15
18
|
const baseMetadata = (
|
|
16
19
|
overrides: Partial<ChunkMetadata> = {},
|
|
17
20
|
): ChunkMetadata => ({
|
|
@@ -24,6 +27,7 @@ const baseMetadata = (
|
|
|
24
27
|
isRead: false,
|
|
25
28
|
hasAttachment: false,
|
|
26
29
|
hasStars: false,
|
|
30
|
+
embeddingId: CURRENT_MODEL,
|
|
27
31
|
...overrides,
|
|
28
32
|
});
|
|
29
33
|
|
|
@@ -93,8 +97,6 @@ describe("buildAnchorSourceText", () => {
|
|
|
93
97
|
});
|
|
94
98
|
|
|
95
99
|
describe("buildMessageAnchor", () => {
|
|
96
|
-
const embedder = createDeterministicEmbeddingService({ dimensions: 8 });
|
|
97
|
-
|
|
98
100
|
const putRecord = async (
|
|
99
101
|
store: ReturnType<typeof createMemoryVectorStore>,
|
|
100
102
|
record: VectorRecord,
|
|
@@ -120,12 +122,12 @@ describe("buildMessageAnchor", () => {
|
|
|
120
122
|
});
|
|
121
123
|
|
|
122
124
|
const anchor = await buildMessageAnchor(
|
|
123
|
-
{ store
|
|
125
|
+
{ store },
|
|
124
126
|
{ accountConfigId: "acct-1", anchorMessageId: "msg-1" },
|
|
125
127
|
);
|
|
126
128
|
|
|
127
129
|
assert.ok(anchor);
|
|
128
|
-
assert.equal(anchor.anchorEmbeddingId,
|
|
130
|
+
assert.equal(anchor.anchorEmbeddingId, CURRENT_MODEL);
|
|
129
131
|
assert.equal(anchor.anchorEmbedding.length, 8);
|
|
130
132
|
assert.ok(Math.abs(l2Norm(anchor.anchorEmbedding) - 1) < 1e-9);
|
|
131
133
|
assert.equal(
|
|
@@ -137,7 +139,7 @@ describe("buildMessageAnchor", () => {
|
|
|
137
139
|
it("returns null when the message has no indexed chunks", async () => {
|
|
138
140
|
const store = createMemoryVectorStore();
|
|
139
141
|
const anchor = await buildMessageAnchor(
|
|
140
|
-
{ store
|
|
142
|
+
{ store },
|
|
141
143
|
{ accountConfigId: "acct-1", anchorMessageId: "absent" },
|
|
142
144
|
);
|
|
143
145
|
assert.equal(anchor, null);
|
|
@@ -156,9 +158,104 @@ describe("buildMessageAnchor", () => {
|
|
|
156
158
|
});
|
|
157
159
|
|
|
158
160
|
const anchor = await buildMessageAnchor(
|
|
159
|
-
{ store
|
|
161
|
+
{ store },
|
|
160
162
|
{ accountConfigId: "acct-1", anchorMessageId: "msg-1" },
|
|
161
163
|
);
|
|
162
164
|
assert.equal(anchor, null);
|
|
163
165
|
});
|
|
166
|
+
|
|
167
|
+
it("stamps the model the pooled chunks were actually embedded under, not a caller-supplied current model (#349)", async () => {
|
|
168
|
+
const store = createMemoryVectorStore();
|
|
169
|
+
await putRecord(store, {
|
|
170
|
+
chunkId: "msg-1::subject",
|
|
171
|
+
vector: [1, 0, 0, 0, 0, 0, 0, 0],
|
|
172
|
+
metadata: baseMetadata({
|
|
173
|
+
chunkType: "subject",
|
|
174
|
+
embeddingId: "amazon.titan-embed-text-v1@1024",
|
|
175
|
+
textPreview: "booking confirmed",
|
|
176
|
+
}),
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
const anchor = await buildMessageAnchor(
|
|
180
|
+
{ store },
|
|
181
|
+
{ accountConfigId: "acct-1", anchorMessageId: "msg-1" },
|
|
182
|
+
);
|
|
183
|
+
|
|
184
|
+
assert.ok(anchor);
|
|
185
|
+
assert.equal(anchor.anchorEmbeddingId, "amazon.titan-embed-text-v1@1024");
|
|
186
|
+
assert.notEqual(anchor.anchorEmbeddingId, CURRENT_MODEL);
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
it("throws when a message's chunks disagree on which model produced them", async () => {
|
|
190
|
+
const store = createMemoryVectorStore();
|
|
191
|
+
await putRecord(store, {
|
|
192
|
+
chunkId: "msg-1::subject",
|
|
193
|
+
vector: [1, 0, 0, 0, 0, 0, 0, 0],
|
|
194
|
+
metadata: baseMetadata({
|
|
195
|
+
chunkType: "subject",
|
|
196
|
+
embeddingId: "model-a@8",
|
|
197
|
+
textPreview: "booking confirmed",
|
|
198
|
+
}),
|
|
199
|
+
});
|
|
200
|
+
await putRecord(store, {
|
|
201
|
+
chunkId: "msg-1::body-0",
|
|
202
|
+
vector: [0, 1, 0, 0, 0, 0, 0, 0],
|
|
203
|
+
metadata: baseMetadata({
|
|
204
|
+
chunkType: "body",
|
|
205
|
+
embeddingId: "model-b@8",
|
|
206
|
+
textPreview: "your trip is booked",
|
|
207
|
+
}),
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
await assert.rejects(
|
|
211
|
+
buildMessageAnchor(
|
|
212
|
+
{ store },
|
|
213
|
+
{ accountConfigId: "acct-1", anchorMessageId: "msg-1" },
|
|
214
|
+
),
|
|
215
|
+
/mismatch/,
|
|
216
|
+
);
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
it("treats chunks with no recorded embeddingId as an unknown model, never the current one", async () => {
|
|
220
|
+
const store = createMemoryVectorStore();
|
|
221
|
+
await putRecord(store, {
|
|
222
|
+
chunkId: "msg-1::subject",
|
|
223
|
+
vector: [1, 0, 0, 0, 0, 0, 0, 0],
|
|
224
|
+
metadata: baseMetadata({
|
|
225
|
+
chunkType: "subject",
|
|
226
|
+
embeddingId: undefined,
|
|
227
|
+
textPreview: "booking confirmed",
|
|
228
|
+
}),
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
const anchor = await buildMessageAnchor(
|
|
232
|
+
{ store },
|
|
233
|
+
{ accountConfigId: "acct-1", anchorMessageId: "msg-1" },
|
|
234
|
+
);
|
|
235
|
+
|
|
236
|
+
assert.ok(anchor);
|
|
237
|
+
assert.equal(anchor.anchorEmbeddingId, UNKNOWN_CHUNK_EMBEDDING_ID);
|
|
238
|
+
});
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
describe("deriveAnchorEmbeddingId", () => {
|
|
242
|
+
const record = (embeddingId: string | undefined): VectorRecord => ({
|
|
243
|
+
chunkId: "c",
|
|
244
|
+
vector: [1],
|
|
245
|
+
metadata: baseMetadata({ embeddingId }),
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
it("returns the shared id when every record agrees", () => {
|
|
249
|
+
assert.equal(
|
|
250
|
+
deriveAnchorEmbeddingId([record("a@1"), record("a@1")]),
|
|
251
|
+
"a@1",
|
|
252
|
+
);
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
it("throws on disagreement between a known id and an unknown one", () => {
|
|
256
|
+
assert.throws(
|
|
257
|
+
() => deriveAnchorEmbeddingId([record("a@1"), record(undefined)]),
|
|
258
|
+
/mismatch/,
|
|
259
|
+
);
|
|
260
|
+
});
|
|
164
261
|
});
|
package/src/anchor.ts
CHANGED
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
import type { VectorStoreService } from "./backends/memory.js";
|
|
2
|
-
import type { EmbeddingService } from "./embeddings.js";
|
|
3
2
|
import { buildTextPreview } from "./search.js";
|
|
4
3
|
import type { ChunkType, VectorRecord } from "./types.js";
|
|
5
4
|
|
|
@@ -46,6 +45,40 @@ export const poolChunkVectors = (vectors: number[][]): number[] => {
|
|
|
46
45
|
return sum.map((value) => value / norm);
|
|
47
46
|
};
|
|
48
47
|
|
|
48
|
+
/**
|
|
49
|
+
* Stamped when none of a message's pooled chunks carry an `embeddingId` (RFC
|
|
50
|
+
* 039 / #349) — vectors written before that metadata field existed. Distinct
|
|
51
|
+
* from every real `EmbeddingService.embeddingId` value, so a drift check that
|
|
52
|
+
* compares against the current model ("does this anchor match what's
|
|
53
|
+
* configured now?") never mistakes an unknown-provenance vector for current;
|
|
54
|
+
* it is always treated as needing a fresh anchor.
|
|
55
|
+
*/
|
|
56
|
+
export const UNKNOWN_CHUNK_EMBEDDING_ID = "unknown";
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Derive the single `embeddingId` the pooled chunk records were actually
|
|
60
|
+
* embedded under (#349) — never the caller's currently-configured embedder,
|
|
61
|
+
* which may have changed since these chunks were last (re-)indexed. All
|
|
62
|
+
* chunks for one message are written by the same index-time pass, so they
|
|
63
|
+
* must agree; a chunk with no recorded `embeddingId` (pre-dating that field)
|
|
64
|
+
* counts as `UNKNOWN_CHUNK_EMBEDDING_ID`. Disagreement — including a mix of a
|
|
65
|
+
* known id and an unknown one — throws rather than silently picking one,
|
|
66
|
+
* consistent with `poolChunkVectors`'s "let it crash" stance on a dimension
|
|
67
|
+
* mismatch.
|
|
68
|
+
*/
|
|
69
|
+
export const deriveAnchorEmbeddingId = (records: VectorRecord[]): string => {
|
|
70
|
+
const ids = new Set(
|
|
71
|
+
records.map((r) => r.metadata.embeddingId ?? UNKNOWN_CHUNK_EMBEDDING_ID),
|
|
72
|
+
);
|
|
73
|
+
if (ids.size > 1) {
|
|
74
|
+
throw new Error(
|
|
75
|
+
`Chunk embeddingId mismatch pooling anchor from ${records.length} records: ${[...ids].join(", ")}`,
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
const [id] = ids;
|
|
79
|
+
return id;
|
|
80
|
+
};
|
|
81
|
+
|
|
49
82
|
// The chunk types whose text carries the semantic meaning of "messages like
|
|
50
83
|
// this" — subject and body. Structured chunks (sender, recipient, attachment,
|
|
51
84
|
// entities) are excluded from the re-embeddable source text; they add no signal
|
|
@@ -80,7 +113,6 @@ export const buildAnchorSourceText = (
|
|
|
80
113
|
|
|
81
114
|
export interface AnchorBuildDeps {
|
|
82
115
|
store: Pick<VectorStoreService, "getByMessage">;
|
|
83
|
-
embedder: Pick<EmbeddingService, "embeddingId">;
|
|
84
116
|
}
|
|
85
117
|
|
|
86
118
|
export interface AnchorBuildParams {
|
|
@@ -97,8 +129,13 @@ export interface AnchorBuildParams {
|
|
|
97
129
|
* `FilterAnchor` row (and leave `Filter.hasAnchor` false) rather than persist an
|
|
98
130
|
* empty anchor.
|
|
99
131
|
*
|
|
100
|
-
* `anchorEmbeddingId` is the
|
|
101
|
-
*
|
|
132
|
+
* `anchorEmbeddingId` is derived from the pooled records' own
|
|
133
|
+
* `metadata.embeddingId` (#349) — the model that actually produced those
|
|
134
|
+
* vectors, which is not necessarily the currently-configured embedder. A
|
|
135
|
+
* message indexed under an older model keeps its old-space vectors until
|
|
136
|
+
* something re-indexes it; stamping the current embedder's id there would
|
|
137
|
+
* mislabel a stale vector as current and permanently defeat any drift check
|
|
138
|
+
* that trusts the stamp.
|
|
102
139
|
*/
|
|
103
140
|
export const buildMessageAnchor = async (
|
|
104
141
|
deps: AnchorBuildDeps,
|
|
@@ -116,7 +153,7 @@ export const buildMessageAnchor = async (
|
|
|
116
153
|
|
|
117
154
|
return {
|
|
118
155
|
anchorEmbedding: poolChunkVectors(records.map((r) => r.vector)),
|
|
119
|
-
anchorEmbeddingId:
|
|
156
|
+
anchorEmbeddingId: deriveAnchorEmbeddingId(records),
|
|
120
157
|
anchorSourceText: buildAnchorSourceText(
|
|
121
158
|
records.map((r) => ({
|
|
122
159
|
chunkType: r.metadata.chunkType,
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
* Gated behind RUN_INTEG_TESTS. Point PG_CONNECTION_URL at a database whose
|
|
9
9
|
* `vector` extension is enabled (default: local remit_test).
|
|
10
10
|
*
|
|
11
|
-
* npm run test:integ -w packages/search-service
|
|
11
|
+
* npm run test:integ:pg -w packages/search-service
|
|
12
12
|
*/
|
|
13
13
|
import assert from "node:assert";
|
|
14
14
|
import { randomUUID } from "node:crypto";
|
|
@@ -247,6 +247,58 @@ describe("S3VectorsBackend.getByMessage (GetVectors by deterministic key, no sca
|
|
|
247
247
|
);
|
|
248
248
|
});
|
|
249
249
|
|
|
250
|
+
it("round-trips a chunk's embeddingId (#349) so a pooling anchor can recover which model produced it", async () => {
|
|
251
|
+
const subjectMetadata = {
|
|
252
|
+
messageId: MESSAGE_ID,
|
|
253
|
+
threadId: "thread-1",
|
|
254
|
+
accountConfigId: "acct-1",
|
|
255
|
+
mailboxIds: ["mb-inbox"],
|
|
256
|
+
chunkType: "subject",
|
|
257
|
+
sentDate: 1_700_000_000,
|
|
258
|
+
isRead: false,
|
|
259
|
+
hasAttachment: false,
|
|
260
|
+
hasStars: false,
|
|
261
|
+
embeddingId: "amazon.titan-embed-text-v1@1024",
|
|
262
|
+
};
|
|
263
|
+
const bodyMetadata = {
|
|
264
|
+
messageId: MESSAGE_ID,
|
|
265
|
+
threadId: "thread-1",
|
|
266
|
+
accountConfigId: "acct-1",
|
|
267
|
+
mailboxIds: ["mb-inbox"],
|
|
268
|
+
chunkType: "body",
|
|
269
|
+
sentDate: 1_700_000_000,
|
|
270
|
+
isRead: false,
|
|
271
|
+
hasAttachment: false,
|
|
272
|
+
hasStars: false,
|
|
273
|
+
};
|
|
274
|
+
s3vMock.on(GetVectorsCommand).resolves({
|
|
275
|
+
vectors: [
|
|
276
|
+
{
|
|
277
|
+
key: `${MESSAGE_ID}::subject`,
|
|
278
|
+
data: { float32: [1, 0, 0] },
|
|
279
|
+
metadata: subjectMetadata,
|
|
280
|
+
},
|
|
281
|
+
{
|
|
282
|
+
key: `${MESSAGE_ID}::body-0`,
|
|
283
|
+
data: { float32: [0, 1, 0] },
|
|
284
|
+
metadata: bodyMetadata,
|
|
285
|
+
},
|
|
286
|
+
],
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
const records = await buildBackend().getByMessage(MESSAGE_ID);
|
|
290
|
+
const byId = new Map(records.map((r) => [r.chunkId, r]));
|
|
291
|
+
|
|
292
|
+
assert.equal(
|
|
293
|
+
byId.get(`${MESSAGE_ID}::subject`)?.metadata.embeddingId,
|
|
294
|
+
"amazon.titan-embed-text-v1@1024",
|
|
295
|
+
);
|
|
296
|
+
assert.equal(
|
|
297
|
+
byId.get(`${MESSAGE_ID}::body-0`)?.metadata.embeddingId,
|
|
298
|
+
undefined,
|
|
299
|
+
);
|
|
300
|
+
});
|
|
301
|
+
|
|
250
302
|
it("requests vector data and metadata, addressing vectors by candidate key", async () => {
|
|
251
303
|
s3vMock.on(GetVectorsCommand).resolves({ vectors: [] });
|
|
252
304
|
|
|
@@ -165,6 +165,12 @@ const toMetadata = (raw: unknown): ChunkMetadata => {
|
|
|
165
165
|
// treat as absent (score-neutral re-rank), same as the other display fields.
|
|
166
166
|
const textPreview =
|
|
167
167
|
typeof obj.textPreview === "string" ? obj.textPreview : undefined;
|
|
168
|
+
// embeddingId is absent on vectors written before this provenance field
|
|
169
|
+
// existed (#349); treat as absent so a pooling consumer (buildMessageAnchor)
|
|
170
|
+
// can tell "unknown model" apart from a real id, rather than losing the real
|
|
171
|
+
// id this backend actually stored.
|
|
172
|
+
const embeddingId =
|
|
173
|
+
typeof obj.embeddingId === "string" ? obj.embeddingId : undefined;
|
|
168
174
|
return {
|
|
169
175
|
messageId: obj.messageId,
|
|
170
176
|
threadId: obj.threadId,
|
|
@@ -180,6 +186,7 @@ const toMetadata = (raw: unknown): ChunkMetadata => {
|
|
|
180
186
|
...(subject !== undefined ? { subject } : {}),
|
|
181
187
|
...(category !== undefined ? { category } : {}),
|
|
182
188
|
...(textPreview !== undefined ? { textPreview } : {}),
|
|
189
|
+
...(embeddingId !== undefined ? { embeddingId } : {}),
|
|
183
190
|
};
|
|
184
191
|
};
|
|
185
192
|
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
* Gated behind RUN_INTEG_TESTS because it loads the native better-sqlite3 and
|
|
8
8
|
* sqlite-vec binaries, matching the pgvector integration suite.
|
|
9
9
|
*
|
|
10
|
-
* npm run test:integ -w packages/search-service
|
|
10
|
+
* npm run test:integ:local -w packages/search-service
|
|
11
11
|
*/
|
|
12
12
|
import assert from "node:assert";
|
|
13
13
|
import { after, before, describe, test } from "node:test";
|
package/src/search.test.ts
CHANGED
|
@@ -459,6 +459,37 @@ describe("DefaultSearchService idempotent indexing", () => {
|
|
|
459
459
|
});
|
|
460
460
|
});
|
|
461
461
|
|
|
462
|
+
describe("chunk embeddingId provenance (#349)", () => {
|
|
463
|
+
it("prepareVectors stamps every chunk with the embedder that produced its vector", async () => {
|
|
464
|
+
const embedder = createDeterministicEmbeddingService({ dimensions: 128 });
|
|
465
|
+
const service = new DefaultSearchService({
|
|
466
|
+
embedder,
|
|
467
|
+
store: new MemoryVectorStore(),
|
|
468
|
+
});
|
|
469
|
+
|
|
470
|
+
const records = await service.prepareVectors(aliceParams(invoiceBody));
|
|
471
|
+
|
|
472
|
+
assert.ok(records.length > 0);
|
|
473
|
+
for (const record of records) {
|
|
474
|
+
assert.equal(record.metadata.embeddingId, embedder.embeddingId);
|
|
475
|
+
}
|
|
476
|
+
});
|
|
477
|
+
|
|
478
|
+
it("indexIncremental stamps every re-embedded chunk with the embedder that produced its vector", async () => {
|
|
479
|
+
const embedder = createDeterministicEmbeddingService({ dimensions: 128 });
|
|
480
|
+
const store = new MemoryVectorStore();
|
|
481
|
+
const service = new DefaultSearchService({ embedder, store });
|
|
482
|
+
|
|
483
|
+
await service.indexIncremental(aliceParams(invoiceBody));
|
|
484
|
+
const stored = await store.getByMessage("msg-alice");
|
|
485
|
+
|
|
486
|
+
assert.ok(stored.length > 0);
|
|
487
|
+
for (const record of stored) {
|
|
488
|
+
assert.equal(record.metadata.embeddingId, embedder.embeddingId);
|
|
489
|
+
}
|
|
490
|
+
});
|
|
491
|
+
});
|
|
492
|
+
|
|
462
493
|
class CountingEmbedder implements EmbeddingService {
|
|
463
494
|
readonly embeddingId: string;
|
|
464
495
|
readonly dimensions: number;
|
package/src/search.ts
CHANGED
|
@@ -240,6 +240,7 @@ export class DefaultSearchService implements SearchService {
|
|
|
240
240
|
...metadata,
|
|
241
241
|
chunkType: chunk.chunkType,
|
|
242
242
|
contentHash: computeContentHash(embeddingId, chunk.text),
|
|
243
|
+
embeddingId,
|
|
243
244
|
textPreview: buildTextPreview(chunk.text),
|
|
244
245
|
...(chunk.chunkType === "attachment" && fileTypes.length > 0
|
|
245
246
|
? { fileTypes }
|
|
@@ -335,6 +336,7 @@ export class DefaultSearchService implements SearchService {
|
|
|
335
336
|
...metadata,
|
|
336
337
|
chunkType: h.chunk.chunkType,
|
|
337
338
|
contentHash: h.contentHash,
|
|
339
|
+
embeddingId,
|
|
338
340
|
textPreview: buildTextPreview(h.chunk.text),
|
|
339
341
|
...(h.chunk.chunkType === "attachment" && fileTypes.length > 0
|
|
340
342
|
? { fileTypes }
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
* Downloads the MiniLM model on first run, so it is gated behind RUN_INTEG_TESTS
|
|
9
9
|
* and excluded from the default unit-test path.
|
|
10
10
|
*
|
|
11
|
-
* npm run test:integ -w packages/search-service
|
|
11
|
+
* npm run test:integ:model -w packages/search-service
|
|
12
12
|
*/
|
|
13
13
|
import assert from "node:assert";
|
|
14
14
|
import { mkdtempSync, rmSync } from "node:fs";
|
package/src/types.ts
CHANGED
|
@@ -48,6 +48,16 @@ export interface ChunkMetadata {
|
|
|
48
48
|
* embedding model changes. Absent on pre-hash vectors (re-PUT once to populate).
|
|
49
49
|
*/
|
|
50
50
|
contentHash?: string;
|
|
51
|
+
/**
|
|
52
|
+
* `<modelId>@<dimensions>` id of the embedder that produced this chunk's
|
|
53
|
+
* vector, stored at index time (see `EmbeddingService.embeddingId`). Lets a
|
|
54
|
+
* consumer that pools chunk vectors — `buildMessageAnchor` — recover which
|
|
55
|
+
* model actually produced them instead of assuming the currently configured
|
|
56
|
+
* embedder. Absent on vectors written before this field existed; re-embedded
|
|
57
|
+
* organically on next touch like `contentHash`/`textPreview` above, no
|
|
58
|
+
* backfill.
|
|
59
|
+
*/
|
|
60
|
+
embeddingId?: string;
|
|
51
61
|
/**
|
|
52
62
|
* Prefix of the chunk's embeddable text, stored at index time and used for the
|
|
53
63
|
* literal-match re-rank in search.ts. Bounded independently by char count
|