@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.
@@ -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
@@ -4,6 +4,7 @@ export default defineConfig({
4
4
  test: {
5
5
  environment: 'node',
6
6
  include: ['src/**/*.test.ts'],
7
+ exclude: ['src/**/*.performance.test.ts'],
7
8
  coverage: {
8
9
  reporter: ['text', 'json', 'html'],
9
10
  },
@@ -0,0 +1,8 @@
1
+ import { defineConfig } from 'vitest/config';
2
+
3
+ export default defineConfig({
4
+ test: {
5
+ environment: 'node',
6
+ include: ['src/**/*.performance.test.ts'],
7
+ },
8
+ });