@remit/search-service 0.0.1
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 +66 -0
- package/src/anchor.test.ts +164 -0
- package/src/anchor.ts +127 -0
- package/src/backends/bedrock.test.ts +148 -0
- package/src/backends/bedrock.ts +105 -0
- package/src/backends/memory.test.ts +168 -0
- package/src/backends/memory.ts +152 -0
- package/src/backends/pgvector.integ.test.ts +174 -0
- package/src/backends/pgvector.ts +306 -0
- package/src/backends/runtime-import.ts +16 -0
- package/src/backends/s3-vectors.test.ts +929 -0
- package/src/backends/s3-vectors.ts +383 -0
- package/src/backends/sqlite-vec.integ.test.ts +144 -0
- package/src/backends/sqlite-vec.ts +250 -0
- package/src/bedrock.ts +4 -0
- package/src/chunking/chunker.test.ts +79 -0
- package/src/chunking/chunker.ts +56 -0
- package/src/chunking/entities.test.ts +82 -0
- package/src/chunking/entities.ts +74 -0
- package/src/chunking/entropy.test.ts +98 -0
- package/src/chunking/entropy.ts +161 -0
- package/src/chunking/keys.ts +22 -0
- package/src/chunking/structured.test.ts +120 -0
- package/src/chunking/structured.ts +79 -0
- package/src/content-hash.test.ts +27 -0
- package/src/content-hash.ts +10 -0
- package/src/embeddings.test.ts +28 -0
- package/src/embeddings.ts +149 -0
- package/src/from-env.test.ts +62 -0
- package/src/from-env.ts +130 -0
- package/src/index.ts +71 -0
- package/src/pgvector.ts +4 -0
- package/src/s3-vectors.ts +5 -0
- package/src/search.test.ts +772 -0
- package/src/search.ts +395 -0
- package/src/semantic-search.integ.test.ts +130 -0
- package/src/sqlite-vec.ts +4 -0
- package/src/types.ts +155 -0
- package/tsconfig.json +8 -0
package/package.json
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@remit/search-service",
|
|
3
|
+
"version": "0.0.1",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"main": "src/index.ts",
|
|
6
|
+
"types": "src/index.ts",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": {
|
|
9
|
+
"types": "./src/index.ts",
|
|
10
|
+
"default": "./src/index.ts"
|
|
11
|
+
},
|
|
12
|
+
"./from-env": {
|
|
13
|
+
"types": "./src/from-env.ts",
|
|
14
|
+
"default": "./src/from-env.ts"
|
|
15
|
+
},
|
|
16
|
+
"./s3-vectors": {
|
|
17
|
+
"types": "./src/s3-vectors.ts",
|
|
18
|
+
"default": "./src/s3-vectors.ts"
|
|
19
|
+
},
|
|
20
|
+
"./bedrock": {
|
|
21
|
+
"types": "./src/bedrock.ts",
|
|
22
|
+
"default": "./src/bedrock.ts"
|
|
23
|
+
},
|
|
24
|
+
"./sqlite-vec": {
|
|
25
|
+
"types": "./src/sqlite-vec.ts",
|
|
26
|
+
"default": "./src/sqlite-vec.ts"
|
|
27
|
+
},
|
|
28
|
+
"./pgvector": {
|
|
29
|
+
"types": "./src/pgvector.ts",
|
|
30
|
+
"default": "./src/pgvector.ts"
|
|
31
|
+
}
|
|
32
|
+
},
|
|
33
|
+
"scripts": {
|
|
34
|
+
"test:typecheck": "tsgo --noEmit",
|
|
35
|
+
"test:run": "node --env-file=../../localhost-test-unit.env --import tsx --test 'src/**/*.test.ts'",
|
|
36
|
+
"test:integ": "RUN_INTEG_TESTS=1 node --env-file=../../localhost-test-unit.env --import tsx --test 'src/**/*.integ.test.ts'",
|
|
37
|
+
"test": "npm run test:typecheck && npm run test:run"
|
|
38
|
+
},
|
|
39
|
+
"dependencies": {
|
|
40
|
+
"@huggingface/transformers": "^4.2.0",
|
|
41
|
+
"@remit/domain-enums": "*",
|
|
42
|
+
"@remit/api-openapi-types": "*",
|
|
43
|
+
"better-sqlite3": "^12.11.1",
|
|
44
|
+
"p-limit": "^6.2.0",
|
|
45
|
+
"pg": "^8.0.0",
|
|
46
|
+
"sqlite-vec": "^0.1.9",
|
|
47
|
+
"zod": "*"
|
|
48
|
+
},
|
|
49
|
+
"devDependencies": {
|
|
50
|
+
"@aws-sdk/client-bedrock-runtime": "*",
|
|
51
|
+
"@aws-sdk/client-s3vectors": "*",
|
|
52
|
+
"@remit/storage-service": "*",
|
|
53
|
+
"@types/better-sqlite3": "^7.6.13",
|
|
54
|
+
"@types/pg": "^8.20.0",
|
|
55
|
+
"aws-sdk-client-mock": "*"
|
|
56
|
+
},
|
|
57
|
+
"license": "MIT",
|
|
58
|
+
"publishConfig": {
|
|
59
|
+
"access": "public"
|
|
60
|
+
},
|
|
61
|
+
"repository": {
|
|
62
|
+
"type": "git",
|
|
63
|
+
"url": "git+https://github.com/remit-mail/remit.git",
|
|
64
|
+
"directory": "packages/search-service"
|
|
65
|
+
}
|
|
66
|
+
}
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { describe, it } from "node:test";
|
|
3
|
+
import {
|
|
4
|
+
buildAnchorSourceText,
|
|
5
|
+
buildMessageAnchor,
|
|
6
|
+
poolChunkVectors,
|
|
7
|
+
} from "./anchor.js";
|
|
8
|
+
import { createMemoryVectorStore } from "./backends/memory.js";
|
|
9
|
+
import { createDeterministicEmbeddingService } from "./embeddings.js";
|
|
10
|
+
import type { ChunkMetadata, VectorRecord } from "./types.js";
|
|
11
|
+
|
|
12
|
+
const l2Norm = (vector: number[]): number =>
|
|
13
|
+
Math.sqrt(vector.reduce((sum, value) => sum + value * value, 0));
|
|
14
|
+
|
|
15
|
+
const baseMetadata = (
|
|
16
|
+
overrides: Partial<ChunkMetadata> = {},
|
|
17
|
+
): ChunkMetadata => ({
|
|
18
|
+
messageId: "msg-1",
|
|
19
|
+
threadId: "thread-1",
|
|
20
|
+
accountConfigId: "acct-1",
|
|
21
|
+
mailboxIds: ["mbox-1"],
|
|
22
|
+
chunkType: "body",
|
|
23
|
+
sentDate: 1_700_000_000,
|
|
24
|
+
isRead: false,
|
|
25
|
+
hasAttachment: false,
|
|
26
|
+
hasStars: false,
|
|
27
|
+
...overrides,
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
describe("poolChunkVectors", () => {
|
|
31
|
+
it("mean-pools chunk vectors and L2-normalizes the result", () => {
|
|
32
|
+
const pooled = poolChunkVectors([
|
|
33
|
+
[2, 0, 0],
|
|
34
|
+
[0, 2, 0],
|
|
35
|
+
]);
|
|
36
|
+
// Mean is [1, 1, 0]; normalized to unit length.
|
|
37
|
+
assert.ok(Math.abs(l2Norm(pooled) - 1) < 1e-9);
|
|
38
|
+
assert.ok(Math.abs(pooled[0] - pooled[1]) < 1e-9);
|
|
39
|
+
assert.ok(Math.abs(pooled[2]) < 1e-9);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it("returns the single vector's direction when pooling one chunk", () => {
|
|
43
|
+
const pooled = poolChunkVectors([[3, 4]]);
|
|
44
|
+
assert.ok(Math.abs(pooled[0] - 0.6) < 1e-9);
|
|
45
|
+
assert.ok(Math.abs(pooled[1] - 0.8) < 1e-9);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it("returns a zero vector unchanged rather than dividing by a zero norm", () => {
|
|
49
|
+
assert.deepEqual(poolChunkVectors([[0, 0, 0]]), [0, 0, 0]);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it("throws on an empty set", () => {
|
|
53
|
+
assert.throws(() => poolChunkVectors([]), /empty set/);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it("throws on a dimension mismatch", () => {
|
|
57
|
+
assert.throws(
|
|
58
|
+
() =>
|
|
59
|
+
poolChunkVectors([
|
|
60
|
+
[1, 2, 3],
|
|
61
|
+
[1, 2],
|
|
62
|
+
]),
|
|
63
|
+
/dimension mismatch/,
|
|
64
|
+
);
|
|
65
|
+
});
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
describe("buildAnchorSourceText", () => {
|
|
69
|
+
it("prefers subject then body previews", () => {
|
|
70
|
+
const text = buildAnchorSourceText([
|
|
71
|
+
{ chunkType: "body", textPreview: "the body" },
|
|
72
|
+
{ chunkType: "subject", textPreview: "the subject" },
|
|
73
|
+
{ chunkType: "sender", textPreview: "someone@example.com" },
|
|
74
|
+
]);
|
|
75
|
+
assert.equal(text, "the subject\nthe body");
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it("falls back to every preview when there is no subject or body", () => {
|
|
79
|
+
const text = buildAnchorSourceText([
|
|
80
|
+
{ chunkType: "sender", textPreview: "someone@example.com" },
|
|
81
|
+
{ chunkType: "entities", textPreview: "ACME Corp" },
|
|
82
|
+
]);
|
|
83
|
+
assert.equal(text, "someone@example.com\nACME Corp");
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it("never exceeds the 512-char preview bound", () => {
|
|
87
|
+
const long = "x".repeat(5000);
|
|
88
|
+
const text = buildAnchorSourceText([
|
|
89
|
+
{ chunkType: "body", textPreview: long },
|
|
90
|
+
]);
|
|
91
|
+
assert.ok(text.length <= 512);
|
|
92
|
+
});
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
describe("buildMessageAnchor", () => {
|
|
96
|
+
const embedder = createDeterministicEmbeddingService({ dimensions: 8 });
|
|
97
|
+
|
|
98
|
+
const putRecord = async (
|
|
99
|
+
store: ReturnType<typeof createMemoryVectorStore>,
|
|
100
|
+
record: VectorRecord,
|
|
101
|
+
): Promise<void> => store.upsert([record]);
|
|
102
|
+
|
|
103
|
+
it("pools a message's chunk vectors and derives the anchor payload", async () => {
|
|
104
|
+
const store = createMemoryVectorStore();
|
|
105
|
+
await putRecord(store, {
|
|
106
|
+
chunkId: "msg-1::subject",
|
|
107
|
+
vector: [1, 0, 0, 0, 0, 0, 0, 0],
|
|
108
|
+
metadata: baseMetadata({
|
|
109
|
+
chunkType: "subject",
|
|
110
|
+
textPreview: "booking confirmed",
|
|
111
|
+
}),
|
|
112
|
+
});
|
|
113
|
+
await putRecord(store, {
|
|
114
|
+
chunkId: "msg-1::body-0",
|
|
115
|
+
vector: [0, 1, 0, 0, 0, 0, 0, 0],
|
|
116
|
+
metadata: baseMetadata({
|
|
117
|
+
chunkType: "body",
|
|
118
|
+
textPreview: "your trip is booked",
|
|
119
|
+
}),
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
const anchor = await buildMessageAnchor(
|
|
123
|
+
{ store, embedder },
|
|
124
|
+
{ accountConfigId: "acct-1", anchorMessageId: "msg-1" },
|
|
125
|
+
);
|
|
126
|
+
|
|
127
|
+
assert.ok(anchor);
|
|
128
|
+
assert.equal(anchor.anchorEmbeddingId, embedder.embeddingId);
|
|
129
|
+
assert.equal(anchor.anchorEmbedding.length, 8);
|
|
130
|
+
assert.ok(Math.abs(l2Norm(anchor.anchorEmbedding) - 1) < 1e-9);
|
|
131
|
+
assert.equal(
|
|
132
|
+
anchor.anchorSourceText,
|
|
133
|
+
"booking confirmed\nyour trip is booked",
|
|
134
|
+
);
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it("returns null when the message has no indexed chunks", async () => {
|
|
138
|
+
const store = createMemoryVectorStore();
|
|
139
|
+
const anchor = await buildMessageAnchor(
|
|
140
|
+
{ store, embedder },
|
|
141
|
+
{ accountConfigId: "acct-1", anchorMessageId: "absent" },
|
|
142
|
+
);
|
|
143
|
+
assert.equal(anchor, null);
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
it("ignores chunks belonging to another account", async () => {
|
|
147
|
+
const store = createMemoryVectorStore();
|
|
148
|
+
await putRecord(store, {
|
|
149
|
+
chunkId: "msg-1::subject",
|
|
150
|
+
vector: [1, 0, 0, 0, 0, 0, 0, 0],
|
|
151
|
+
metadata: baseMetadata({
|
|
152
|
+
chunkType: "subject",
|
|
153
|
+
accountConfigId: "other-acct",
|
|
154
|
+
textPreview: "not mine",
|
|
155
|
+
}),
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
const anchor = await buildMessageAnchor(
|
|
159
|
+
{ store, embedder },
|
|
160
|
+
{ accountConfigId: "acct-1", anchorMessageId: "msg-1" },
|
|
161
|
+
);
|
|
162
|
+
assert.equal(anchor, null);
|
|
163
|
+
});
|
|
164
|
+
});
|
package/src/anchor.ts
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import type { VectorStoreService } from "./backends/memory.js";
|
|
2
|
+
import type { EmbeddingService } from "./embeddings.js";
|
|
3
|
+
import { buildTextPreview } from "./search.js";
|
|
4
|
+
import type { ChunkType, VectorRecord } from "./types.js";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The persisted anchor of a semantic filter (RFC 034 Decision 2.1). A single
|
|
8
|
+
* mean-pooled snapshot of the anchor message's chunk vectors, plus the bounded
|
|
9
|
+
* source text needed to re-embed it after a model migration (Decision 2.4).
|
|
10
|
+
* Written once onto the sibling `FilterAnchor` row at filter-save time; never
|
|
11
|
+
* re-derived per match.
|
|
12
|
+
*/
|
|
13
|
+
export interface AnchorPayload {
|
|
14
|
+
anchorEmbedding: number[];
|
|
15
|
+
anchorEmbeddingId: string;
|
|
16
|
+
anchorSourceText: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Mean-pool chunk vectors into one, then L2-normalize so the anchor is unit
|
|
21
|
+
* length like the per-chunk vectors it summarizes (cosine is scale-invariant,
|
|
22
|
+
* but a normalized anchor keeps the stored vector consistent with the chunk
|
|
23
|
+
* vectors it was pooled from). Throws on an empty set or a dimension mismatch —
|
|
24
|
+
* both are programmer errors the caller cannot recover from (let it crash).
|
|
25
|
+
*/
|
|
26
|
+
export const poolChunkVectors = (vectors: number[][]): number[] => {
|
|
27
|
+
if (vectors.length === 0) {
|
|
28
|
+
throw new Error("Cannot pool an empty set of chunk vectors");
|
|
29
|
+
}
|
|
30
|
+
const dimensions = vectors[0].length;
|
|
31
|
+
const sum = new Array<number>(dimensions).fill(0);
|
|
32
|
+
for (const vector of vectors) {
|
|
33
|
+
if (vector.length !== dimensions) {
|
|
34
|
+
throw new Error(
|
|
35
|
+
`Chunk vector dimension mismatch: ${vector.length} vs ${dimensions}`,
|
|
36
|
+
);
|
|
37
|
+
}
|
|
38
|
+
for (let i = 0; i < dimensions; i++) sum[i] += vector[i];
|
|
39
|
+
}
|
|
40
|
+
for (let i = 0; i < dimensions; i++) sum[i] /= vectors.length;
|
|
41
|
+
|
|
42
|
+
let norm = 0;
|
|
43
|
+
for (const value of sum) norm += value * value;
|
|
44
|
+
norm = Math.sqrt(norm);
|
|
45
|
+
if (norm === 0) return sum;
|
|
46
|
+
return sum.map((value) => value / norm);
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
// The chunk types whose text carries the semantic meaning of "messages like
|
|
50
|
+
// this" — subject and body. Structured chunks (sender, recipient, attachment,
|
|
51
|
+
// entities) are excluded from the re-embeddable source text; they add no signal
|
|
52
|
+
// a user's plain-sentence anchor is about.
|
|
53
|
+
const SOURCE_TEXT_CHUNK_TYPES: readonly ChunkType[] = ["subject", "body"];
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Assemble the anchor's re-embeddable source text (RFC 034 Decision 2.4) from
|
|
57
|
+
* the message's chunk previews, preferring subject then body. Falls back to
|
|
58
|
+
* every available preview when the message has neither. Bounded by the same
|
|
59
|
+
* `buildTextPreview` char/byte budget the chunk vectors already pay, so it never
|
|
60
|
+
* exceeds the 512-char (and S3 Vectors byte) cap.
|
|
61
|
+
*/
|
|
62
|
+
export const buildAnchorSourceText = (
|
|
63
|
+
chunks: Array<{ chunkType: ChunkType; textPreview?: string }>,
|
|
64
|
+
): string => {
|
|
65
|
+
const previews: string[] = [];
|
|
66
|
+
for (const type of SOURCE_TEXT_CHUNK_TYPES) {
|
|
67
|
+
for (const chunk of chunks) {
|
|
68
|
+
if (chunk.chunkType === type && chunk.textPreview) {
|
|
69
|
+
previews.push(chunk.textPreview);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
if (previews.length === 0) {
|
|
74
|
+
for (const chunk of chunks) {
|
|
75
|
+
if (chunk.textPreview) previews.push(chunk.textPreview);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
return buildTextPreview(previews.join("\n"));
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
export interface AnchorBuildDeps {
|
|
82
|
+
store: Pick<VectorStoreService, "getByMessage">;
|
|
83
|
+
embedder: Pick<EmbeddingService, "embeddingId">;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export interface AnchorBuildParams {
|
|
87
|
+
accountConfigId: string;
|
|
88
|
+
anchorMessageId: string;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Build a filter's persisted anchor from the anchor message's already-indexed
|
|
93
|
+
* chunk vectors (RFC 034 Decision 2.1). Reads the message's chunk vectors,
|
|
94
|
+
* pools them into one, and derives the bounded source text — never embeds
|
|
95
|
+
* anything new here; the vectors already exist from index time. Returns `null`
|
|
96
|
+
* when the message has no indexed chunks, so the caller can decline to write a
|
|
97
|
+
* `FilterAnchor` row (and leave `Filter.hasAnchor` false) rather than persist an
|
|
98
|
+
* empty anchor.
|
|
99
|
+
*
|
|
100
|
+
* `anchorEmbeddingId` is the current embedder's identifier — the model the
|
|
101
|
+
* chunk vectors were embedded under, which the indexing pipeline keeps current.
|
|
102
|
+
*/
|
|
103
|
+
export const buildMessageAnchor = async (
|
|
104
|
+
deps: AnchorBuildDeps,
|
|
105
|
+
params: AnchorBuildParams,
|
|
106
|
+
): Promise<AnchorPayload | null> => {
|
|
107
|
+
const records = (await deps.store.getByMessage(params.anchorMessageId))
|
|
108
|
+
.filter(
|
|
109
|
+
(record: VectorRecord) =>
|
|
110
|
+
record.metadata.accountConfigId === params.accountConfigId,
|
|
111
|
+
)
|
|
112
|
+
.sort((a: VectorRecord, b: VectorRecord) =>
|
|
113
|
+
a.chunkId.localeCompare(b.chunkId),
|
|
114
|
+
);
|
|
115
|
+
if (records.length === 0) return null;
|
|
116
|
+
|
|
117
|
+
return {
|
|
118
|
+
anchorEmbedding: poolChunkVectors(records.map((r) => r.vector)),
|
|
119
|
+
anchorEmbeddingId: deps.embedder.embeddingId,
|
|
120
|
+
anchorSourceText: buildAnchorSourceText(
|
|
121
|
+
records.map((r) => ({
|
|
122
|
+
chunkType: r.metadata.chunkType,
|
|
123
|
+
textPreview: r.metadata.textPreview,
|
|
124
|
+
})),
|
|
125
|
+
),
|
|
126
|
+
};
|
|
127
|
+
};
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { describe, it } from "node:test";
|
|
3
|
+
import {
|
|
4
|
+
BedrockRuntimeClient,
|
|
5
|
+
InvokeModelCommand,
|
|
6
|
+
} from "@aws-sdk/client-bedrock-runtime";
|
|
7
|
+
import { mockClient } from "aws-sdk-client-mock";
|
|
8
|
+
import { BedrockEmbeddingService } from "./bedrock.js";
|
|
9
|
+
|
|
10
|
+
const encodeTitanResponse = (dimensions: number): Uint8Array =>
|
|
11
|
+
new TextEncoder().encode(
|
|
12
|
+
JSON.stringify({ embedding: new Array(dimensions).fill(0) }),
|
|
13
|
+
);
|
|
14
|
+
|
|
15
|
+
describe("BedrockEmbeddingService", () => {
|
|
16
|
+
it("caps in-flight InvokeModel calls to the configured concurrency", async () => {
|
|
17
|
+
const concurrency = 6;
|
|
18
|
+
const total = 20;
|
|
19
|
+
let inFlight = 0;
|
|
20
|
+
let peak = 0;
|
|
21
|
+
|
|
22
|
+
const bedrockMock = mockClient(BedrockRuntimeClient);
|
|
23
|
+
bedrockMock.on(InvokeModelCommand).callsFake(async () => {
|
|
24
|
+
inFlight += 1;
|
|
25
|
+
peak = Math.max(peak, inFlight);
|
|
26
|
+
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
27
|
+
inFlight -= 1;
|
|
28
|
+
return { body: encodeTitanResponse(1024) };
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
const service = new BedrockEmbeddingService({
|
|
32
|
+
client: new BedrockRuntimeClient({}),
|
|
33
|
+
concurrency,
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
const texts = Array.from({ length: total }, (_, i) => `text-${i}`);
|
|
37
|
+
const results = await service.embed(texts);
|
|
38
|
+
|
|
39
|
+
assert.equal(results.length, total);
|
|
40
|
+
assert.ok(
|
|
41
|
+
peak <= concurrency,
|
|
42
|
+
`peak in-flight ${peak} exceeded concurrency ${concurrency}`,
|
|
43
|
+
);
|
|
44
|
+
assert.equal(bedrockMock.commandCalls(InvokeModelCommand).length, total);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it("truncates over-budget input before sending it to Bedrock", async () => {
|
|
48
|
+
const bedrockMock = mockClient(BedrockRuntimeClient);
|
|
49
|
+
let sentLength = 0;
|
|
50
|
+
bedrockMock.on(InvokeModelCommand).callsFake((input) => {
|
|
51
|
+
const body = JSON.parse(input.body as string) as { inputText: string };
|
|
52
|
+
sentLength = body.inputText.length;
|
|
53
|
+
return { body: encodeTitanResponse(1024) };
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
const service = new BedrockEmbeddingService({
|
|
57
|
+
client: new BedrockRuntimeClient({}),
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
await service.embed(["x".repeat(50000)]);
|
|
61
|
+
|
|
62
|
+
assert.ok(
|
|
63
|
+
sentLength <= 6000,
|
|
64
|
+
`inputText length ${sentLength} exceeds the 6000-char budget`,
|
|
65
|
+
);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it("shrinks and retries when Bedrock rejects a dense input as over-budget", async () => {
|
|
69
|
+
// A 6000-char chunk of dense/non-Latin text can still exceed Titan's
|
|
70
|
+
// 8192-token limit even though it's within the char budget. Rather than
|
|
71
|
+
// dead-letter the message forever, the embedder halves the input and
|
|
72
|
+
// retries until Bedrock accepts it (#910).
|
|
73
|
+
const bedrockMock = mockClient(BedrockRuntimeClient);
|
|
74
|
+
const sentLengths: number[] = [];
|
|
75
|
+
bedrockMock.on(InvokeModelCommand).callsFake((input) => {
|
|
76
|
+
const body = JSON.parse(input.body as string) as { inputText: string };
|
|
77
|
+
sentLengths.push(body.inputText.length);
|
|
78
|
+
if (body.inputText.length > 3000) {
|
|
79
|
+
throw new Error(
|
|
80
|
+
"ValidationException: 400 Bad Request: Too many input tokens. Max input tokens: 8192, request input token count: 14022",
|
|
81
|
+
);
|
|
82
|
+
}
|
|
83
|
+
return { body: encodeTitanResponse(1024) };
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
const service = new BedrockEmbeddingService({
|
|
87
|
+
client: new BedrockRuntimeClient({}),
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
const [vector] = await service.embed(["字".repeat(50000)]);
|
|
91
|
+
|
|
92
|
+
assert.equal(vector.length, 1024);
|
|
93
|
+
assert.equal(sentLengths[0], 6000, "first attempt uses the full budget");
|
|
94
|
+
assert.ok(
|
|
95
|
+
// biome-ignore lint/style/noNonNullAssertion: test assertion, value is guaranteed by test setup
|
|
96
|
+
sentLengths.at(-1) !== undefined && sentLengths.at(-1)! <= 3000,
|
|
97
|
+
"a later attempt shrinks under the token limit",
|
|
98
|
+
);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it("surfaces a non-token error without shrinking and retrying", async () => {
|
|
102
|
+
const bedrockMock = mockClient(BedrockRuntimeClient);
|
|
103
|
+
let calls = 0;
|
|
104
|
+
bedrockMock.on(InvokeModelCommand).callsFake(() => {
|
|
105
|
+
calls += 1;
|
|
106
|
+
throw new Error("AccessDeniedException: not authorized");
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
const service = new BedrockEmbeddingService({
|
|
110
|
+
client: new BedrockRuntimeClient({}),
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
await assert.rejects(
|
|
114
|
+
service.embed(["hello"]),
|
|
115
|
+
/AccessDeniedException/,
|
|
116
|
+
"non-token errors must propagate",
|
|
117
|
+
);
|
|
118
|
+
assert.equal(calls, 1, "no shrink-retry for non-token errors");
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it("returns embeddings in the same order as the input texts", async () => {
|
|
122
|
+
const bedrockMock = mockClient(BedrockRuntimeClient);
|
|
123
|
+
bedrockMock.on(InvokeModelCommand).callsFake(async (input) => {
|
|
124
|
+
const body = JSON.parse(input.body as string) as { inputText: string };
|
|
125
|
+
const tag = Number.parseInt(body.inputText.split("-")[1] ?? "0", 10);
|
|
126
|
+
await new Promise((resolve) => setTimeout(resolve, (5 - (tag % 5)) * 2));
|
|
127
|
+
return {
|
|
128
|
+
body: new TextEncoder().encode(
|
|
129
|
+
JSON.stringify({ embedding: [tag, 0, 0, 0] }),
|
|
130
|
+
),
|
|
131
|
+
};
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
const service = new BedrockEmbeddingService({
|
|
135
|
+
client: new BedrockRuntimeClient({}),
|
|
136
|
+
dimensions: 4,
|
|
137
|
+
concurrency: 3,
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
const texts = ["text-0", "text-1", "text-2", "text-3", "text-4"];
|
|
141
|
+
const results = await service.embed(texts);
|
|
142
|
+
|
|
143
|
+
assert.deepEqual(
|
|
144
|
+
results.map((v) => v[0]),
|
|
145
|
+
[0, 1, 2, 3, 4],
|
|
146
|
+
);
|
|
147
|
+
});
|
|
148
|
+
});
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import {
|
|
2
|
+
BedrockRuntimeClient,
|
|
3
|
+
InvokeModelCommand,
|
|
4
|
+
} from "@aws-sdk/client-bedrock-runtime";
|
|
5
|
+
import pLimit from "p-limit";
|
|
6
|
+
import type { EmbeddingService } from "../embeddings.js";
|
|
7
|
+
|
|
8
|
+
export interface BedrockEmbeddingConfig {
|
|
9
|
+
client?: BedrockRuntimeClient;
|
|
10
|
+
region?: string;
|
|
11
|
+
modelId?: string;
|
|
12
|
+
dimensions?: number;
|
|
13
|
+
concurrency?: number;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const DEFAULT_MODEL_ID = "amazon.titan-embed-text-v2:0";
|
|
17
|
+
const DEFAULT_DIMENSIONS = 1024;
|
|
18
|
+
const DEFAULT_CONCURRENCY = 6;
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Titan v2 rejects inputs over its 8192-token limit. Chunk builders already cap
|
|
22
|
+
* their output, but this is a final backstop so no chunk source can ever push
|
|
23
|
+
* an over-budget inputText to Bedrock. 6000 chars is safe for typical Latin text
|
|
24
|
+
* (~4 chars/token), but dense or non-Latin scripts (CJK, packed entity lists)
|
|
25
|
+
* can exceed 8192 tokens at far fewer chars — observed up to ~21k tokens from a
|
|
26
|
+
* 6000-char chunk. On a token-limit rejection we halve and retry rather than let
|
|
27
|
+
* a valid-but-dense message dead-letter forever (#910).
|
|
28
|
+
*/
|
|
29
|
+
const MAX_INPUT_CHARS = 6000;
|
|
30
|
+
// Below this we stop shrinking and let the error surface: a chunk this small
|
|
31
|
+
// that still overflows is not something a retry can fix.
|
|
32
|
+
const MIN_INPUT_CHARS = 256;
|
|
33
|
+
|
|
34
|
+
const isTokenLimitError = (error: unknown): boolean =>
|
|
35
|
+
error instanceof Error &&
|
|
36
|
+
/too many input tokens|input is too long|maximum.*token/i.test(error.message);
|
|
37
|
+
|
|
38
|
+
const isNumberArray = (value: unknown): value is number[] =>
|
|
39
|
+
Array.isArray(value) && value.every((n) => typeof n === "number");
|
|
40
|
+
|
|
41
|
+
const parseTitanResponse = (raw: Uint8Array): number[] => {
|
|
42
|
+
const text = new TextDecoder().decode(raw);
|
|
43
|
+
const parsed: unknown = JSON.parse(text);
|
|
44
|
+
if (
|
|
45
|
+
typeof parsed === "object" &&
|
|
46
|
+
parsed !== null &&
|
|
47
|
+
"embedding" in parsed &&
|
|
48
|
+
isNumberArray((parsed as { embedding: unknown }).embedding)
|
|
49
|
+
) {
|
|
50
|
+
return (parsed as { embedding: number[] }).embedding;
|
|
51
|
+
}
|
|
52
|
+
throw new Error("Bedrock Titan response missing 'embedding' array");
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
export class BedrockEmbeddingService implements EmbeddingService {
|
|
56
|
+
private client: BedrockRuntimeClient;
|
|
57
|
+
private modelId: string;
|
|
58
|
+
readonly dimensions: number;
|
|
59
|
+
readonly embeddingId: string;
|
|
60
|
+
private limit: ReturnType<typeof pLimit>;
|
|
61
|
+
|
|
62
|
+
constructor(config: BedrockEmbeddingConfig = {}) {
|
|
63
|
+
this.client =
|
|
64
|
+
config.client ?? new BedrockRuntimeClient({ region: config.region });
|
|
65
|
+
this.modelId = config.modelId ?? DEFAULT_MODEL_ID;
|
|
66
|
+
this.dimensions = config.dimensions ?? DEFAULT_DIMENSIONS;
|
|
67
|
+
this.embeddingId = `${this.modelId}@${this.dimensions}`;
|
|
68
|
+
this.limit = pLimit(config.concurrency ?? DEFAULT_CONCURRENCY);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
embed = async (texts: string[]): Promise<number[][]> =>
|
|
72
|
+
Promise.all(texts.map((text) => this.limit(() => this.embedOne(text))));
|
|
73
|
+
|
|
74
|
+
private embedOne = async (text: string): Promise<number[]> => {
|
|
75
|
+
let charBudget = MAX_INPUT_CHARS;
|
|
76
|
+
while (true) {
|
|
77
|
+
try {
|
|
78
|
+
return await this.invoke(text.slice(0, charBudget));
|
|
79
|
+
} catch (error) {
|
|
80
|
+
if (!isTokenLimitError(error) || charBudget <= MIN_INPUT_CHARS) {
|
|
81
|
+
throw error;
|
|
82
|
+
}
|
|
83
|
+
charBudget = Math.max(MIN_INPUT_CHARS, Math.floor(charBudget / 2));
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
private invoke = async (inputText: string): Promise<number[]> => {
|
|
89
|
+
const cmd = new InvokeModelCommand({
|
|
90
|
+
modelId: this.modelId,
|
|
91
|
+
contentType: "application/json",
|
|
92
|
+
accept: "application/json",
|
|
93
|
+
body: JSON.stringify({
|
|
94
|
+
inputText,
|
|
95
|
+
dimensions: this.dimensions,
|
|
96
|
+
normalize: true,
|
|
97
|
+
}),
|
|
98
|
+
});
|
|
99
|
+
const response = await this.client.send(cmd);
|
|
100
|
+
if (!response.body) {
|
|
101
|
+
throw new Error("Bedrock InvokeModel returned empty body");
|
|
102
|
+
}
|
|
103
|
+
return parseTitanResponse(response.body);
|
|
104
|
+
};
|
|
105
|
+
}
|