Skip to content

Commit b961f3d

Browse files
jonathanMLDevzho
andauthored
added benchmarks (#79)
* added benchmarks * removed baseline.json * addressed a ai review --------- Co-authored-by: zho <jornathanm910923@gmail.com>
1 parent d2723d7 commit b961f3d

3 files changed

Lines changed: 336 additions & 0 deletions

File tree

README.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -542,6 +542,16 @@ npm run build
542542
npm test
543543
```
544544

545+
### Benchmarks
546+
547+
Measure server-side processing overhead with mocked Pinecone responses (no live API calls, no API key required):
548+
549+
```bash
550+
npm run benchmark
551+
```
552+
553+
The script prints a table of p50, p95, and p99 latencies in milliseconds and writes results to [`benchmarks/baseline.json`](benchmarks/baseline.json). Compare a new run to the committed baseline (for example with `git diff benchmarks/baseline.json` after re-running the command) to spot regressions.
554+
545555
### Testing the keyword_search tool
546556

547557
1. **Connectivity and keyword search (script):**

benchmarks/latency.ts

Lines changed: 325 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,325 @@
1+
#!/usr/bin/env tsx
2+
/**
3+
* Local benchmark harness: mocked Pinecone I/O, measures server-side latency (p50/p95/p99).
4+
*
5+
* Usage: npm run benchmark
6+
*/
7+
8+
import { writeFileSync } from 'node:fs';
9+
import { dirname, join } from 'node:path';
10+
import { fileURLToPath } from 'node:url';
11+
import { performance } from 'node:perf_hooks';
12+
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
13+
import { PineconeClient } from '../src/pinecone-client.js';
14+
import { setLogLevel } from '../src/logger.js';
15+
import { setPineconeClient } from '../src/server/client-context.js';
16+
import { invalidateNamespacesCache, getNamespacesWithCache } from '../src/server/namespaces-cache.js';
17+
import { registerGuidedQueryTool } from '../src/server/tools/guided-query-tool.js';
18+
import type { MergedHit, PineconeHit, SearchResult, SearchableIndex } from '../src/types.js';
19+
20+
const WARMUP = 10;
21+
const ITERATIONS = 200;
22+
const TOP_K = 20;
23+
24+
type BenchmarkResult = {
25+
name: string;
26+
p50: number;
27+
p95: number;
28+
p99: number;
29+
min: number;
30+
max: number;
31+
iterations: number;
32+
};
33+
34+
/** Test double: stub ensureIndexes, searchIndex, rerankResults (no network). */
35+
type PineconeClientBenchDouble = PineconeClient & {
36+
ensureIndexes: () => Promise<{ denseIndex: SearchableIndex; sparseIndex: SearchableIndex }>;
37+
searchIndex: (
38+
_index: SearchableIndex,
39+
_query: string,
40+
_topK: number,
41+
_namespace?: string,
42+
_metadataFilter?: Record<string, unknown>,
43+
_options?: { fields?: string[] }
44+
) => Promise<PineconeHit[]>;
45+
rerankResults: (_q: string, results: MergedHit[], topN: number) => Promise<SearchResult[]>;
46+
};
47+
48+
function syntheticHits(prefix: string, count: number, scoreBase: number): PineconeHit[] {
49+
const hits: PineconeHit[] = [];
50+
for (let i = 0; i < count; i++) {
51+
hits.push({
52+
_id: `${prefix}-${i}`,
53+
_score: scoreBase - i * 0.01,
54+
fields: {
55+
chunk_text: `Content ${prefix} ${i} lorem ipsum dolor sit amet.`,
56+
document_number: `DOC-${prefix}-${i}`,
57+
title: `Title ${i}`,
58+
url: `https://example.com/${prefix}/${i}`,
59+
author: 'bench',
60+
},
61+
});
62+
}
63+
return hits;
64+
}
65+
66+
function percentile(sorted: number[], p: number): number {
67+
if (sorted.length === 0) return 0;
68+
const idx = Math.ceil((p / 100) * sorted.length) - 1;
69+
return sorted[Math.max(0, idx)] ?? 0;
70+
}
71+
72+
async function runBenchmark(
73+
name: string,
74+
fn: () => Promise<void>,
75+
iterations = ITERATIONS
76+
): Promise<BenchmarkResult> {
77+
for (let w = 0; w < WARMUP; w++) {
78+
await fn();
79+
}
80+
const samples: number[] = [];
81+
let min = Number.POSITIVE_INFINITY;
82+
let max = Number.NEGATIVE_INFINITY;
83+
for (let i = 0; i < iterations; i++) {
84+
const t0 = performance.now();
85+
await fn();
86+
const t1 = performance.now();
87+
const ms = t1 - t0;
88+
samples.push(ms);
89+
min = Math.min(min, ms);
90+
max = Math.max(max, ms);
91+
}
92+
samples.sort((a, b) => a - b);
93+
const round4 = (n: number) => Math.round(n * 10000) / 10000;
94+
return {
95+
name,
96+
p50: round4(percentile(samples, 50)),
97+
p95: round4(percentile(samples, 95)),
98+
p99: round4(percentile(samples, 99)),
99+
min: round4(min),
100+
max: round4(max),
101+
iterations,
102+
};
103+
}
104+
105+
function formatTable(rows: BenchmarkResult[]): string {
106+
const headers = ['Scenario', 'p50 (ms)', 'p95 (ms)', 'p99 (ms)', 'min (ms)', 'max (ms)'];
107+
const colWidths = [28, 12, 12, 12, 12, 12];
108+
const line = (cells: string[]) =>
109+
cells.map((c, i) => c.padEnd(colWidths[i])).join(' | ');
110+
const out: string[] = [line(headers), line(colWidths.map((w) => '-'.repeat(w)))];
111+
for (const r of rows) {
112+
out.push(
113+
line([
114+
r.name.slice(0, colWidths[0] ?? 28),
115+
r.p50.toFixed(4),
116+
r.p95.toFixed(4),
117+
r.p99.toFixed(4),
118+
r.min.toFixed(4),
119+
r.max.toFixed(4),
120+
])
121+
);
122+
}
123+
return out.join('\n');
124+
}
125+
126+
function buildQueryBenchClient(): PineconeClientBenchDouble {
127+
const denseHits = syntheticHits('dense', TOP_K, 0.95);
128+
const sparseHits = syntheticHits('sparse', TOP_K, 0.9);
129+
const denseIndexRef = {} as SearchableIndex;
130+
const sparseIndexRef = {} as SearchableIndex;
131+
const client = new PineconeClient({
132+
apiKey: 'bench-key',
133+
indexName: 'bench-index',
134+
rerankModel: 'bench-rerank',
135+
}) as PineconeClientBenchDouble;
136+
137+
client.ensureIndexes = async () => ({
138+
denseIndex: denseIndexRef,
139+
sparseIndex: sparseIndexRef,
140+
});
141+
142+
client.searchIndex = async (index) => {
143+
if (index === denseIndexRef) return denseHits;
144+
if (index === sparseIndexRef) return sparseHits;
145+
return [];
146+
};
147+
148+
client.rerankResults = async (_q, results, topN) =>
149+
results.slice(0, topN).map((r, i) => ({
150+
id: r._id,
151+
content: r.chunk_text,
152+
score: 1 - i * 0.01,
153+
metadata: r.metadata,
154+
reranked: true,
155+
}));
156+
157+
return client;
158+
}
159+
160+
function captureGuidedQueryHandler(): (params: {
161+
user_query: string;
162+
namespace?: string;
163+
metadata_filter?: Record<string, unknown>;
164+
top_k: number;
165+
preferred_tool: 'auto' | 'count' | 'query_fast' | 'query_detailed';
166+
enrich_urls: boolean;
167+
}) => Promise<unknown> {
168+
const handlers = new Map<string, (params: unknown) => Promise<unknown>>();
169+
const mockServer = {
170+
registerTool: (
171+
name: string,
172+
_config: unknown,
173+
handler: (params: unknown) => Promise<unknown>
174+
) => {
175+
handlers.set(name, handler);
176+
},
177+
} as unknown as McpServer;
178+
registerGuidedQueryTool(mockServer);
179+
const h = handlers.get('guided_query');
180+
if (!h) {
181+
throw new Error('guided_query handler not registered');
182+
}
183+
return h as (params: {
184+
user_query: string;
185+
namespace?: string;
186+
metadata_filter?: Record<string, unknown>;
187+
top_k: number;
188+
preferred_tool: 'auto' | 'count' | 'query_fast' | 'query_detailed';
189+
enrich_urls: boolean;
190+
}) => Promise<unknown>;
191+
}
192+
193+
const benchNamespaceMetadata = {
194+
document_number: 'string',
195+
title: 'string',
196+
url: 'string',
197+
author: 'string',
198+
chunk_text: 'string',
199+
} as const;
200+
201+
function createBenchPineconeMock(): PineconeClient {
202+
const namespaces = [
203+
{
204+
namespace: 'docs',
205+
recordCount: 1000,
206+
metadata: { ...benchNamespaceMetadata },
207+
},
208+
];
209+
210+
const mockQueryResults: SearchResult[] = syntheticHits('mock', 10, 0.9).map((h) => ({
211+
id: h._id,
212+
content: String(h.fields['chunk_text'] ?? ''),
213+
score: h._score,
214+
metadata: {
215+
document_number: h.fields['document_number'],
216+
title: h.fields['title'],
217+
url: h.fields['url'],
218+
author: h.fields['author'],
219+
},
220+
reranked: false,
221+
}));
222+
223+
return {
224+
async query() {
225+
return mockQueryResults;
226+
},
227+
async count() {
228+
return { count: 42, truncated: false };
229+
},
230+
async listNamespacesWithMetadata() {
231+
return namespaces;
232+
},
233+
async listNamespacesFromKeywordIndex() {
234+
return namespaces.map((n) => ({ namespace: n.namespace, recordCount: n.recordCount }));
235+
},
236+
getSparseIndexName() {
237+
return 'bench-index-sparse';
238+
},
239+
async keywordSearch() {
240+
return mockQueryResults;
241+
},
242+
} as unknown as PineconeClient;
243+
}
244+
245+
async function main(): Promise<void> {
246+
setLogLevel('ERROR');
247+
const results: BenchmarkResult[] = [];
248+
249+
const queryClient = buildQueryBenchClient();
250+
results.push(
251+
await runBenchmark('query_no_rerank', async () => {
252+
await queryClient.query({
253+
query: 'benchmark hybrid query text',
254+
namespace: 'docs',
255+
topK: TOP_K,
256+
useReranking: false,
257+
});
258+
})
259+
);
260+
261+
results.push(
262+
await runBenchmark('query_with_rerank', async () => {
263+
await queryClient.query({
264+
query: 'benchmark hybrid query text',
265+
namespace: 'docs',
266+
topK: TOP_K,
267+
useReranking: true,
268+
});
269+
})
270+
);
271+
272+
setPineconeClient(createBenchPineconeMock());
273+
invalidateNamespacesCache();
274+
await getNamespacesWithCache();
275+
276+
const guidedHandler = captureGuidedQueryHandler();
277+
const guidedParams = {
278+
user_query: 'list papers about machine learning',
279+
top_k: TOP_K,
280+
preferred_tool: 'query_fast' as const,
281+
enrich_urls: false,
282+
};
283+
284+
results.push(
285+
await runBenchmark('guided_query_end_to_end', async () => {
286+
await guidedHandler(guidedParams);
287+
})
288+
);
289+
290+
results.push(
291+
await runBenchmark('list_namespaces_cache_miss', async () => {
292+
invalidateNamespacesCache();
293+
await getNamespacesWithCache();
294+
})
295+
);
296+
297+
results.push(
298+
await runBenchmark('list_namespaces_cache_hit', async () => {
299+
await getNamespacesWithCache();
300+
})
301+
);
302+
303+
const table = formatTable(results);
304+
console.log(table);
305+
console.log('');
306+
307+
const payload = {
308+
generated_at: new Date().toISOString(),
309+
node: process.version,
310+
warmup_iterations: WARMUP,
311+
measured_iterations: ITERATIONS,
312+
results,
313+
};
314+
315+
const __filename = fileURLToPath(import.meta.url);
316+
const __dirname = dirname(__filename);
317+
const baselinePath = join(__dirname, 'baseline.json');
318+
writeFileSync(baselinePath, `${JSON.stringify(payload, null, 2)}\n`, 'utf8');
319+
console.log(`Wrote ${baselinePath}`);
320+
}
321+
322+
main().catch((err) => {
323+
console.error(err);
324+
process.exit(1);
325+
});

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@
5454
"test": "vitest run",
5555
"test:coverage": "vitest run --coverage",
5656
"test:watch": "vitest",
57+
"benchmark": "tsx benchmarks/latency.ts",
5758
"test:search": "tsx scripts/test-search.ts",
5859
"lint": "eslint src/",
5960
"lint:fix": "eslint src/ --fix",

0 commit comments

Comments
 (0)