@mastra/pg 0.1.4 → 0.1.5-alpha.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/.turbo/turbo-build.log +5 -5
- package/CHANGELOG.md +16 -0
- package/README.md +16 -10
- package/dist/_tsup-dts-rollup.d.ts +149 -3
- package/dist/index.d.ts +1 -0
- package/dist/index.js +90 -30
- package/docker-compose.perf.yaml +21 -0
- package/package.json +5 -2
- package/src/vector/index.test.ts +144 -47
- package/src/vector/index.ts +131 -34
- package/src/vector/performance.helpers.ts +286 -0
- package/src/vector/types.ts +16 -0
- package/src/vector/vector.performance.test.ts +371 -0
- package/vitest.config.ts +1 -0
- package/vitest.perf.config.ts +8 -0
|
@@ -0,0 +1,286 @@
|
|
|
1
|
+
import { type IndexConfig, type IndexType } from './types';
|
|
2
|
+
|
|
3
|
+
import { PgVector } from '.';
|
|
4
|
+
|
|
5
|
+
export interface TestResult {
|
|
6
|
+
distribution: string;
|
|
7
|
+
dimension: number;
|
|
8
|
+
type: IndexType;
|
|
9
|
+
size: number;
|
|
10
|
+
k?: number;
|
|
11
|
+
metrics: {
|
|
12
|
+
recall?: number;
|
|
13
|
+
minRecall?: number;
|
|
14
|
+
maxRecall?: number;
|
|
15
|
+
latency?: {
|
|
16
|
+
p50: number;
|
|
17
|
+
p95: number;
|
|
18
|
+
lists?: number;
|
|
19
|
+
vectorsPerList?: number;
|
|
20
|
+
m?: number;
|
|
21
|
+
ef?: number;
|
|
22
|
+
};
|
|
23
|
+
clustering?: {
|
|
24
|
+
numLists?: number;
|
|
25
|
+
avgVectorsPerList?: number;
|
|
26
|
+
recommendedLists?: number;
|
|
27
|
+
distribution?: string;
|
|
28
|
+
};
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export const generateRandomVectors = (count: number, dim: number) => {
|
|
33
|
+
return Array.from({ length: count }, () => {
|
|
34
|
+
return Array.from({ length: dim }, () => Math.random() * 2 - 1);
|
|
35
|
+
});
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
export const generateClusteredVectors = (count: number, dim: number, numClusters: number = 10) => {
|
|
39
|
+
// Generate cluster centers
|
|
40
|
+
const centers = Array.from({ length: numClusters }, () => Array.from({ length: dim }, () => Math.random() * 2 - 1));
|
|
41
|
+
|
|
42
|
+
// Generate vectors around centers with varying spread
|
|
43
|
+
return Array.from({ length: count }, () => {
|
|
44
|
+
// Pick a random cluster, with some clusters being more popular
|
|
45
|
+
const centerIdx = Math.floor(Math.pow(Math.random(), 2) * numClusters);
|
|
46
|
+
const center = centers[centerIdx] as number[];
|
|
47
|
+
|
|
48
|
+
// Add noise, with some vectors being further from centers
|
|
49
|
+
const spread = Math.random() < 0.8 ? 0.1 : 0.5; // 80% close, 20% far
|
|
50
|
+
return center.map(c => c + (Math.random() * spread - spread / 2));
|
|
51
|
+
});
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
// Or even more extreme:
|
|
55
|
+
export const generateSkewedVectors = (count: number, dim: number) => {
|
|
56
|
+
// Create dense clusters with sparse regions
|
|
57
|
+
const vectors: number[][] = [];
|
|
58
|
+
|
|
59
|
+
const denseCount = Math.floor(count * 0.6);
|
|
60
|
+
const sparseCount = count - denseCount;
|
|
61
|
+
|
|
62
|
+
// Dense cluster (60% of vectors)
|
|
63
|
+
const denseCenter = Array.from({ length: dim }, () => Math.random() * 0.2);
|
|
64
|
+
for (let i = 0; i < denseCount; i++) {
|
|
65
|
+
vectors.push(denseCenter.map(c => c + (Math.random() * 0.1 - 0.05)));
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Scattered vectors (40%)
|
|
69
|
+
for (let i = 0; i < sparseCount; i++) {
|
|
70
|
+
vectors.push(Array.from({ length: dim }, () => Math.random() * 2 - 1));
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
return vectors.sort(() => Math.random() - 0.5); // Shuffle
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
export const findNearestBruteForce = (query: number[], vectors: number[][], k: number) => {
|
|
77
|
+
const similarities = vectors.map((vector, idx) => {
|
|
78
|
+
const similarity = cosineSimilarity(query, vector);
|
|
79
|
+
return { idx, dist: similarity };
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
const sorted = similarities.sort((a, b) => b.dist - a.dist);
|
|
83
|
+
return sorted.slice(0, k).map(x => x.idx);
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
export const calculateRecall = (actual: number[], expected: number[], k: number): number => {
|
|
87
|
+
let score = 0;
|
|
88
|
+
for (let i = 0; i < k; i++) {
|
|
89
|
+
if (actual[i] === expected[i]) {
|
|
90
|
+
score += 1;
|
|
91
|
+
} else if (expected.includes(actual[i] ?? 0)) {
|
|
92
|
+
score += 0.5;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
return score / k;
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
export function cosineSimilarity(a: number[], b: number[]): number {
|
|
99
|
+
const dotProduct = a.reduce((sum, val, i) => sum + (val ?? 0) * (b[i] ?? 0), 0);
|
|
100
|
+
const normA = Math.sqrt(a.reduce((sum, val) => sum + val * val, 0));
|
|
101
|
+
const normB = Math.sqrt(b.reduce((sum, val) => sum + val * val, 0));
|
|
102
|
+
return dotProduct / (normA * normB);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export const formatTable = (data: any[], columns: string[]) => {
|
|
106
|
+
const colWidths = columns.map(col =>
|
|
107
|
+
Math.max(
|
|
108
|
+
col.length,
|
|
109
|
+
...data.map(row => {
|
|
110
|
+
const value = row[col];
|
|
111
|
+
return value === undefined || value === null ? '-'.length : value.toString().length;
|
|
112
|
+
}),
|
|
113
|
+
),
|
|
114
|
+
);
|
|
115
|
+
|
|
116
|
+
const topBorder = '┌' + colWidths.map(w => '─'.repeat(w)).join('┬') + '┐';
|
|
117
|
+
const headerSeparator = '├' + colWidths.map(w => '─'.repeat(w)).join('┼') + '┤';
|
|
118
|
+
const bottomBorder = '└' + colWidths.map(w => '─'.repeat(w)).join('┴') + '┘';
|
|
119
|
+
|
|
120
|
+
const header = '│' + columns.map((col, i) => col.padEnd(colWidths[i] ?? 0)).join('│') + '│';
|
|
121
|
+
const rows = data.map(
|
|
122
|
+
row =>
|
|
123
|
+
'│' +
|
|
124
|
+
columns
|
|
125
|
+
.map((col, i) => {
|
|
126
|
+
const value = row[col];
|
|
127
|
+
const displayValue = value === undefined || value === null ? '-' : value.toString();
|
|
128
|
+
return displayValue.padEnd(colWidths[i]);
|
|
129
|
+
})
|
|
130
|
+
.join('│') +
|
|
131
|
+
'│',
|
|
132
|
+
);
|
|
133
|
+
|
|
134
|
+
return [topBorder, header, headerSeparator, ...rows, bottomBorder].join('\n');
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
export const groupBy = <T, K extends keyof T>(
|
|
138
|
+
array: T[],
|
|
139
|
+
key: K | ((item: T) => string),
|
|
140
|
+
reducer?: (group: T[]) => any,
|
|
141
|
+
): Record<string, any> => {
|
|
142
|
+
const grouped = array.reduce(
|
|
143
|
+
(acc, item) => {
|
|
144
|
+
const value = typeof key === 'function' ? key(item) : item[key];
|
|
145
|
+
if (!acc[value as any]) acc[value as any] = [];
|
|
146
|
+
acc[value as any]?.push(item);
|
|
147
|
+
return acc;
|
|
148
|
+
},
|
|
149
|
+
{} as Record<string, T[]>,
|
|
150
|
+
);
|
|
151
|
+
|
|
152
|
+
if (reducer) {
|
|
153
|
+
return Object.fromEntries(Object.entries(grouped).map(([key, group]) => [key, reducer(group)]));
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
return grouped;
|
|
157
|
+
};
|
|
158
|
+
|
|
159
|
+
export const calculateTimeout = (dimension: number, size: number, k: number) => {
|
|
160
|
+
let timeout = 600000;
|
|
161
|
+
if (dimension >= 1024) timeout *= 3;
|
|
162
|
+
else if (dimension >= 384) timeout *= 1.5;
|
|
163
|
+
if (size >= 10000) timeout *= 2;
|
|
164
|
+
if (k >= 75) timeout *= 1.5;
|
|
165
|
+
return timeout * 5;
|
|
166
|
+
};
|
|
167
|
+
|
|
168
|
+
export const baseTestConfigs = {
|
|
169
|
+
smokeTests: [{ dimension: 384, size: 1_000, k: 10, queryCount: 10 }],
|
|
170
|
+
'64': [
|
|
171
|
+
{ dimension: 64, size: 100, k: 10, queryCount: 30 },
|
|
172
|
+
{ dimension: 64, size: 100, k: 25, queryCount: 30 },
|
|
173
|
+
{ dimension: 64, size: 100, k: 50, queryCount: 30 },
|
|
174
|
+
{ dimension: 64, size: 100, k: 100, queryCount: 30 },
|
|
175
|
+
{ dimension: 64, size: 1_000, k: 10, queryCount: 30 },
|
|
176
|
+
{ dimension: 64, size: 1_000, k: 25, queryCount: 30 },
|
|
177
|
+
{ dimension: 64, size: 1_000, k: 50, queryCount: 30 },
|
|
178
|
+
{ dimension: 64, size: 1_000, k: 100, queryCount: 30 },
|
|
179
|
+
{ dimension: 64, size: 10_000, k: 10, queryCount: 30 },
|
|
180
|
+
{ dimension: 64, size: 100_000, k: 10, queryCount: 30 },
|
|
181
|
+
{ dimension: 64, size: 100_000, k: 25, queryCount: 30 },
|
|
182
|
+
{ dimension: 64, size: 100_000, k: 50, queryCount: 30 },
|
|
183
|
+
{ dimension: 64, size: 100_000, k: 100, queryCount: 30 },
|
|
184
|
+
{ dimension: 64, size: 500_000, k: 10, queryCount: 30 },
|
|
185
|
+
{ dimension: 64, size: 1_000_000, k: 10, queryCount: 30 },
|
|
186
|
+
],
|
|
187
|
+
'384': [
|
|
188
|
+
{ dimension: 384, size: 100, k: 10, queryCount: 30 },
|
|
189
|
+
{ dimension: 384, size: 100, k: 25, queryCount: 30 },
|
|
190
|
+
{ dimension: 384, size: 100, k: 50, queryCount: 30 },
|
|
191
|
+
{ dimension: 384, size: 100, k: 100, queryCount: 30 },
|
|
192
|
+
{ dimension: 384, size: 1_000, k: 10, queryCount: 30 },
|
|
193
|
+
{ dimension: 384, size: 1_000, k: 25, queryCount: 30 },
|
|
194
|
+
{ dimension: 384, size: 1_000, k: 50, queryCount: 30 },
|
|
195
|
+
{ dimension: 384, size: 1_000, k: 100, queryCount: 30 },
|
|
196
|
+
{ dimension: 384, size: 10_000, k: 10, queryCount: 30 },
|
|
197
|
+
{ dimension: 384, size: 100_000, k: 10, queryCount: 30 },
|
|
198
|
+
{ dimension: 384, size: 100_000, k: 25, queryCount: 30 },
|
|
199
|
+
{ dimension: 384, size: 100_000, k: 50, queryCount: 30 },
|
|
200
|
+
{ dimension: 384, size: 100_000, k: 100, queryCount: 30 },
|
|
201
|
+
{ dimension: 384, size: 500_000, k: 10, queryCount: 30 },
|
|
202
|
+
],
|
|
203
|
+
'1024': [
|
|
204
|
+
{ dimension: 1024, size: 100, k: 10, queryCount: 30 },
|
|
205
|
+
{ dimension: 1024, size: 100, k: 25, queryCount: 30 },
|
|
206
|
+
{ dimension: 1024, size: 100, k: 50, queryCount: 30 },
|
|
207
|
+
{ dimension: 1024, size: 100, k: 100, queryCount: 30 },
|
|
208
|
+
{ dimension: 1024, size: 1_000, k: 10, queryCount: 30 },
|
|
209
|
+
{ dimension: 1024, size: 1_000, k: 25, queryCount: 30 },
|
|
210
|
+
{ dimension: 1024, size: 1_000, k: 50, queryCount: 30 },
|
|
211
|
+
{ dimension: 1024, size: 1_000, k: 100, queryCount: 30 },
|
|
212
|
+
{ dimension: 1024, size: 10_000, k: 10, queryCount: 30 },
|
|
213
|
+
{ dimension: 1024, size: 10_000, k: 25, queryCount: 30 },
|
|
214
|
+
{ dimension: 1024, size: 10_000, k: 50, queryCount: 30 },
|
|
215
|
+
{ dimension: 1024, size: 10_000, k: 100, queryCount: 30 },
|
|
216
|
+
{ dimension: 1024, size: 50_000, k: 10, queryCount: 30 },
|
|
217
|
+
{ dimension: 1024, size: 50_000, k: 25, queryCount: 30 },
|
|
218
|
+
],
|
|
219
|
+
stressTests: [
|
|
220
|
+
// Maximum load
|
|
221
|
+
{ dimension: 512, size: 1_000_000, k: 50, queryCount: 5 },
|
|
222
|
+
|
|
223
|
+
// Dense search
|
|
224
|
+
{ dimension: 256, size: 1_000_000, k: 100, queryCount: 5 },
|
|
225
|
+
|
|
226
|
+
{ dimension: 1024, size: 500_000, k: 50, queryCount: 5 },
|
|
227
|
+
],
|
|
228
|
+
};
|
|
229
|
+
|
|
230
|
+
export interface TestConfig {
|
|
231
|
+
dimension: number;
|
|
232
|
+
size: number;
|
|
233
|
+
k: number;
|
|
234
|
+
queryCount: number;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
export async function warmupQuery(vectorDB: PgVector, indexName: string, dimension: number, k: number) {
|
|
238
|
+
const warmupVector = generateRandomVectors(1, dimension)[0] as number[];
|
|
239
|
+
await vectorDB.query(indexName, warmupVector, k);
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
export async function measureLatency<T>(fn: () => Promise<T>): Promise<[number, T]> {
|
|
243
|
+
const start = performance.now();
|
|
244
|
+
const result = await fn();
|
|
245
|
+
const end = performance.now();
|
|
246
|
+
return [end - start, result];
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
export const getListCount = (indexConfig: IndexConfig, size: number): number | undefined => {
|
|
250
|
+
if (indexConfig.type !== 'ivfflat') return undefined;
|
|
251
|
+
if (indexConfig.ivf?.lists) return indexConfig.ivf.lists;
|
|
252
|
+
return Math.max(100, Math.min(4000, Math.floor(Math.sqrt(size) * 2)));
|
|
253
|
+
};
|
|
254
|
+
|
|
255
|
+
export const getHNSWConfig = (indexConfig: IndexConfig): { m: number; efConstruction: number } => {
|
|
256
|
+
return {
|
|
257
|
+
m: indexConfig.hnsw?.m ?? 8,
|
|
258
|
+
efConstruction: indexConfig.hnsw?.efConstruction ?? 32,
|
|
259
|
+
};
|
|
260
|
+
};
|
|
261
|
+
|
|
262
|
+
export function getSearchEf(k: number, m: number) {
|
|
263
|
+
return {
|
|
264
|
+
default: Math.max(k, m * k), // Default calculation
|
|
265
|
+
lower: Math.max(k, (m * k) / 2), // Lower quality, faster
|
|
266
|
+
higher: Math.max(k, m * k * 2), // Higher quality, slower
|
|
267
|
+
};
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
export function getIndexDescription({
|
|
271
|
+
type,
|
|
272
|
+
hnsw,
|
|
273
|
+
}: {
|
|
274
|
+
type: IndexType;
|
|
275
|
+
hnsw: { m: number; efConstruction: number };
|
|
276
|
+
}): string {
|
|
277
|
+
if (type === 'hnsw') {
|
|
278
|
+
return `HNSW(m=${hnsw.m},ef=${hnsw.efConstruction})`;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
if (type === 'ivfflat') {
|
|
282
|
+
return `IVF`;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
return 'Flat';
|
|
286
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
export type IndexType = 'ivfflat' | 'hnsw' | 'flat';
|
|
2
|
+
|
|
3
|
+
interface IVFConfig {
|
|
4
|
+
lists?: number;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
interface HNSWConfig {
|
|
8
|
+
m?: number; // Max number of connections (default: 16)
|
|
9
|
+
efConstruction?: number; // Build-time complexity (default: 64)
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface IndexConfig {
|
|
13
|
+
type?: IndexType;
|
|
14
|
+
ivf?: IVFConfig;
|
|
15
|
+
hnsw?: HNSWConfig;
|
|
16
|
+
}
|
|
@@ -0,0 +1,371 @@
|
|
|
1
|
+
import pg from 'pg';
|
|
2
|
+
import { describe, it, beforeAll, afterAll, beforeEach, afterEach } from 'vitest';
|
|
3
|
+
|
|
4
|
+
import {
|
|
5
|
+
baseTestConfigs,
|
|
6
|
+
TestConfig,
|
|
7
|
+
TestResult,
|
|
8
|
+
calculateTimeout,
|
|
9
|
+
generateRandomVectors,
|
|
10
|
+
findNearestBruteForce,
|
|
11
|
+
calculateRecall,
|
|
12
|
+
formatTable,
|
|
13
|
+
groupBy,
|
|
14
|
+
measureLatency,
|
|
15
|
+
getListCount,
|
|
16
|
+
getSearchEf,
|
|
17
|
+
generateClusteredVectors,
|
|
18
|
+
generateSkewedVectors,
|
|
19
|
+
getHNSWConfig,
|
|
20
|
+
getIndexDescription,
|
|
21
|
+
} from './performance.helpers';
|
|
22
|
+
import { IndexConfig, IndexType } from './types';
|
|
23
|
+
|
|
24
|
+
import { PgVector } from '.';
|
|
25
|
+
|
|
26
|
+
interface IndexTestConfig extends IndexConfig {
|
|
27
|
+
type: IndexType;
|
|
28
|
+
rebuild?: boolean;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
class PGPerformanceVector extends PgVector {
|
|
32
|
+
private perfPool: pg.Pool;
|
|
33
|
+
|
|
34
|
+
constructor(connectionString: string) {
|
|
35
|
+
super(connectionString);
|
|
36
|
+
|
|
37
|
+
const basePool = new pg.Pool({
|
|
38
|
+
connectionString,
|
|
39
|
+
max: 20, // Maximum number of clients in the pool
|
|
40
|
+
idleTimeoutMillis: 30000, // Close idle connections after 30 seconds
|
|
41
|
+
connectionTimeoutMillis: 2000, // Fail fast if can't connect
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
this.perfPool = basePool;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
async bulkUpsert(indexName: string, vectors: number[][], metadata?: any[], ids?: string[]) {
|
|
48
|
+
const client = await this.perfPool.connect();
|
|
49
|
+
try {
|
|
50
|
+
await client.query('BEGIN');
|
|
51
|
+
const vectorIds = ids || vectors.map(() => crypto.randomUUID());
|
|
52
|
+
|
|
53
|
+
// Same query structure as upsert, just using unnest for bulk operation
|
|
54
|
+
const query = `
|
|
55
|
+
INSERT INTO ${indexName} (vector_id, embedding, metadata)
|
|
56
|
+
SELECT * FROM unnest(
|
|
57
|
+
$1::text[],
|
|
58
|
+
$2::vector[],
|
|
59
|
+
$3::jsonb[]
|
|
60
|
+
)
|
|
61
|
+
ON CONFLICT (vector_id)
|
|
62
|
+
DO UPDATE SET
|
|
63
|
+
embedding = EXCLUDED.embedding,
|
|
64
|
+
metadata = EXCLUDED.metadata
|
|
65
|
+
RETURNING embedding::text
|
|
66
|
+
`;
|
|
67
|
+
|
|
68
|
+
// Same parameter structure as upsert, just as arrays
|
|
69
|
+
await client.query(query, [
|
|
70
|
+
vectorIds,
|
|
71
|
+
vectors.map(v => `[${v.join(',')}]`),
|
|
72
|
+
(metadata || vectors.map(() => ({}))).map(m => JSON.stringify(m)),
|
|
73
|
+
]);
|
|
74
|
+
await client.query('COMMIT');
|
|
75
|
+
return vectorIds;
|
|
76
|
+
} catch (error) {
|
|
77
|
+
await client.query('ROLLBACK');
|
|
78
|
+
throw error;
|
|
79
|
+
} finally {
|
|
80
|
+
client.release();
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const warmupCache = new Map<string, boolean>();
|
|
86
|
+
async function smartWarmup(
|
|
87
|
+
vectorDB: PGPerformanceVector,
|
|
88
|
+
testIndexName: string,
|
|
89
|
+
indexType: string,
|
|
90
|
+
dimension: number,
|
|
91
|
+
k: number,
|
|
92
|
+
) {
|
|
93
|
+
const cacheKey = `${dimension}-${k}-${indexType}`;
|
|
94
|
+
if (!warmupCache.has(cacheKey)) {
|
|
95
|
+
console.log(`Warming up ${indexType} index for ${dimension}d vectors, k=${k}`);
|
|
96
|
+
const warmupVector = generateRandomVectors(1, dimension)[0] as number[];
|
|
97
|
+
await vectorDB.query(testIndexName, warmupVector, k);
|
|
98
|
+
warmupCache.set(cacheKey, true);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const connectionString = process.env.DB_URL || `postgresql://postgres:postgres@localhost:5435/mastra`;
|
|
103
|
+
describe('PostgreSQL Index Performance', () => {
|
|
104
|
+
let vectorDB: PGPerformanceVector;
|
|
105
|
+
const testIndexName = 'test_index_performance';
|
|
106
|
+
const results: TestResult[] = [];
|
|
107
|
+
|
|
108
|
+
const indexConfigs: IndexTestConfig[] = [
|
|
109
|
+
{ type: 'flat' }, // Test flat/linear search as baseline
|
|
110
|
+
{ type: 'ivfflat', ivf: { lists: 100 } }, // Test IVF with fixed lists
|
|
111
|
+
{ type: 'ivfflat', rebuild: true }, // Test IVF with calculated lists and rebuild
|
|
112
|
+
{ type: 'hnsw' }, // Test HNSW with default parameters
|
|
113
|
+
{ type: 'hnsw', hnsw: { m: 16, efConstruction: 64 } }, // Test HNSW with custom parameters
|
|
114
|
+
];
|
|
115
|
+
beforeAll(async () => {
|
|
116
|
+
// Initialize PGPerformanceVector
|
|
117
|
+
vectorDB = new PGPerformanceVector(connectionString);
|
|
118
|
+
});
|
|
119
|
+
beforeEach(async () => {
|
|
120
|
+
await vectorDB.deleteIndex(testIndexName);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
afterEach(async () => {
|
|
124
|
+
await vectorDB.deleteIndex(testIndexName);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
afterAll(async () => {
|
|
128
|
+
await vectorDB.disconnect();
|
|
129
|
+
analyzeResults(results);
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
// Combine all test configs
|
|
133
|
+
const allConfigs: TestConfig[] = [
|
|
134
|
+
...baseTestConfigs['64'],
|
|
135
|
+
...baseTestConfigs['384'],
|
|
136
|
+
...baseTestConfigs['1024'],
|
|
137
|
+
...baseTestConfigs.smokeTests,
|
|
138
|
+
...baseTestConfigs.stressTests,
|
|
139
|
+
];
|
|
140
|
+
|
|
141
|
+
// For each index config
|
|
142
|
+
for (const indexConfig of indexConfigs) {
|
|
143
|
+
const indexType = indexConfig.type;
|
|
144
|
+
const rebuild = indexConfig.rebuild ?? false;
|
|
145
|
+
const hnswConfig = getHNSWConfig(indexConfig);
|
|
146
|
+
const indexDescription = getIndexDescription({
|
|
147
|
+
type: indexType,
|
|
148
|
+
hnsw: hnswConfig,
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
describe(`Index: ${indexDescription}`, () => {
|
|
152
|
+
for (const testConfig of allConfigs) {
|
|
153
|
+
const timeout = calculateTimeout(testConfig.dimension, testConfig.size, testConfig.k);
|
|
154
|
+
const testDesc = `dim=${testConfig.dimension} size=${testConfig.size} k=${testConfig.k}`;
|
|
155
|
+
|
|
156
|
+
for (const [distType, generator] of Object.entries(distributions)) {
|
|
157
|
+
it(
|
|
158
|
+
testDesc,
|
|
159
|
+
async () => {
|
|
160
|
+
const testVectors = generator(testConfig.size, testConfig.dimension);
|
|
161
|
+
const queryVectors = generator(testConfig.queryCount, testConfig.dimension);
|
|
162
|
+
|
|
163
|
+
// Create index and insert vectors
|
|
164
|
+
const lists = getListCount(indexConfig, testConfig.size);
|
|
165
|
+
|
|
166
|
+
await vectorDB.createIndex(
|
|
167
|
+
testIndexName,
|
|
168
|
+
testConfig.dimension,
|
|
169
|
+
'cosine',
|
|
170
|
+
indexConfig,
|
|
171
|
+
indexType === 'ivfflat',
|
|
172
|
+
);
|
|
173
|
+
|
|
174
|
+
console.log(
|
|
175
|
+
`Batched bulk upserting ${testVectors.length} ${distType} vectors into index ${testIndexName}`,
|
|
176
|
+
);
|
|
177
|
+
const batchSizes = splitIntoRandomBatches(testConfig.size, testConfig.dimension);
|
|
178
|
+
await batchedBulkUpsert(vectorDB, testIndexName, testVectors, batchSizes);
|
|
179
|
+
if (indexType === 'hnsw' || rebuild) {
|
|
180
|
+
console.log('rebuilding index');
|
|
181
|
+
await vectorDB.defineIndex(testIndexName, 'cosine', indexConfig);
|
|
182
|
+
console.log('index rebuilt');
|
|
183
|
+
}
|
|
184
|
+
await smartWarmup(vectorDB, testIndexName, indexType, testConfig.dimension, testConfig.k);
|
|
185
|
+
|
|
186
|
+
// For HNSW, test different EF values
|
|
187
|
+
const efValues = indexType === 'hnsw' ? getSearchEf(testConfig.k, hnswConfig.m) : { default: undefined };
|
|
188
|
+
|
|
189
|
+
for (const [efType, ef] of Object.entries(efValues)) {
|
|
190
|
+
const recalls: number[] = [];
|
|
191
|
+
const latencies: number[] = [];
|
|
192
|
+
|
|
193
|
+
for (const queryVector of queryVectors) {
|
|
194
|
+
const expectedNeighbors = findNearestBruteForce(queryVector, testVectors, testConfig.k);
|
|
195
|
+
|
|
196
|
+
const [latency, actualResults] = await measureLatency(async () =>
|
|
197
|
+
vectorDB.query(
|
|
198
|
+
testIndexName,
|
|
199
|
+
queryVector,
|
|
200
|
+
testConfig.k,
|
|
201
|
+
undefined,
|
|
202
|
+
false,
|
|
203
|
+
0,
|
|
204
|
+
{ ef }, // For HNSW
|
|
205
|
+
),
|
|
206
|
+
);
|
|
207
|
+
|
|
208
|
+
const actualNeighbors = actualResults.map(r => r.metadata?.index);
|
|
209
|
+
const recall = calculateRecall(actualNeighbors, expectedNeighbors, testConfig.k);
|
|
210
|
+
recalls.push(recall);
|
|
211
|
+
latencies.push(latency);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
const sorted = [...latencies].sort((a, b) => a - b);
|
|
215
|
+
results.push({
|
|
216
|
+
distribution: distType,
|
|
217
|
+
dimension: testConfig.dimension,
|
|
218
|
+
size: testConfig.size,
|
|
219
|
+
k: testConfig.k,
|
|
220
|
+
type: indexType,
|
|
221
|
+
metrics: {
|
|
222
|
+
recall: recalls.length > 0 ? recalls.reduce((a, b) => a + b, 0) / recalls.length : 0,
|
|
223
|
+
minRecall: Math.min(...recalls),
|
|
224
|
+
maxRecall: Math.max(...recalls),
|
|
225
|
+
latency: {
|
|
226
|
+
p50: sorted[Math.floor(sorted.length * 0.5)],
|
|
227
|
+
p95: sorted[Math.floor(sorted.length * 0.95)],
|
|
228
|
+
...(indexType === 'ivfflat' && {
|
|
229
|
+
lists,
|
|
230
|
+
vectorsPerList: Math.round(testConfig.size / (lists || 1)),
|
|
231
|
+
}),
|
|
232
|
+
...(indexType === 'hnsw' && {
|
|
233
|
+
m: hnswConfig.m,
|
|
234
|
+
efConstruction: hnswConfig.efConstruction,
|
|
235
|
+
ef,
|
|
236
|
+
efType,
|
|
237
|
+
}),
|
|
238
|
+
},
|
|
239
|
+
...(indexType === 'ivfflat' && {
|
|
240
|
+
clustering: {
|
|
241
|
+
numLists: lists,
|
|
242
|
+
avgVectorsPerList: testConfig.size / (lists || 1),
|
|
243
|
+
recommendedLists: Math.floor(Math.sqrt(testConfig.size)),
|
|
244
|
+
distribution: distType,
|
|
245
|
+
},
|
|
246
|
+
}),
|
|
247
|
+
},
|
|
248
|
+
});
|
|
249
|
+
}
|
|
250
|
+
},
|
|
251
|
+
timeout,
|
|
252
|
+
);
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
});
|
|
256
|
+
}
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
function analyzeResults(results: TestResult[]) {
|
|
260
|
+
const byType = groupBy(results, (r: TestResult) => r.type);
|
|
261
|
+
Object.entries(byType).forEach(([type, typeResults]) => {
|
|
262
|
+
console.log(`\n=== ${type.toUpperCase()} Index Analysis ===\n`);
|
|
263
|
+
|
|
264
|
+
const byDimension = groupBy(typeResults, (r: TestResult) => r.dimension.toString());
|
|
265
|
+
Object.entries(byDimension).forEach(([dim, dimResults]) => {
|
|
266
|
+
console.log(`\n--- Analysis for ${dim} dimensions ---\n`);
|
|
267
|
+
|
|
268
|
+
// Combined Performance Analysis
|
|
269
|
+
const columns = ['Distribution', 'Dataset Size', 'K'];
|
|
270
|
+
if (type === 'hnsw') {
|
|
271
|
+
columns.push('M', 'EF Construction', 'EF', 'EF Type');
|
|
272
|
+
} else if (type === 'ivfflat') {
|
|
273
|
+
columns.push('Lists', 'Vectors/List');
|
|
274
|
+
}
|
|
275
|
+
columns.push('Min Recall', 'Avg Recall', 'Max Recall', 'P50 (ms)', 'P95 (ms)');
|
|
276
|
+
|
|
277
|
+
const performanceData = Object.values(
|
|
278
|
+
groupBy(
|
|
279
|
+
dimResults,
|
|
280
|
+
(r: any) => `${r.size}-${r.k}-${type === 'ivfflat' ? r.metrics.latency.lists : r.metrics.latency.m}`,
|
|
281
|
+
(results: any[]) => {
|
|
282
|
+
const sortedResults = [...results].sort(
|
|
283
|
+
(a, b) =>
|
|
284
|
+
['random', 'clustered', 'skewed', 'mixed'].indexOf(a.distribution) -
|
|
285
|
+
['random', 'clustered', 'skewed', 'mixed'].indexOf(b.distribution),
|
|
286
|
+
);
|
|
287
|
+
return sortedResults.map(result => ({
|
|
288
|
+
Distribution: result.distribution,
|
|
289
|
+
'Dataset Size': result.size,
|
|
290
|
+
K: result.k,
|
|
291
|
+
...(type === 'ivfflat'
|
|
292
|
+
? {
|
|
293
|
+
Lists: result.metrics.latency.lists,
|
|
294
|
+
'Vectors/List': result.metrics.latency.vectorsPerList,
|
|
295
|
+
}
|
|
296
|
+
: {}),
|
|
297
|
+
...(type === 'hnsw'
|
|
298
|
+
? {
|
|
299
|
+
M: result.metrics.latency.m,
|
|
300
|
+
'EF Construction': result.metrics.latency.efConstruction,
|
|
301
|
+
EF: result.metrics.latency.ef,
|
|
302
|
+
'EF Type': result.metrics.latency.efType,
|
|
303
|
+
}
|
|
304
|
+
: {}),
|
|
305
|
+
'Min Recall': result.metrics.minRecall.toFixed(3),
|
|
306
|
+
'Avg Recall': result.metrics.recall.toFixed(3),
|
|
307
|
+
'Max Recall': result.metrics.maxRecall.toFixed(3),
|
|
308
|
+
'P50 (ms)': result.metrics.latency.p50.toFixed(2),
|
|
309
|
+
'P95 (ms)': result.metrics.latency.p95.toFixed(2),
|
|
310
|
+
}));
|
|
311
|
+
},
|
|
312
|
+
),
|
|
313
|
+
).flat();
|
|
314
|
+
|
|
315
|
+
console.log(formatTable(performanceData, columns));
|
|
316
|
+
});
|
|
317
|
+
});
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
function splitIntoRandomBatches(total: number, dimension: number): number[] {
|
|
321
|
+
const batches: number[] = [];
|
|
322
|
+
let remaining = total;
|
|
323
|
+
|
|
324
|
+
const batchRange = dimension === 1024 ? 5000 : 15000;
|
|
325
|
+
|
|
326
|
+
while (remaining > 0) {
|
|
327
|
+
const batchSize = Math.min(remaining, batchRange + Math.floor(Math.random() * batchRange));
|
|
328
|
+
batches.push(batchSize);
|
|
329
|
+
remaining -= batchSize;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
return batches;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
async function batchedBulkUpsert(
|
|
336
|
+
vectorDB: PGPerformanceVector,
|
|
337
|
+
testIndexName: string,
|
|
338
|
+
vectors: number[][],
|
|
339
|
+
batchSizes: number[],
|
|
340
|
+
) {
|
|
341
|
+
let offset = 0;
|
|
342
|
+
const vectorIds = vectors.map((_, idx) => `vec_${idx}`);
|
|
343
|
+
const metadata = vectors.map((_, idx) => ({ index: idx }));
|
|
344
|
+
|
|
345
|
+
for (const size of batchSizes) {
|
|
346
|
+
const batch = vectors.slice(offset, offset + size);
|
|
347
|
+
const batchIds = vectorIds.slice(offset, offset + size);
|
|
348
|
+
const batchMetadata = metadata.slice(offset, offset + size);
|
|
349
|
+
await vectorDB.bulkUpsert(testIndexName, batch, batchMetadata, batchIds);
|
|
350
|
+
offset += size;
|
|
351
|
+
console.log(`${offset} of ${vectors.length} vectors upserted`);
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
const distributions = {
|
|
356
|
+
random: generateRandomVectors,
|
|
357
|
+
clustered: generateClusteredVectors,
|
|
358
|
+
skewed: generateSkewedVectors,
|
|
359
|
+
mixed: (size: number, dimension: number) => {
|
|
360
|
+
const generators = [generateRandomVectors, generateClusteredVectors, generateSkewedVectors];
|
|
361
|
+
const batchSizes = splitIntoRandomBatches(size, dimension);
|
|
362
|
+
|
|
363
|
+
let vectors: number[][] = [];
|
|
364
|
+
for (const batchSize of batchSizes) {
|
|
365
|
+
const generator = generators[Math.floor(Math.random() * generators.length)];
|
|
366
|
+
vectors = vectors.concat(generator(batchSize, dimension));
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
return vectors;
|
|
370
|
+
},
|
|
371
|
+
};
|
package/vitest.config.ts
CHANGED