@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
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
import assert from "node:assert";
|
|
2
|
+
import { describe, it } from "node:test";
|
|
3
|
+
import type { ChunkMetadata, VectorRecord } from "../types.js";
|
|
4
|
+
import { MemoryVectorStore } from "./memory.js";
|
|
5
|
+
|
|
6
|
+
const buildMetadata = (
|
|
7
|
+
overrides: Partial<ChunkMetadata> = {},
|
|
8
|
+
): ChunkMetadata => ({
|
|
9
|
+
messageId: "msg-1",
|
|
10
|
+
threadId: "thread-1",
|
|
11
|
+
accountConfigId: "acct-1",
|
|
12
|
+
mailboxIds: ["mb-inbox"],
|
|
13
|
+
chunkType: "body",
|
|
14
|
+
sentDate: 1_700_000_000,
|
|
15
|
+
isRead: false,
|
|
16
|
+
hasAttachment: false,
|
|
17
|
+
hasStars: false,
|
|
18
|
+
...overrides,
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
const record = (
|
|
22
|
+
chunkId: string,
|
|
23
|
+
vector: number[],
|
|
24
|
+
overrides: Partial<ChunkMetadata> = {},
|
|
25
|
+
): VectorRecord => ({
|
|
26
|
+
chunkId,
|
|
27
|
+
vector,
|
|
28
|
+
metadata: buildMetadata({ ...overrides }),
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
describe("MemoryVectorStore", () => {
|
|
32
|
+
it("returns nearest matches in score-descending order", async () => {
|
|
33
|
+
const store = new MemoryVectorStore();
|
|
34
|
+
await store.upsert([
|
|
35
|
+
record("a", [1, 0, 0]),
|
|
36
|
+
record("b", [0, 1, 0]),
|
|
37
|
+
record("c", [0.7, 0.7, 0]),
|
|
38
|
+
]);
|
|
39
|
+
|
|
40
|
+
const matches = await store.query({ vector: [1, 0, 0], topK: 3 });
|
|
41
|
+
assert.strictEqual(matches.length, 3);
|
|
42
|
+
assert.strictEqual(matches[0].chunkId, "a");
|
|
43
|
+
assert.ok(matches[0].score > matches[1].score);
|
|
44
|
+
assert.ok(matches[1].score >= matches[2].score);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it("respects topK", async () => {
|
|
48
|
+
const store = new MemoryVectorStore();
|
|
49
|
+
await store.upsert([
|
|
50
|
+
record("a", [1, 0, 0]),
|
|
51
|
+
record("b", [0, 1, 0]),
|
|
52
|
+
record("c", [0, 0, 1]),
|
|
53
|
+
]);
|
|
54
|
+
|
|
55
|
+
const matches = await store.query({ vector: [1, 0, 0], topK: 1 });
|
|
56
|
+
assert.strictEqual(matches.length, 1);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it("filters by mailboxId", async () => {
|
|
60
|
+
const store = new MemoryVectorStore();
|
|
61
|
+
await store.upsert([
|
|
62
|
+
record("a", [1, 0, 0], { mailboxIds: ["mb-inbox"] }),
|
|
63
|
+
record("b", [1, 0, 0], { mailboxIds: ["mb-archive"] }),
|
|
64
|
+
]);
|
|
65
|
+
|
|
66
|
+
const matches = await store.query({
|
|
67
|
+
vector: [1, 0, 0],
|
|
68
|
+
topK: 5,
|
|
69
|
+
filter: { mailboxId: "mb-archive" },
|
|
70
|
+
});
|
|
71
|
+
assert.strictEqual(matches.length, 1);
|
|
72
|
+
assert.strictEqual(matches[0].chunkId, "b");
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it("filters by accountConfigId", async () => {
|
|
76
|
+
const store = new MemoryVectorStore();
|
|
77
|
+
await store.upsert([
|
|
78
|
+
record("a", [1, 0, 0], { accountConfigId: "acct-1" }),
|
|
79
|
+
record("b", [1, 0, 0], { accountConfigId: "acct-2" }),
|
|
80
|
+
]);
|
|
81
|
+
|
|
82
|
+
const matches = await store.query({
|
|
83
|
+
vector: [1, 0, 0],
|
|
84
|
+
topK: 5,
|
|
85
|
+
filter: { accountConfigId: "acct-1" },
|
|
86
|
+
});
|
|
87
|
+
assert.strictEqual(matches.length, 1);
|
|
88
|
+
assert.strictEqual(matches[0].chunkId, "a");
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it("filters by category", async () => {
|
|
92
|
+
const store = new MemoryVectorStore();
|
|
93
|
+
await store.upsert([
|
|
94
|
+
record("a", [1, 0, 0], { category: "newsletter" }),
|
|
95
|
+
record("b", [1, 0, 0], { category: "personal" }),
|
|
96
|
+
]);
|
|
97
|
+
|
|
98
|
+
const matches = await store.query({
|
|
99
|
+
vector: [1, 0, 0],
|
|
100
|
+
topK: 5,
|
|
101
|
+
filter: { category: "newsletter" },
|
|
102
|
+
});
|
|
103
|
+
assert.strictEqual(matches.length, 1);
|
|
104
|
+
assert.strictEqual(matches[0].chunkId, "a");
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it("filters by sentDateRange", async () => {
|
|
108
|
+
const store = new MemoryVectorStore();
|
|
109
|
+
await store.upsert([
|
|
110
|
+
record("old", [1, 0, 0], { sentDate: 100 }),
|
|
111
|
+
record("mid", [1, 0, 0], { sentDate: 500 }),
|
|
112
|
+
record("new", [1, 0, 0], { sentDate: 1000 }),
|
|
113
|
+
]);
|
|
114
|
+
|
|
115
|
+
const matches = await store.query({
|
|
116
|
+
vector: [1, 0, 0],
|
|
117
|
+
topK: 5,
|
|
118
|
+
filter: { sentDateRange: { from: 200, to: 800 } },
|
|
119
|
+
});
|
|
120
|
+
const ids = matches.map((m) => m.chunkId).sort();
|
|
121
|
+
assert.deepStrictEqual(ids, ["mid"]);
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it("deletes all chunks for a given messageId", async () => {
|
|
125
|
+
const store = new MemoryVectorStore();
|
|
126
|
+
await store.upsert([
|
|
127
|
+
record("a", [1, 0, 0], { messageId: "msg-1" }),
|
|
128
|
+
record("b", [1, 0, 0], { messageId: "msg-1" }),
|
|
129
|
+
record("c", [1, 0, 0], { messageId: "msg-2" }),
|
|
130
|
+
]);
|
|
131
|
+
|
|
132
|
+
await store.delete({ messageId: "msg-1" });
|
|
133
|
+
|
|
134
|
+
const matches = await store.query({ vector: [1, 0, 0], topK: 5 });
|
|
135
|
+
assert.strictEqual(matches.length, 1);
|
|
136
|
+
assert.strictEqual(matches[0].chunkId, "c");
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it("returns all chunk records for a messageId via getByMessage", async () => {
|
|
140
|
+
const store = new MemoryVectorStore();
|
|
141
|
+
await store.upsert([
|
|
142
|
+
record("a", [1, 0, 0], { messageId: "msg-1", chunkType: "subject" }),
|
|
143
|
+
record("b", [0, 1, 0], { messageId: "msg-1", chunkType: "body" }),
|
|
144
|
+
record("c", [0, 0, 1], { messageId: "msg-2" }),
|
|
145
|
+
]);
|
|
146
|
+
|
|
147
|
+
const records = await store.getByMessage("msg-1");
|
|
148
|
+
const ids = records.map((r) => r.chunkId).sort();
|
|
149
|
+
assert.deepStrictEqual(ids, ["a", "b"]);
|
|
150
|
+
const subject = records.find((r) => r.chunkId === "a");
|
|
151
|
+
assert.deepStrictEqual(subject?.vector, [1, 0, 0]);
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
it("returns an empty array from getByMessage for an unknown message", async () => {
|
|
155
|
+
const store = new MemoryVectorStore();
|
|
156
|
+
assert.deepStrictEqual(await store.getByMessage("absent"), []);
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
it("upsert overwrites an existing record by chunkId", async () => {
|
|
160
|
+
const store = new MemoryVectorStore();
|
|
161
|
+
await store.upsert([record("a", [1, 0, 0])]);
|
|
162
|
+
await store.upsert([record("a", [0, 1, 0])]);
|
|
163
|
+
assert.strictEqual(store.size(), 1);
|
|
164
|
+
|
|
165
|
+
const matches = await store.query({ vector: [0, 1, 0], topK: 1 });
|
|
166
|
+
assert.ok(matches[0].score > 0.9);
|
|
167
|
+
});
|
|
168
|
+
});
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
VectorMatch,
|
|
3
|
+
VectorQuery,
|
|
4
|
+
VectorQueryFilter,
|
|
5
|
+
VectorRecord,
|
|
6
|
+
} from "../types.js";
|
|
7
|
+
|
|
8
|
+
export interface VectorStoreService {
|
|
9
|
+
upsert(vectors: VectorRecord[]): Promise<void>;
|
|
10
|
+
query(params: VectorQuery): Promise<VectorMatch[]>;
|
|
11
|
+
delete(filter: { messageId: string }): Promise<void>;
|
|
12
|
+
/**
|
|
13
|
+
* Read the stored content hash for each of the given deterministic chunk keys.
|
|
14
|
+
* Keys with no stored vector, or a vector with no contentHash, are absent from
|
|
15
|
+
* the map. Addresses vectors by key only — never an index-wide scan.
|
|
16
|
+
*/
|
|
17
|
+
existingContentHashes(chunkIds: string[]): Promise<Map<string, string>>;
|
|
18
|
+
/**
|
|
19
|
+
* Read every stored chunk vector (data + metadata) for a message, addressed by
|
|
20
|
+
* the message's deterministic chunk keys — never an index-wide scan. Empty when
|
|
21
|
+
* the message has no indexed chunks. Backs the filter-anchor build (RFC 034
|
|
22
|
+
* Decision 2.1), which pools a message's chunk vectors into a single anchor.
|
|
23
|
+
*/
|
|
24
|
+
getByMessage(messageId: string): Promise<VectorRecord[]>;
|
|
25
|
+
/**
|
|
26
|
+
* Release any held connections (e.g. a pooled database client). Optional — the
|
|
27
|
+
* in-memory and file backends hold nothing; the pgvector backend closes its
|
|
28
|
+
* pool so a short-lived process (a test, a one-shot reindex) can exit cleanly.
|
|
29
|
+
*/
|
|
30
|
+
close?(): Promise<void>;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const cosineSimilarity = (a: number[], b: number[]): number => {
|
|
34
|
+
if (a.length !== b.length) {
|
|
35
|
+
throw new Error(`Vector dimension mismatch: ${a.length} vs ${b.length}`);
|
|
36
|
+
}
|
|
37
|
+
let dot = 0;
|
|
38
|
+
let normA = 0;
|
|
39
|
+
let normB = 0;
|
|
40
|
+
for (let i = 0; i < a.length; i++) {
|
|
41
|
+
dot += a[i] * b[i];
|
|
42
|
+
normA += a[i] * a[i];
|
|
43
|
+
normB += b[i] * b[i];
|
|
44
|
+
}
|
|
45
|
+
if (normA === 0 || normB === 0) return 0;
|
|
46
|
+
return dot / (Math.sqrt(normA) * Math.sqrt(normB));
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
const matchesFilter = (
|
|
50
|
+
record: VectorRecord,
|
|
51
|
+
filter: VectorQueryFilter | undefined,
|
|
52
|
+
): boolean => {
|
|
53
|
+
if (!filter) return true;
|
|
54
|
+
const m = record.metadata;
|
|
55
|
+
if (
|
|
56
|
+
filter.accountConfigId !== undefined &&
|
|
57
|
+
m.accountConfigId !== filter.accountConfigId
|
|
58
|
+
) {
|
|
59
|
+
return false;
|
|
60
|
+
}
|
|
61
|
+
if (
|
|
62
|
+
filter.mailboxId !== undefined &&
|
|
63
|
+
!m.mailboxIds.includes(filter.mailboxId)
|
|
64
|
+
) {
|
|
65
|
+
return false;
|
|
66
|
+
}
|
|
67
|
+
if (filter.chunkType !== undefined && m.chunkType !== filter.chunkType) {
|
|
68
|
+
return false;
|
|
69
|
+
}
|
|
70
|
+
if (filter.category !== undefined && m.category !== filter.category) {
|
|
71
|
+
return false;
|
|
72
|
+
}
|
|
73
|
+
if (
|
|
74
|
+
filter.hasAttachment !== undefined &&
|
|
75
|
+
m.hasAttachment !== filter.hasAttachment
|
|
76
|
+
) {
|
|
77
|
+
return false;
|
|
78
|
+
}
|
|
79
|
+
if (filter.hasStars !== undefined && m.hasStars !== filter.hasStars) {
|
|
80
|
+
return false;
|
|
81
|
+
}
|
|
82
|
+
if (filter.isRead !== undefined && m.isRead !== filter.isRead) {
|
|
83
|
+
return false;
|
|
84
|
+
}
|
|
85
|
+
if (filter.sentDateRange) {
|
|
86
|
+
const { from, to } = filter.sentDateRange;
|
|
87
|
+
if (from !== undefined && m.sentDate < from) return false;
|
|
88
|
+
if (to !== undefined && m.sentDate > to) return false;
|
|
89
|
+
}
|
|
90
|
+
return true;
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
export class MemoryVectorStore implements VectorStoreService {
|
|
94
|
+
private store = new Map<string, VectorRecord>();
|
|
95
|
+
|
|
96
|
+
upsert = async (vectors: VectorRecord[]): Promise<void> => {
|
|
97
|
+
for (const v of vectors) {
|
|
98
|
+
this.store.set(v.chunkId, v);
|
|
99
|
+
}
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
query = async (params: VectorQuery): Promise<VectorMatch[]> => {
|
|
103
|
+
const matches: VectorMatch[] = [];
|
|
104
|
+
for (const record of this.store.values()) {
|
|
105
|
+
if (!matchesFilter(record, params.filter)) continue;
|
|
106
|
+
const score = cosineSimilarity(params.vector, record.vector);
|
|
107
|
+
matches.push({
|
|
108
|
+
chunkId: record.chunkId,
|
|
109
|
+
score,
|
|
110
|
+
metadata: record.metadata,
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
matches.sort((a, b) => b.score - a.score);
|
|
114
|
+
return matches.slice(0, params.topK);
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
existingContentHashes = async (
|
|
118
|
+
chunkIds: string[],
|
|
119
|
+
): Promise<Map<string, string>> => {
|
|
120
|
+
const out = new Map<string, string>();
|
|
121
|
+
for (const chunkId of chunkIds) {
|
|
122
|
+
const hash = this.store.get(chunkId)?.metadata.contentHash;
|
|
123
|
+
if (typeof hash === "string") out.set(chunkId, hash);
|
|
124
|
+
}
|
|
125
|
+
return out;
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
getByMessage = async (messageId: string): Promise<VectorRecord[]> => {
|
|
129
|
+
const out: VectorRecord[] = [];
|
|
130
|
+
for (const record of this.store.values()) {
|
|
131
|
+
if (record.metadata.messageId === messageId) out.push(record);
|
|
132
|
+
}
|
|
133
|
+
return out;
|
|
134
|
+
};
|
|
135
|
+
|
|
136
|
+
delete = async (filter: { messageId: string }): Promise<void> => {
|
|
137
|
+
const toDelete: string[] = [];
|
|
138
|
+
for (const [id, record] of this.store.entries()) {
|
|
139
|
+
if (record.metadata.messageId === filter.messageId) {
|
|
140
|
+
toDelete.push(id);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
for (const id of toDelete) {
|
|
144
|
+
this.store.delete(id);
|
|
145
|
+
}
|
|
146
|
+
};
|
|
147
|
+
|
|
148
|
+
size = (): number => this.store.size;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
export const createMemoryVectorStore = (): MemoryVectorStore =>
|
|
152
|
+
new MemoryVectorStore();
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Exercises the pgvector store against a real local Postgres with the `vector`
|
|
3
|
+
* extension (the pg-parity container). Proves upsert, cosine ranking,
|
|
4
|
+
* multi-condition scoped filtering, content-hash lookup, and delete — the
|
|
5
|
+
* multi-condition filter is the case that silently emptied results on S3
|
|
6
|
+
* Vectors, so it is tested explicitly here.
|
|
7
|
+
*
|
|
8
|
+
* Gated behind RUN_INTEG_TESTS. Point PG_CONNECTION_URL at a database whose
|
|
9
|
+
* `vector` extension is enabled (default: local remit_test).
|
|
10
|
+
*
|
|
11
|
+
* npm run test:integ -w packages/search-service
|
|
12
|
+
*/
|
|
13
|
+
import assert from "node:assert";
|
|
14
|
+
import { randomUUID } from "node:crypto";
|
|
15
|
+
import { after, before, describe, test } from "node:test";
|
|
16
|
+
import pg from "pg";
|
|
17
|
+
import type { ChunkMetadata, VectorRecord } from "../types.js";
|
|
18
|
+
import type { VectorStoreService } from "./memory.js";
|
|
19
|
+
import { createPgVectorStore } from "./pgvector.js";
|
|
20
|
+
|
|
21
|
+
const RUN = process.env.RUN_INTEG_TESTS === "1";
|
|
22
|
+
const CONNECTION_STRING =
|
|
23
|
+
process.env.PG_CONNECTION_URL ??
|
|
24
|
+
"postgresql://remit:remit@localhost:5432/remit_test";
|
|
25
|
+
|
|
26
|
+
const DIMENSIONS = 4;
|
|
27
|
+
|
|
28
|
+
const meta = (
|
|
29
|
+
over: Partial<ChunkMetadata> & { messageId: string },
|
|
30
|
+
): ChunkMetadata => ({
|
|
31
|
+
threadId: "t-1",
|
|
32
|
+
accountConfigId: "acc-1",
|
|
33
|
+
mailboxIds: ["mb-1"],
|
|
34
|
+
chunkType: "body",
|
|
35
|
+
sentDate: 1000,
|
|
36
|
+
isRead: false,
|
|
37
|
+
hasAttachment: false,
|
|
38
|
+
hasStars: false,
|
|
39
|
+
...over,
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
const record = (
|
|
43
|
+
chunkId: string,
|
|
44
|
+
vector: number[],
|
|
45
|
+
over: Partial<ChunkMetadata> & { messageId: string },
|
|
46
|
+
): VectorRecord => ({ chunkId, vector, metadata: meta(over) });
|
|
47
|
+
|
|
48
|
+
describe("pgvector store (integration)", { skip: !RUN }, () => {
|
|
49
|
+
const table = `message_embedding_test_${randomUUID().replace(/-/g, "")}`;
|
|
50
|
+
let store: VectorStoreService;
|
|
51
|
+
let adminPool: pg.Pool;
|
|
52
|
+
|
|
53
|
+
before(() => {
|
|
54
|
+
adminPool = new pg.Pool({ connectionString: CONNECTION_STRING });
|
|
55
|
+
store = createPgVectorStore({
|
|
56
|
+
connectionString: CONNECTION_STRING,
|
|
57
|
+
dimensions: DIMENSIONS,
|
|
58
|
+
tableName: table,
|
|
59
|
+
});
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
after(async () => {
|
|
63
|
+
await adminPool.query(`DROP TABLE IF EXISTS ${table}`);
|
|
64
|
+
await adminPool.end();
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test("upsert then query ranks by cosine similarity", async () => {
|
|
68
|
+
await store.upsert([
|
|
69
|
+
record("c-x", [1, 0, 0, 0], { messageId: "m-x", contentHash: "hx" }),
|
|
70
|
+
record("c-y", [0, 1, 0, 0], { messageId: "m-y", contentHash: "hy" }),
|
|
71
|
+
record("c-z", [0.9, 0.1, 0, 0], { messageId: "m-z", contentHash: "hz" }),
|
|
72
|
+
]);
|
|
73
|
+
|
|
74
|
+
const matches = await store.query({ vector: [1, 0, 0, 0], topK: 3 });
|
|
75
|
+
|
|
76
|
+
assert.equal(matches[0].chunkId, "c-x");
|
|
77
|
+
assert.equal(matches[1].chunkId, "c-z");
|
|
78
|
+
assert.equal(matches[2].chunkId, "c-y");
|
|
79
|
+
assert.ok(matches[0].score > matches[1].score);
|
|
80
|
+
assert.ok(matches[0].score > 0.99);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test("multi-condition scoped filter narrows to the matching partition", async () => {
|
|
84
|
+
await store.upsert([
|
|
85
|
+
record("s-a", [1, 0, 0, 0], {
|
|
86
|
+
messageId: "m-a",
|
|
87
|
+
accountConfigId: "acc-A",
|
|
88
|
+
mailboxIds: ["inbox"],
|
|
89
|
+
isRead: true,
|
|
90
|
+
}),
|
|
91
|
+
record("s-b", [1, 0, 0, 0], {
|
|
92
|
+
messageId: "m-b",
|
|
93
|
+
accountConfigId: "acc-A",
|
|
94
|
+
mailboxIds: ["archive"],
|
|
95
|
+
isRead: true,
|
|
96
|
+
}),
|
|
97
|
+
record("s-c", [1, 0, 0, 0], {
|
|
98
|
+
messageId: "m-c",
|
|
99
|
+
accountConfigId: "acc-B",
|
|
100
|
+
mailboxIds: ["inbox"],
|
|
101
|
+
isRead: true,
|
|
102
|
+
}),
|
|
103
|
+
record("s-d", [1, 0, 0, 0], {
|
|
104
|
+
messageId: "m-d",
|
|
105
|
+
accountConfigId: "acc-A",
|
|
106
|
+
mailboxIds: ["inbox"],
|
|
107
|
+
isRead: false,
|
|
108
|
+
}),
|
|
109
|
+
]);
|
|
110
|
+
|
|
111
|
+
const matches = await store.query({
|
|
112
|
+
vector: [1, 0, 0, 0],
|
|
113
|
+
topK: 10,
|
|
114
|
+
filter: { accountConfigId: "acc-A", mailboxId: "inbox", isRead: true },
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
const ids = matches.map((m) => m.chunkId);
|
|
118
|
+
assert.deepEqual(ids, ["s-a"]);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
test("existingContentHashes returns stored hashes only for known keys", async () => {
|
|
122
|
+
const hashes = await store.existingContentHashes(["c-x", "c-y", "missing"]);
|
|
123
|
+
assert.equal(hashes.get("c-x"), "hx");
|
|
124
|
+
assert.equal(hashes.get("c-y"), "hy");
|
|
125
|
+
assert.equal(hashes.has("missing"), false);
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
test("upsert overwrites an existing chunk in place", async () => {
|
|
129
|
+
await store.upsert([
|
|
130
|
+
record("c-x", [0, 0, 0, 1], { messageId: "m-x", contentHash: "hx2" }),
|
|
131
|
+
]);
|
|
132
|
+
const hashes = await store.existingContentHashes(["c-x"]);
|
|
133
|
+
assert.equal(hashes.get("c-x"), "hx2");
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
test("getByMessage returns every chunk of a message with its vector and metadata", async () => {
|
|
137
|
+
await store.upsert([
|
|
138
|
+
record("g-sub", [1, 0, 0, 0], {
|
|
139
|
+
messageId: "m-get",
|
|
140
|
+
chunkType: "subject",
|
|
141
|
+
contentHash: "hg1",
|
|
142
|
+
}),
|
|
143
|
+
record("g-body", [0, 1, 0, 0], {
|
|
144
|
+
messageId: "m-get",
|
|
145
|
+
chunkType: "body",
|
|
146
|
+
contentHash: "hg2",
|
|
147
|
+
}),
|
|
148
|
+
record("g-other", [0, 0, 1, 0], { messageId: "m-other" }),
|
|
149
|
+
]);
|
|
150
|
+
|
|
151
|
+
const records = await store.getByMessage("m-get");
|
|
152
|
+
|
|
153
|
+
const byId = new Map(records.map((r) => [r.chunkId, r]));
|
|
154
|
+
assert.equal(records.length, 2, "only the message's own chunks");
|
|
155
|
+
assert.deepEqual(byId.get("g-sub")?.vector, [1, 0, 0, 0]);
|
|
156
|
+
assert.deepEqual(byId.get("g-body")?.vector, [0, 1, 0, 0]);
|
|
157
|
+
assert.equal(byId.get("g-sub")?.metadata.chunkType, "subject");
|
|
158
|
+
assert.equal(byId.get("g-body")?.metadata.messageId, "m-get");
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
test("getByMessage returns an empty array for an unknown message", async () => {
|
|
162
|
+
assert.deepEqual(await store.getByMessage("m-absent"), []);
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
test("delete removes every chunk of a message", async () => {
|
|
166
|
+
await store.upsert([
|
|
167
|
+
record("d-1", [1, 0, 0, 0], { messageId: "m-del" }),
|
|
168
|
+
record("d-2", [0, 1, 0, 0], { messageId: "m-del" }),
|
|
169
|
+
]);
|
|
170
|
+
await store.delete({ messageId: "m-del" });
|
|
171
|
+
const hashes = await store.existingContentHashes(["d-1", "d-2"]);
|
|
172
|
+
assert.equal(hashes.size, 0);
|
|
173
|
+
});
|
|
174
|
+
});
|