@aitytech/agentkits-memory 1.0.0 → 2.0.0
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/LICENSE +21 -0
- package/README.md +267 -149
- package/assets/agentkits-memory-add-memory.png +0 -0
- package/assets/agentkits-memory-memory-detail.png +0 -0
- package/assets/agentkits-memory-memory-list.png +0 -0
- package/assets/logo.svg +24 -0
- package/dist/better-sqlite3-backend.d.ts +192 -0
- package/dist/better-sqlite3-backend.d.ts.map +1 -0
- package/dist/better-sqlite3-backend.js +801 -0
- package/dist/better-sqlite3-backend.js.map +1 -0
- package/dist/cli/save.js +0 -0
- package/dist/cli/setup.d.ts +6 -2
- package/dist/cli/setup.d.ts.map +1 -1
- package/dist/cli/setup.js +289 -42
- package/dist/cli/setup.js.map +1 -1
- package/dist/cli/viewer.js +25 -56
- package/dist/cli/viewer.js.map +1 -1
- package/dist/cli/web-viewer.d.ts +14 -0
- package/dist/cli/web-viewer.d.ts.map +1 -0
- package/dist/cli/web-viewer.js +1769 -0
- package/dist/cli/web-viewer.js.map +1 -0
- package/dist/embeddings/embedding-cache.d.ts +131 -0
- package/dist/embeddings/embedding-cache.d.ts.map +1 -0
- package/dist/embeddings/embedding-cache.js +217 -0
- package/dist/embeddings/embedding-cache.js.map +1 -0
- package/dist/embeddings/index.d.ts +11 -0
- package/dist/embeddings/index.d.ts.map +1 -0
- package/dist/embeddings/index.js +11 -0
- package/dist/embeddings/index.js.map +1 -0
- package/dist/embeddings/local-embeddings.d.ts +140 -0
- package/dist/embeddings/local-embeddings.d.ts.map +1 -0
- package/dist/embeddings/local-embeddings.js +293 -0
- package/dist/embeddings/local-embeddings.js.map +1 -0
- package/dist/hooks/context.d.ts +6 -1
- package/dist/hooks/context.d.ts.map +1 -1
- package/dist/hooks/context.js +12 -2
- package/dist/hooks/context.js.map +1 -1
- package/dist/hooks/observation.d.ts +6 -1
- package/dist/hooks/observation.d.ts.map +1 -1
- package/dist/hooks/observation.js +12 -2
- package/dist/hooks/observation.js.map +1 -1
- package/dist/hooks/service.d.ts +1 -6
- package/dist/hooks/service.d.ts.map +1 -1
- package/dist/hooks/service.js +33 -85
- package/dist/hooks/service.js.map +1 -1
- package/dist/hooks/session-init.d.ts +6 -1
- package/dist/hooks/session-init.d.ts.map +1 -1
- package/dist/hooks/session-init.js +12 -2
- package/dist/hooks/session-init.js.map +1 -1
- package/dist/hooks/summarize.d.ts +6 -1
- package/dist/hooks/summarize.d.ts.map +1 -1
- package/dist/hooks/summarize.js +12 -2
- package/dist/hooks/summarize.js.map +1 -1
- package/dist/index.d.ts +10 -17
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +172 -94
- package/dist/index.js.map +1 -1
- package/dist/mcp/server.js +17 -3
- package/dist/mcp/server.js.map +1 -1
- package/dist/migration.js +3 -3
- package/dist/migration.js.map +1 -1
- package/dist/search/hybrid-search.d.ts +262 -0
- package/dist/search/hybrid-search.d.ts.map +1 -0
- package/dist/search/hybrid-search.js +688 -0
- package/dist/search/hybrid-search.js.map +1 -0
- package/dist/search/index.d.ts +13 -0
- package/dist/search/index.d.ts.map +1 -0
- package/dist/search/index.js +13 -0
- package/dist/search/index.js.map +1 -0
- package/dist/search/token-economics.d.ts +161 -0
- package/dist/search/token-economics.d.ts.map +1 -0
- package/dist/search/token-economics.js +239 -0
- package/dist/search/token-economics.js.map +1 -0
- package/dist/types.d.ts +0 -68
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js.map +1 -1
- package/package.json +23 -8
- package/src/__tests__/better-sqlite3-backend.test.ts +1466 -0
- package/src/__tests__/cache-manager.test.ts +499 -0
- package/src/__tests__/embedding-integration.test.ts +481 -0
- package/src/__tests__/hnsw-index.test.ts +727 -0
- package/src/__tests__/index.test.ts +432 -0
- package/src/better-sqlite3-backend.ts +1000 -0
- package/src/cli/setup.ts +358 -47
- package/src/cli/viewer.ts +28 -63
- package/src/cli/web-viewer.ts +1956 -0
- package/src/embeddings/__tests__/embedding-cache.test.ts +269 -0
- package/src/embeddings/__tests__/local-embeddings.test.ts +495 -0
- package/src/embeddings/embedding-cache.ts +318 -0
- package/src/embeddings/index.ts +20 -0
- package/src/embeddings/local-embeddings.ts +419 -0
- package/src/hooks/__tests__/handlers.test.ts +58 -17
- package/src/hooks/__tests__/integration.test.ts +77 -26
- package/src/hooks/context.ts +13 -2
- package/src/hooks/observation.ts +13 -2
- package/src/hooks/service.ts +39 -100
- package/src/hooks/session-init.ts +13 -2
- package/src/hooks/summarize.ts +13 -2
- package/src/index.ts +210 -116
- package/src/mcp/server.ts +20 -3
- package/src/search/__tests__/hybrid-search.test.ts +669 -0
- package/src/search/__tests__/token-economics.test.ts +276 -0
- package/src/search/hybrid-search.ts +968 -0
- package/src/search/index.ts +29 -0
- package/src/search/token-economics.ts +367 -0
- package/src/types.ts +0 -96
- package/src/__tests__/sqljs-backend.test.ts +0 -410
- package/src/migration.ts +0 -574
- package/src/sql.js.d.ts +0 -70
- package/src/sqljs-backend.ts +0 -789
|
@@ -0,0 +1,727 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for HNSWIndex
|
|
3
|
+
*
|
|
4
|
+
* Tests HNSW vector index functionality including:
|
|
5
|
+
* - Vector insertion and search
|
|
6
|
+
* - Distance metrics (cosine, euclidean, dot product, manhattan)
|
|
7
|
+
* - Quantization (binary, scalar, product)
|
|
8
|
+
* - Index operations (remove, rebuild, clear)
|
|
9
|
+
* - Statistics and events
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
|
13
|
+
import { HNSWIndex } from '../hnsw-index.js';
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Helper to create a random vector
|
|
17
|
+
*/
|
|
18
|
+
function randomVector(dimensions: number): Float32Array {
|
|
19
|
+
const vector = new Float32Array(dimensions);
|
|
20
|
+
for (let i = 0; i < dimensions; i++) {
|
|
21
|
+
vector[i] = Math.random() * 2 - 1; // Values between -1 and 1
|
|
22
|
+
}
|
|
23
|
+
return vector;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Helper to create a normalized vector (unit length)
|
|
28
|
+
*/
|
|
29
|
+
function normalizedVector(dimensions: number): Float32Array {
|
|
30
|
+
const vector = randomVector(dimensions);
|
|
31
|
+
let norm = 0;
|
|
32
|
+
for (let i = 0; i < dimensions; i++) {
|
|
33
|
+
norm += vector[i] * vector[i];
|
|
34
|
+
}
|
|
35
|
+
norm = Math.sqrt(norm);
|
|
36
|
+
for (let i = 0; i < dimensions; i++) {
|
|
37
|
+
vector[i] /= norm;
|
|
38
|
+
}
|
|
39
|
+
return vector;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Helper to create a specific vector
|
|
44
|
+
*/
|
|
45
|
+
function createVector(values: number[]): Float32Array {
|
|
46
|
+
return new Float32Array(values);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
describe('HNSWIndex', () => {
|
|
50
|
+
let index: HNSWIndex;
|
|
51
|
+
const dimensions = 8;
|
|
52
|
+
|
|
53
|
+
beforeEach(() => {
|
|
54
|
+
index = new HNSWIndex({
|
|
55
|
+
dimensions,
|
|
56
|
+
M: 8,
|
|
57
|
+
efConstruction: 50,
|
|
58
|
+
maxElements: 1000,
|
|
59
|
+
metric: 'cosine',
|
|
60
|
+
});
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
describe('basic operations', () => {
|
|
64
|
+
it('should create an empty index', () => {
|
|
65
|
+
expect(index.size).toBe(0);
|
|
66
|
+
expect(index.has('nonexistent')).toBe(false);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it('should add a single point', async () => {
|
|
70
|
+
const vector = randomVector(dimensions);
|
|
71
|
+
await index.addPoint('id1', vector);
|
|
72
|
+
|
|
73
|
+
expect(index.size).toBe(1);
|
|
74
|
+
expect(index.has('id1')).toBe(true);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it('should add multiple points', async () => {
|
|
78
|
+
for (let i = 0; i < 10; i++) {
|
|
79
|
+
await index.addPoint(`id${i}`, randomVector(dimensions));
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
expect(index.size).toBe(10);
|
|
83
|
+
for (let i = 0; i < 10; i++) {
|
|
84
|
+
expect(index.has(`id${i}`)).toBe(true);
|
|
85
|
+
}
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it('should reject vectors with wrong dimensions', async () => {
|
|
89
|
+
const wrongVector = randomVector(dimensions + 5);
|
|
90
|
+
|
|
91
|
+
await expect(index.addPoint('id1', wrongVector)).rejects.toThrow(
|
|
92
|
+
/dimension mismatch/
|
|
93
|
+
);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it('should reject when index is full', async () => {
|
|
97
|
+
const smallIndex = new HNSWIndex({
|
|
98
|
+
dimensions,
|
|
99
|
+
maxElements: 2,
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
await smallIndex.addPoint('id1', randomVector(dimensions));
|
|
103
|
+
await smallIndex.addPoint('id2', randomVector(dimensions));
|
|
104
|
+
|
|
105
|
+
await expect(
|
|
106
|
+
smallIndex.addPoint('id3', randomVector(dimensions))
|
|
107
|
+
).rejects.toThrow(/full/);
|
|
108
|
+
});
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
describe('search', () => {
|
|
112
|
+
it('should return empty results for empty index', async () => {
|
|
113
|
+
const query = randomVector(dimensions);
|
|
114
|
+
const results = await index.search(query, 5);
|
|
115
|
+
|
|
116
|
+
expect(results).toHaveLength(0);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it('should find the exact vector', async () => {
|
|
120
|
+
const vector = normalizedVector(dimensions);
|
|
121
|
+
await index.addPoint('id1', vector);
|
|
122
|
+
|
|
123
|
+
const results = await index.search(vector, 1);
|
|
124
|
+
|
|
125
|
+
expect(results).toHaveLength(1);
|
|
126
|
+
expect(results[0].id).toBe('id1');
|
|
127
|
+
expect(results[0].distance).toBeCloseTo(0, 5);
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it('should return k nearest neighbors', async () => {
|
|
131
|
+
// Add 20 points
|
|
132
|
+
for (let i = 0; i < 20; i++) {
|
|
133
|
+
await index.addPoint(`id${i}`, randomVector(dimensions));
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const query = randomVector(dimensions);
|
|
137
|
+
const results = await index.search(query, 5);
|
|
138
|
+
|
|
139
|
+
expect(results).toHaveLength(5);
|
|
140
|
+
// Results should be sorted by distance
|
|
141
|
+
for (let i = 1; i < results.length; i++) {
|
|
142
|
+
expect(results[i].distance).toBeGreaterThanOrEqual(results[i - 1].distance);
|
|
143
|
+
}
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
it('should respect k limit when index has fewer points', async () => {
|
|
147
|
+
await index.addPoint('id1', randomVector(dimensions));
|
|
148
|
+
await index.addPoint('id2', randomVector(dimensions));
|
|
149
|
+
|
|
150
|
+
const query = randomVector(dimensions);
|
|
151
|
+
const results = await index.search(query, 10);
|
|
152
|
+
|
|
153
|
+
expect(results).toHaveLength(2);
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
it('should reject query with wrong dimensions', async () => {
|
|
157
|
+
await index.addPoint('id1', randomVector(dimensions));
|
|
158
|
+
const wrongQuery = randomVector(dimensions + 3);
|
|
159
|
+
|
|
160
|
+
await expect(index.search(wrongQuery, 5)).rejects.toThrow(
|
|
161
|
+
/dimension mismatch/
|
|
162
|
+
);
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
it('should find similar vectors closer', async () => {
|
|
166
|
+
// Create a base vector
|
|
167
|
+
const base = createVector([1, 0, 0, 0, 0, 0, 0, 0]);
|
|
168
|
+
const similar = createVector([0.9, 0.1, 0, 0, 0, 0, 0, 0]);
|
|
169
|
+
const different = createVector([0, 0, 0, 0, 0, 0, 0, 1]);
|
|
170
|
+
|
|
171
|
+
await index.addPoint('similar', similar);
|
|
172
|
+
await index.addPoint('different', different);
|
|
173
|
+
|
|
174
|
+
const results = await index.search(base, 2);
|
|
175
|
+
|
|
176
|
+
expect(results[0].id).toBe('similar');
|
|
177
|
+
expect(results[1].id).toBe('different');
|
|
178
|
+
expect(results[0].distance).toBeLessThan(results[1].distance);
|
|
179
|
+
});
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
describe('searchWithFilters', () => {
|
|
183
|
+
it('should apply filters to results', async () => {
|
|
184
|
+
for (let i = 0; i < 20; i++) {
|
|
185
|
+
await index.addPoint(`id${i}`, randomVector(dimensions));
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
const query = randomVector(dimensions);
|
|
189
|
+
// Only accept IDs that end with even numbers
|
|
190
|
+
const filter = (id: string) => {
|
|
191
|
+
const num = parseInt(id.replace('id', ''));
|
|
192
|
+
return num % 2 === 0;
|
|
193
|
+
};
|
|
194
|
+
|
|
195
|
+
const results = await index.searchWithFilters(query, 5, filter);
|
|
196
|
+
|
|
197
|
+
expect(results.length).toBeLessThanOrEqual(5);
|
|
198
|
+
for (const result of results) {
|
|
199
|
+
const num = parseInt(result.id.replace('id', ''));
|
|
200
|
+
expect(num % 2).toBe(0);
|
|
201
|
+
}
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
it('should return fewer results if filter eliminates many', async () => {
|
|
205
|
+
for (let i = 0; i < 10; i++) {
|
|
206
|
+
await index.addPoint(`id${i}`, randomVector(dimensions));
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
const query = randomVector(dimensions);
|
|
210
|
+
// Only accept id0
|
|
211
|
+
const filter = (id: string) => id === 'id0';
|
|
212
|
+
|
|
213
|
+
const results = await index.searchWithFilters(query, 5, filter);
|
|
214
|
+
|
|
215
|
+
expect(results.length).toBe(1);
|
|
216
|
+
expect(results[0].id).toBe('id0');
|
|
217
|
+
});
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
describe('removePoint', () => {
|
|
221
|
+
it('should remove an existing point', async () => {
|
|
222
|
+
await index.addPoint('id1', randomVector(dimensions));
|
|
223
|
+
await index.addPoint('id2', randomVector(dimensions));
|
|
224
|
+
|
|
225
|
+
expect(index.size).toBe(2);
|
|
226
|
+
const removed = await index.removePoint('id1');
|
|
227
|
+
|
|
228
|
+
expect(removed).toBe(true);
|
|
229
|
+
expect(index.size).toBe(1);
|
|
230
|
+
expect(index.has('id1')).toBe(false);
|
|
231
|
+
expect(index.has('id2')).toBe(true);
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
it('should return false for non-existent point', async () => {
|
|
235
|
+
const removed = await index.removePoint('nonexistent');
|
|
236
|
+
expect(removed).toBe(false);
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
it('should handle removing entry point', async () => {
|
|
240
|
+
await index.addPoint('id1', randomVector(dimensions));
|
|
241
|
+
await index.addPoint('id2', randomVector(dimensions));
|
|
242
|
+
|
|
243
|
+
// Remove both points
|
|
244
|
+
await index.removePoint('id1');
|
|
245
|
+
await index.removePoint('id2');
|
|
246
|
+
|
|
247
|
+
expect(index.size).toBe(0);
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
it('should still allow search after removal', async () => {
|
|
251
|
+
await index.addPoint('id1', randomVector(dimensions));
|
|
252
|
+
await index.addPoint('id2', randomVector(dimensions));
|
|
253
|
+
await index.addPoint('id3', randomVector(dimensions));
|
|
254
|
+
|
|
255
|
+
await index.removePoint('id2');
|
|
256
|
+
|
|
257
|
+
const query = randomVector(dimensions);
|
|
258
|
+
const results = await index.search(query, 10);
|
|
259
|
+
|
|
260
|
+
expect(results).toHaveLength(2);
|
|
261
|
+
expect(results.map((r) => r.id)).not.toContain('id2');
|
|
262
|
+
});
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
describe('rebuild', () => {
|
|
266
|
+
it('should rebuild index from entries', async () => {
|
|
267
|
+
const entries = [];
|
|
268
|
+
for (let i = 0; i < 10; i++) {
|
|
269
|
+
entries.push({ id: `id${i}`, vector: randomVector(dimensions) });
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
await index.rebuild(entries);
|
|
273
|
+
|
|
274
|
+
expect(index.size).toBe(10);
|
|
275
|
+
for (const entry of entries) {
|
|
276
|
+
expect(index.has(entry.id)).toBe(true);
|
|
277
|
+
}
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
it('should clear existing entries during rebuild', async () => {
|
|
281
|
+
await index.addPoint('old1', randomVector(dimensions));
|
|
282
|
+
await index.addPoint('old2', randomVector(dimensions));
|
|
283
|
+
|
|
284
|
+
await index.rebuild([{ id: 'new1', vector: randomVector(dimensions) }]);
|
|
285
|
+
|
|
286
|
+
expect(index.size).toBe(1);
|
|
287
|
+
expect(index.has('old1')).toBe(false);
|
|
288
|
+
expect(index.has('new1')).toBe(true);
|
|
289
|
+
});
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
describe('clear', () => {
|
|
293
|
+
it('should remove all entries', async () => {
|
|
294
|
+
for (let i = 0; i < 5; i++) {
|
|
295
|
+
await index.addPoint(`id${i}`, randomVector(dimensions));
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
expect(index.size).toBe(5);
|
|
299
|
+
index.clear();
|
|
300
|
+
expect(index.size).toBe(0);
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
it('should reset statistics', async () => {
|
|
304
|
+
await index.addPoint('id1', randomVector(dimensions));
|
|
305
|
+
await index.search(randomVector(dimensions), 5);
|
|
306
|
+
|
|
307
|
+
index.clear();
|
|
308
|
+
const stats = index.getStats();
|
|
309
|
+
|
|
310
|
+
expect(stats.vectorCount).toBe(0);
|
|
311
|
+
});
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
describe('statistics', () => {
|
|
315
|
+
it('should track vector count', async () => {
|
|
316
|
+
await index.addPoint('id1', randomVector(dimensions));
|
|
317
|
+
await index.addPoint('id2', randomVector(dimensions));
|
|
318
|
+
|
|
319
|
+
const stats = index.getStats();
|
|
320
|
+
expect(stats.vectorCount).toBe(2);
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
it('should estimate memory usage', async () => {
|
|
324
|
+
await index.addPoint('id1', randomVector(dimensions));
|
|
325
|
+
|
|
326
|
+
const stats = index.getStats();
|
|
327
|
+
expect(stats.memoryUsage).toBeGreaterThan(0);
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
it('should track search time', async () => {
|
|
331
|
+
await index.addPoint('id1', randomVector(dimensions));
|
|
332
|
+
await index.search(randomVector(dimensions), 5);
|
|
333
|
+
await index.search(randomVector(dimensions), 5);
|
|
334
|
+
|
|
335
|
+
const stats = index.getStats();
|
|
336
|
+
expect(stats.avgSearchTime).toBeGreaterThanOrEqual(0);
|
|
337
|
+
});
|
|
338
|
+
});
|
|
339
|
+
|
|
340
|
+
describe('events', () => {
|
|
341
|
+
it('should emit point:added event', async () => {
|
|
342
|
+
const handler = vi.fn();
|
|
343
|
+
index.on('point:added', handler);
|
|
344
|
+
|
|
345
|
+
await index.addPoint('id1', randomVector(dimensions));
|
|
346
|
+
|
|
347
|
+
expect(handler).toHaveBeenCalledWith(
|
|
348
|
+
expect.objectContaining({
|
|
349
|
+
id: 'id1',
|
|
350
|
+
duration: expect.any(Number),
|
|
351
|
+
})
|
|
352
|
+
);
|
|
353
|
+
});
|
|
354
|
+
|
|
355
|
+
it('should emit point:removed event', async () => {
|
|
356
|
+
const handler = vi.fn();
|
|
357
|
+
index.on('point:removed', handler);
|
|
358
|
+
|
|
359
|
+
await index.addPoint('id1', randomVector(dimensions));
|
|
360
|
+
await index.removePoint('id1');
|
|
361
|
+
|
|
362
|
+
expect(handler).toHaveBeenCalledWith({ id: 'id1' });
|
|
363
|
+
});
|
|
364
|
+
|
|
365
|
+
it('should emit index:rebuilt event', async () => {
|
|
366
|
+
const handler = vi.fn();
|
|
367
|
+
index.on('index:rebuilt', handler);
|
|
368
|
+
|
|
369
|
+
await index.rebuild([{ id: 'id1', vector: randomVector(dimensions) }]);
|
|
370
|
+
|
|
371
|
+
expect(handler).toHaveBeenCalledWith(
|
|
372
|
+
expect.objectContaining({
|
|
373
|
+
vectorCount: 1,
|
|
374
|
+
buildTime: expect.any(Number),
|
|
375
|
+
})
|
|
376
|
+
);
|
|
377
|
+
});
|
|
378
|
+
});
|
|
379
|
+
|
|
380
|
+
describe('distance metrics', () => {
|
|
381
|
+
describe('cosine', () => {
|
|
382
|
+
it('should find identical vectors as distance 0', async () => {
|
|
383
|
+
const cosineIndex = new HNSWIndex({
|
|
384
|
+
dimensions: 4,
|
|
385
|
+
metric: 'cosine',
|
|
386
|
+
});
|
|
387
|
+
|
|
388
|
+
const vector = normalizedVector(4);
|
|
389
|
+
await cosineIndex.addPoint('id1', vector);
|
|
390
|
+
|
|
391
|
+
const results = await cosineIndex.search(vector, 1);
|
|
392
|
+
expect(results[0].distance).toBeCloseTo(0, 5);
|
|
393
|
+
});
|
|
394
|
+
|
|
395
|
+
it('should find opposite vectors as distance close to 2', async () => {
|
|
396
|
+
const cosineIndex = new HNSWIndex({
|
|
397
|
+
dimensions: 4,
|
|
398
|
+
metric: 'cosine',
|
|
399
|
+
});
|
|
400
|
+
|
|
401
|
+
const vector = createVector([1, 0, 0, 0]);
|
|
402
|
+
const opposite = createVector([-1, 0, 0, 0]);
|
|
403
|
+
|
|
404
|
+
await cosineIndex.addPoint('id1', opposite);
|
|
405
|
+
|
|
406
|
+
const results = await cosineIndex.search(vector, 1);
|
|
407
|
+
expect(results[0].distance).toBeCloseTo(2, 1);
|
|
408
|
+
});
|
|
409
|
+
});
|
|
410
|
+
|
|
411
|
+
describe('euclidean', () => {
|
|
412
|
+
it('should compute euclidean distance correctly', async () => {
|
|
413
|
+
const euclideanIndex = new HNSWIndex({
|
|
414
|
+
dimensions: 3,
|
|
415
|
+
metric: 'euclidean',
|
|
416
|
+
});
|
|
417
|
+
|
|
418
|
+
const vector1 = createVector([0, 0, 0]);
|
|
419
|
+
const vector2 = createVector([3, 4, 0]); // Distance should be 5
|
|
420
|
+
|
|
421
|
+
await euclideanIndex.addPoint('id1', vector2);
|
|
422
|
+
|
|
423
|
+
const results = await euclideanIndex.search(vector1, 1);
|
|
424
|
+
expect(results[0].distance).toBeCloseTo(5, 5);
|
|
425
|
+
});
|
|
426
|
+
});
|
|
427
|
+
|
|
428
|
+
describe('dot product', () => {
|
|
429
|
+
it('should compute dot product distance', async () => {
|
|
430
|
+
const dotIndex = new HNSWIndex({
|
|
431
|
+
dimensions: 4,
|
|
432
|
+
metric: 'dot',
|
|
433
|
+
});
|
|
434
|
+
|
|
435
|
+
const vector1 = createVector([1, 2, 3, 4]);
|
|
436
|
+
const vector2 = createVector([1, 1, 1, 1]); // Dot product = 10
|
|
437
|
+
|
|
438
|
+
await dotIndex.addPoint('id1', vector2);
|
|
439
|
+
|
|
440
|
+
const results = await dotIndex.search(vector1, 1);
|
|
441
|
+
// Dot distance is negative (higher dot product = more similar = lower distance)
|
|
442
|
+
expect(results[0].distance).toBeCloseTo(-10, 5);
|
|
443
|
+
});
|
|
444
|
+
});
|
|
445
|
+
|
|
446
|
+
describe('manhattan', () => {
|
|
447
|
+
it('should compute manhattan distance correctly', async () => {
|
|
448
|
+
const manhattanIndex = new HNSWIndex({
|
|
449
|
+
dimensions: 3,
|
|
450
|
+
metric: 'manhattan',
|
|
451
|
+
});
|
|
452
|
+
|
|
453
|
+
const vector1 = createVector([0, 0, 0]);
|
|
454
|
+
const vector2 = createVector([1, 2, 3]); // Manhattan distance = 6
|
|
455
|
+
|
|
456
|
+
await manhattanIndex.addPoint('id1', vector2);
|
|
457
|
+
|
|
458
|
+
const results = await manhattanIndex.search(vector1, 1);
|
|
459
|
+
expect(results[0].distance).toBeCloseTo(6, 5);
|
|
460
|
+
});
|
|
461
|
+
});
|
|
462
|
+
});
|
|
463
|
+
|
|
464
|
+
describe('quantization', () => {
|
|
465
|
+
describe('binary quantization', () => {
|
|
466
|
+
it('should work with binary quantization', async () => {
|
|
467
|
+
const quantizedIndex = new HNSWIndex({
|
|
468
|
+
dimensions: 32,
|
|
469
|
+
quantization: {
|
|
470
|
+
enabled: true,
|
|
471
|
+
type: 'binary',
|
|
472
|
+
bits: 1,
|
|
473
|
+
method: 'scalar',
|
|
474
|
+
},
|
|
475
|
+
});
|
|
476
|
+
|
|
477
|
+
await quantizedIndex.addPoint('id1', randomVector(32));
|
|
478
|
+
await quantizedIndex.addPoint('id2', randomVector(32));
|
|
479
|
+
|
|
480
|
+
const results = await quantizedIndex.search(randomVector(32), 2);
|
|
481
|
+
expect(results.length).toBe(2);
|
|
482
|
+
|
|
483
|
+
const stats = quantizedIndex.getStats();
|
|
484
|
+
expect(stats.compressionRatio).toBe(32);
|
|
485
|
+
});
|
|
486
|
+
});
|
|
487
|
+
|
|
488
|
+
describe('scalar quantization', () => {
|
|
489
|
+
it('should work with scalar quantization', async () => {
|
|
490
|
+
const quantizedIndex = new HNSWIndex({
|
|
491
|
+
dimensions: 16,
|
|
492
|
+
quantization: {
|
|
493
|
+
enabled: true,
|
|
494
|
+
type: 'scalar',
|
|
495
|
+
bits: 8,
|
|
496
|
+
method: 'scalar',
|
|
497
|
+
},
|
|
498
|
+
});
|
|
499
|
+
|
|
500
|
+
await quantizedIndex.addPoint('id1', randomVector(16));
|
|
501
|
+
await quantizedIndex.addPoint('id2', randomVector(16));
|
|
502
|
+
|
|
503
|
+
const results = await quantizedIndex.search(randomVector(16), 2);
|
|
504
|
+
expect(results.length).toBe(2);
|
|
505
|
+
|
|
506
|
+
const stats = quantizedIndex.getStats();
|
|
507
|
+
expect(stats.compressionRatio).toBe(4); // 32/8
|
|
508
|
+
});
|
|
509
|
+
});
|
|
510
|
+
|
|
511
|
+
describe('product quantization', () => {
|
|
512
|
+
it('should work with product quantization', async () => {
|
|
513
|
+
const quantizedIndex = new HNSWIndex({
|
|
514
|
+
dimensions: 32,
|
|
515
|
+
quantization: {
|
|
516
|
+
enabled: true,
|
|
517
|
+
type: 'product',
|
|
518
|
+
bits: 8,
|
|
519
|
+
method: 'product',
|
|
520
|
+
subquantizers: 8,
|
|
521
|
+
},
|
|
522
|
+
});
|
|
523
|
+
|
|
524
|
+
await quantizedIndex.addPoint('id1', randomVector(32));
|
|
525
|
+
await quantizedIndex.addPoint('id2', randomVector(32));
|
|
526
|
+
|
|
527
|
+
const results = await quantizedIndex.search(randomVector(32), 2);
|
|
528
|
+
expect(results.length).toBe(2);
|
|
529
|
+
|
|
530
|
+
const stats = quantizedIndex.getStats();
|
|
531
|
+
expect(stats.compressionRatio).toBe(8);
|
|
532
|
+
});
|
|
533
|
+
});
|
|
534
|
+
});
|
|
535
|
+
|
|
536
|
+
describe('default configuration', () => {
|
|
537
|
+
it('should use default values when not specified', async () => {
|
|
538
|
+
const defaultIndex = new HNSWIndex({});
|
|
539
|
+
|
|
540
|
+
// Should use default dimensions (1536 for OpenAI)
|
|
541
|
+
const vector = randomVector(1536);
|
|
542
|
+
await defaultIndex.addPoint('id1', vector);
|
|
543
|
+
|
|
544
|
+
expect(defaultIndex.size).toBe(1);
|
|
545
|
+
});
|
|
546
|
+
});
|
|
547
|
+
|
|
548
|
+
describe('concurrent operations', () => {
|
|
549
|
+
it('should handle concurrent insertions', async () => {
|
|
550
|
+
const promises = [];
|
|
551
|
+
for (let i = 0; i < 20; i++) {
|
|
552
|
+
promises.push(index.addPoint(`id${i}`, randomVector(dimensions)));
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
await Promise.all(promises);
|
|
556
|
+
expect(index.size).toBe(20);
|
|
557
|
+
});
|
|
558
|
+
|
|
559
|
+
it('should handle concurrent searches', async () => {
|
|
560
|
+
// Add some points first
|
|
561
|
+
for (let i = 0; i < 10; i++) {
|
|
562
|
+
await index.addPoint(`id${i}`, randomVector(dimensions));
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
// Concurrent searches
|
|
566
|
+
const promises = [];
|
|
567
|
+
for (let i = 0; i < 10; i++) {
|
|
568
|
+
promises.push(index.search(randomVector(dimensions), 5));
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
const results = await Promise.all(promises);
|
|
572
|
+
for (const result of results) {
|
|
573
|
+
expect(result.length).toBeLessThanOrEqual(5);
|
|
574
|
+
}
|
|
575
|
+
});
|
|
576
|
+
});
|
|
577
|
+
|
|
578
|
+
describe('edge cases', () => {
|
|
579
|
+
it('should handle single element index', async () => {
|
|
580
|
+
await index.addPoint('only', randomVector(dimensions));
|
|
581
|
+
|
|
582
|
+
const results = await index.search(randomVector(dimensions), 10);
|
|
583
|
+
expect(results).toHaveLength(1);
|
|
584
|
+
expect(results[0].id).toBe('only');
|
|
585
|
+
});
|
|
586
|
+
|
|
587
|
+
it('should handle searching for more results than exist', async () => {
|
|
588
|
+
await index.addPoint('id1', randomVector(dimensions));
|
|
589
|
+
await index.addPoint('id2', randomVector(dimensions));
|
|
590
|
+
|
|
591
|
+
const results = await index.search(randomVector(dimensions), 100);
|
|
592
|
+
expect(results).toHaveLength(2);
|
|
593
|
+
});
|
|
594
|
+
|
|
595
|
+
it('should handle zero vector', async () => {
|
|
596
|
+
const zeroVector = createVector([0, 0, 0, 0, 0, 0, 0, 0]);
|
|
597
|
+
await index.addPoint('zero', zeroVector);
|
|
598
|
+
|
|
599
|
+
expect(index.has('zero')).toBe(true);
|
|
600
|
+
});
|
|
601
|
+
|
|
602
|
+
it('should handle very similar vectors', async () => {
|
|
603
|
+
const base = createVector([1, 0, 0, 0, 0, 0, 0, 0]);
|
|
604
|
+
const almostSame = createVector([0.99, 0.01, 0, 0, 0, 0, 0, 0]);
|
|
605
|
+
|
|
606
|
+
await index.addPoint('base', base);
|
|
607
|
+
await index.addPoint('almost', almostSame);
|
|
608
|
+
|
|
609
|
+
const results = await index.search(base, 2);
|
|
610
|
+
// Both should be found, with base being closer (distance closer to 0)
|
|
611
|
+
expect(results.length).toBe(2);
|
|
612
|
+
// The base vector should have distance very close to 0
|
|
613
|
+
const baseResult = results.find((r) => r.id === 'base');
|
|
614
|
+
expect(baseResult).toBeDefined();
|
|
615
|
+
expect(baseResult!.distance).toBeCloseTo(0, 3);
|
|
616
|
+
});
|
|
617
|
+
});
|
|
618
|
+
|
|
619
|
+
describe('large scale', () => {
|
|
620
|
+
it('should handle 100 vectors efficiently', async () => {
|
|
621
|
+
const largeIndex = new HNSWIndex({
|
|
622
|
+
dimensions: 64,
|
|
623
|
+
M: 16,
|
|
624
|
+
efConstruction: 100,
|
|
625
|
+
maxElements: 10000,
|
|
626
|
+
});
|
|
627
|
+
|
|
628
|
+
const startInsert = performance.now();
|
|
629
|
+
for (let i = 0; i < 100; i++) {
|
|
630
|
+
await largeIndex.addPoint(`id${i}`, randomVector(64));
|
|
631
|
+
}
|
|
632
|
+
const insertTime = performance.now() - startInsert;
|
|
633
|
+
|
|
634
|
+
expect(largeIndex.size).toBe(100);
|
|
635
|
+
expect(insertTime).toBeLessThan(5000); // Should complete in 5 seconds
|
|
636
|
+
|
|
637
|
+
const startSearch = performance.now();
|
|
638
|
+
const results = await largeIndex.search(randomVector(64), 10);
|
|
639
|
+
const searchTime = performance.now() - startSearch;
|
|
640
|
+
|
|
641
|
+
expect(results).toHaveLength(10);
|
|
642
|
+
expect(searchTime).toBeLessThan(100); // Search should be fast
|
|
643
|
+
});
|
|
644
|
+
});
|
|
645
|
+
|
|
646
|
+
describe('quantization edge cases', () => {
|
|
647
|
+
it('should handle no quantization type', async () => {
|
|
648
|
+
const noQuantIndex = new HNSWIndex({
|
|
649
|
+
dimensions: 8,
|
|
650
|
+
quantization: {
|
|
651
|
+
enabled: true,
|
|
652
|
+
type: 'none',
|
|
653
|
+
bits: 8,
|
|
654
|
+
method: 'scalar',
|
|
655
|
+
},
|
|
656
|
+
});
|
|
657
|
+
|
|
658
|
+
await noQuantIndex.addPoint('id1', randomVector(8));
|
|
659
|
+
const results = await noQuantIndex.search(randomVector(8), 1);
|
|
660
|
+
expect(results.length).toBe(1);
|
|
661
|
+
|
|
662
|
+
const stats = noQuantIndex.getStats();
|
|
663
|
+
expect(stats.compressionRatio).toBe(1); // No compression with 'none' type
|
|
664
|
+
});
|
|
665
|
+
});
|
|
666
|
+
|
|
667
|
+
describe('multiple layers', () => {
|
|
668
|
+
it('should build multi-level graph with many insertions', async () => {
|
|
669
|
+
const multiLevelIndex = new HNSWIndex({
|
|
670
|
+
dimensions: 8,
|
|
671
|
+
M: 4,
|
|
672
|
+
efConstruction: 20,
|
|
673
|
+
maxElements: 200,
|
|
674
|
+
});
|
|
675
|
+
|
|
676
|
+
// Insert enough points to likely have multiple levels
|
|
677
|
+
for (let i = 0; i < 50; i++) {
|
|
678
|
+
await multiLevelIndex.addPoint(`id${i}`, randomVector(8));
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
expect(multiLevelIndex.size).toBe(50);
|
|
682
|
+
|
|
683
|
+
// Search should still work
|
|
684
|
+
const results = await multiLevelIndex.search(randomVector(8), 5);
|
|
685
|
+
expect(results.length).toBe(5);
|
|
686
|
+
});
|
|
687
|
+
});
|
|
688
|
+
|
|
689
|
+
describe('connection pruning', () => {
|
|
690
|
+
it('should prune connections when graph is dense', async () => {
|
|
691
|
+
// Use small M to force pruning
|
|
692
|
+
const denseIndex = new HNSWIndex({
|
|
693
|
+
dimensions: 4,
|
|
694
|
+
M: 2,
|
|
695
|
+
efConstruction: 10,
|
|
696
|
+
maxElements: 100,
|
|
697
|
+
});
|
|
698
|
+
|
|
699
|
+
// Insert points that will require pruning
|
|
700
|
+
for (let i = 0; i < 20; i++) {
|
|
701
|
+
await denseIndex.addPoint(`id${i}`, randomVector(4));
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
expect(denseIndex.size).toBe(20);
|
|
705
|
+
|
|
706
|
+
// Should still be searchable after pruning
|
|
707
|
+
const results = await denseIndex.search(randomVector(4), 5);
|
|
708
|
+
expect(results.length).toBe(5);
|
|
709
|
+
});
|
|
710
|
+
});
|
|
711
|
+
|
|
712
|
+
describe('ef parameter', () => {
|
|
713
|
+
it('should use custom ef for search', async () => {
|
|
714
|
+
for (let i = 0; i < 30; i++) {
|
|
715
|
+
await index.addPoint(`id${i}`, randomVector(dimensions));
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
// Search with different ef values
|
|
719
|
+
const lowEf = await index.search(randomVector(dimensions), 5, 10);
|
|
720
|
+
const highEf = await index.search(randomVector(dimensions), 5, 100);
|
|
721
|
+
|
|
722
|
+
// Both should return 5 results
|
|
723
|
+
expect(lowEf.length).toBe(5);
|
|
724
|
+
expect(highEf.length).toBe(5);
|
|
725
|
+
});
|
|
726
|
+
});
|
|
727
|
+
});
|