Skip to content

Commit 4651682

Browse files
jonathanMLDevzho
andauthored
resolved lifecycle issues (cppalliance#97)
* resolved lifecycle issues * fixed type check errors * addressed ai review results --------- Co-authored-by: zho <jornathanm910923@gmail.com>
1 parent 4eadc6c commit 4651682

27 files changed

Lines changed: 634 additions & 128 deletions

benchmarks/latency.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -222,7 +222,11 @@ function createBenchPineconeMock(): PineconeClient {
222222

223223
return {
224224
async query() {
225-
return mockQueryResults;
225+
return {
226+
results: mockQueryResults,
227+
degraded: false,
228+
hybrid_leg_failed: null,
229+
};
226230
},
227231
async count() {
228232
return { count: 42, truncated: false };

src/config.ts

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,12 @@
66
* `process.env` directly anymore — they receive their slice of the config.
77
*/
88

9-
import { DEFAULT_INDEX_NAME, DEFAULT_RERANK_MODEL, FLOW_CACHE_TTL_MS } from './constants.js';
9+
import {
10+
DEFAULT_INDEX_NAME,
11+
DEFAULT_RERANK_MODEL,
12+
DEFAULT_TOP_K,
13+
FLOW_CACHE_TTL_MS,
14+
} from './constants.js';
1015

1116
/** Allowed log levels, in ascending severity. */
1217
export type LogLevel = 'DEBUG' | 'INFO' | 'WARN' | 'ERROR';
@@ -49,9 +54,6 @@ export interface ServerConfig {
4954
/** Default per-call timeout for Pinecone requests, in milliseconds. */
5055
export const DEFAULT_REQUEST_TIMEOUT_MS = 15_000;
5156

52-
/** Default top-k mirrors constants.DEFAULT_TOP_K but is duplicated here to avoid a cycle. */
53-
const DEFAULT_TOP_K = 10;
54-
5557
function asLogLevel(value: string | undefined, fallback: LogLevel): LogLevel {
5658
const allowed: LogLevel[] = ['DEBUG', 'INFO', 'WARN', 'ERROR'];
5759
return allowed.includes(value as LogLevel) ? (value as LogLevel) : fallback;

src/pinecone-client.test.ts

Lines changed: 104 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
1-
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
1+
import { describe, it, expect, beforeEach, vi } from 'vitest';
22
import { PineconeClient } from './pinecone-client.js';
3+
import { resolveConfig } from './config.js';
34
import type { SearchableIndex, PineconeHit } from './types.js';
45
import * as rerankModule from './pinecone/rerank.js';
56

@@ -31,26 +32,40 @@ describe('PineconeClient', () => {
3132
});
3233
});
3334

34-
afterEach(() => {
35-
delete process.env['PINECONE_INDEX_NAME'];
36-
delete process.env['PINECONE_RERANK_MODEL'];
37-
delete process.env['PINECONE_TOP_K'];
38-
});
39-
4035
describe('constructor', () => {
4136
it('should initialize with provided config', () => {
4237
expect(client).toBeDefined();
4338
});
4439

45-
it('should use environment variables as fallbacks', () => {
46-
process.env['PINECONE_INDEX_NAME'] = 'env-index';
47-
process.env['PINECONE_RERANK_MODEL'] = 'env-model';
48-
49-
const envClient = new PineconeClient({
50-
apiKey: 'test-api-key',
51-
});
52-
53-
expect(envClient).toBeDefined();
40+
it('honors resolveConfig overrides without PINECONE_* env on the client path', () => {
41+
const prevIndex = process.env['PINECONE_INDEX_NAME'];
42+
const prevModel = process.env['PINECONE_RERANK_MODEL'];
43+
const prevTopK = process.env['PINECONE_TOP_K'];
44+
delete process.env['PINECONE_INDEX_NAME'];
45+
delete process.env['PINECONE_RERANK_MODEL'];
46+
delete process.env['PINECONE_TOP_K'];
47+
try {
48+
const resolved = resolveConfig({
49+
apiKey: 'test-api-key',
50+
indexName: 'resolved-index',
51+
rerankModel: 'resolved-model',
52+
defaultTopK: 42,
53+
});
54+
const c = new PineconeClient({
55+
apiKey: resolved.apiKey,
56+
indexName: resolved.indexName,
57+
rerankModel: resolved.rerankModel,
58+
defaultTopK: resolved.defaultTopK,
59+
});
60+
expect(c.getSparseIndexName()).toBe('resolved-index-sparse');
61+
} finally {
62+
if (prevIndex !== undefined) process.env['PINECONE_INDEX_NAME'] = prevIndex;
63+
else delete process.env['PINECONE_INDEX_NAME'];
64+
if (prevModel !== undefined) process.env['PINECONE_RERANK_MODEL'] = prevModel;
65+
else delete process.env['PINECONE_RERANK_MODEL'];
66+
if (prevTopK !== undefined) process.env['PINECONE_TOP_K'] = prevTopK;
67+
else delete process.env['PINECONE_TOP_K'];
68+
}
5469
});
5570
});
5671

@@ -76,16 +91,16 @@ describe('PineconeClient', () => {
7691

7792
it('should continue hybrid search when one index fails', async () => {
7893
const testClient = stubPineconeClient(client);
94+
const denseRef = {} as SearchableIndex;
95+
const sparseRef = {} as SearchableIndex;
7996

8097
testClient.ensureIndexes = async () => ({
81-
denseIndex: {} as SearchableIndex,
82-
sparseIndex: {} as SearchableIndex,
98+
denseIndex: denseRef,
99+
sparseIndex: sparseRef,
83100
});
84101

85-
let searchCall = 0;
86-
testClient.searchIndex = async () => {
87-
searchCall += 1;
88-
if (searchCall === 1) {
102+
testClient.searchIndex = async (index) => {
103+
if (index === denseRef) {
89104
throw new Error('dense failure');
90105
}
91106
return [
@@ -97,16 +112,18 @@ describe('PineconeClient', () => {
97112
];
98113
};
99114

100-
const results = await client.query({
115+
const out = await client.query({
101116
query: 'hybrid search',
102117
namespace: 'test',
103118
topK: 5,
104119
useReranking: false,
105120
});
106121

107-
expect(results).toHaveLength(1);
108-
expect(results[0].content).toBe('hybrid content');
109-
expect(results[0].metadata.author).toBe('tester');
122+
expect(out.results).toHaveLength(1);
123+
expect(out.results[0]?.content).toBe('hybrid content');
124+
expect(out.results[0]?.metadata.author).toBe('tester');
125+
expect(out.hybrid_leg_failed).toBe('dense');
126+
expect(out.degraded).toBe(false);
110127
});
111128

112129
it('should throw when both dense and sparse searches fail', async () => {
@@ -249,15 +266,18 @@ describe('PineconeClient', () => {
249266
});
250267

251268
it('uses rerankResults from pinecone/rerank when useReranking is true', async () => {
252-
const spy = vi.spyOn(rerankModule, 'rerankResults').mockResolvedValue([
253-
{
254-
id: 'd1',
255-
content: 'from dense',
256-
score: 0.9,
257-
metadata: {},
258-
reranked: true,
259-
},
260-
]);
269+
const spy = vi.spyOn(rerankModule, 'rerankResults').mockResolvedValue({
270+
results: [
271+
{
272+
id: 'd1',
273+
content: 'from dense',
274+
score: 0.9,
275+
metadata: {},
276+
reranked: true,
277+
},
278+
],
279+
degraded: false,
280+
});
261281
try {
262282
const testClient = stubPineconeClient(client);
263283
const denseRef = {} as SearchableIndex;
@@ -280,9 +300,54 @@ describe('PineconeClient', () => {
280300
useReranking: true,
281301
});
282302

283-
expect(results).toHaveLength(1);
284-
expect(results[0].reranked).toBe(true);
285-
expect(results[0].content).toBe('from dense');
303+
expect(results.results).toHaveLength(1);
304+
expect(results.results[0]?.reranked).toBe(true);
305+
expect(results.results[0]?.content).toBe('from dense');
306+
expect(spy).toHaveBeenCalled();
307+
} finally {
308+
spy.mockRestore();
309+
}
310+
});
311+
312+
it('propagates rerank degradation to hybrid query outcome', async () => {
313+
const spy = vi.spyOn(rerankModule, 'rerankResults').mockResolvedValue({
314+
results: [
315+
{
316+
id: 'd1',
317+
content: 'from dense',
318+
score: 0.9,
319+
metadata: {},
320+
reranked: false,
321+
},
322+
],
323+
degraded: true,
324+
degradation_reason: 'rerank_failed: timeout',
325+
});
326+
try {
327+
const testClient = stubPineconeClient(client);
328+
const denseRef = {} as SearchableIndex;
329+
const sparseRef = {} as SearchableIndex;
330+
testClient.ensureIndexes = async () => ({
331+
denseIndex: denseRef,
332+
sparseIndex: sparseRef,
333+
});
334+
testClient.searchIndex = async (index) => {
335+
if (index === denseRef) {
336+
return [{ _id: 'd1', _score: 0.9, fields: { chunk_text: 'from dense' } }];
337+
}
338+
return [];
339+
};
340+
341+
const out = await client.query({
342+
query: 'q',
343+
namespace: 'n',
344+
topK: 5,
345+
useReranking: true,
346+
});
347+
348+
expect(out.degraded).toBe(true);
349+
expect(out.degradation_reason).toBe('rerank_failed: timeout');
350+
expect(out.results[0]?.reranked).toBe(false);
286351
expect(spy).toHaveBeenCalled();
287352
} finally {
288353
spy.mockRestore();
@@ -314,7 +379,7 @@ describe('PineconeClient', () => {
314379
useReranking: false,
315380
});
316381

317-
expect(results.length).toBe(2);
382+
expect(results.results.length).toBe(2);
318383
});
319384
});
320385

src/pinecone-client.ts

Lines changed: 38 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@ import type {
1111
KeywordSearchParams,
1212
KeywordIndexNamespacesResult,
1313
SearchableIndex,
14+
HybridQueryResult,
15+
HybridLegFailed,
1416
} from './types.js';
1517
import {
1618
DEFAULT_INDEX_NAME,
@@ -35,16 +37,16 @@ export class PineconeClient {
3537
private defaultTopK: number;
3638
private readonly indexSession: PineconeIndexSession;
3739

38-
/** Create a client with the given config; env vars override index name, rerank model, and top-k. */
40+
/**
41+
* Create a client from a resolved {@link PineconeClientConfig}.
42+
* Index name, rerank model, and default top-k come only from this object (typically
43+
* built via {@link resolveConfig} / CLI); this class does not read `process.env`.
44+
*/
3945
constructor(config: PineconeClientConfig) {
40-
const indexName = config.indexName || process.env['PINECONE_INDEX_NAME'] || DEFAULT_INDEX_NAME;
46+
const indexName = config.indexName ?? DEFAULT_INDEX_NAME;
4147
this.indexSession = new PineconeIndexSession(config.apiKey, indexName);
42-
this.rerankModel =
43-
config.rerankModel || process.env['PINECONE_RERANK_MODEL'] || DEFAULT_RERANK_MODEL;
44-
const envTopK = process.env['PINECONE_TOP_K'];
45-
const parsedEnvTopK = envTopK !== undefined ? parseInt(envTopK, 10) : NaN;
46-
this.defaultTopK =
47-
config.defaultTopK ?? (Number.isFinite(parsedEnvTopK) ? parsedEnvTopK : DEFAULT_TOP_K);
48+
this.rerankModel = config.rerankModel ?? DEFAULT_RERANK_MODEL;
49+
this.defaultTopK = config.defaultTopK ?? DEFAULT_TOP_K;
4850
}
4951

5052
/** Sparse index name `{indexName}-sparse` (keyword / hybrid sparse). */
@@ -105,7 +107,7 @@ export class PineconeClient {
105107
return searchIndexImpl(index, query, topK, namespace, metadataFilter, options);
106108
}
107109

108-
async query(params: QueryParams): Promise<SearchResult[]> {
110+
async query(params: QueryParams): Promise<HybridQueryResult> {
109111
const {
110112
query,
111113
topK: requestedTopK,
@@ -148,17 +150,37 @@ export class PineconeClient {
148150
throw new Error('Hybrid search failed: both dense and sparse index searches failed.');
149151
}
150152

153+
let hybridLegFailed: HybridLegFailed = null;
154+
if (
155+
denseResult.status === 'rejected' &&
156+
sparseResult.status === 'fulfilled' &&
157+
sparseHits.length > 0
158+
) {
159+
hybridLegFailed = 'dense';
160+
} else if (
161+
sparseResult.status === 'rejected' &&
162+
denseResult.status === 'fulfilled' &&
163+
denseHits.length > 0
164+
) {
165+
hybridLegFailed = 'sparse';
166+
}
167+
151168
const mergedResults = mergeResults(denseHits, sparseHits);
152169

170+
let degraded = false;
171+
let degradation_reason: string | undefined;
153172
let documents: SearchResult[];
154173
if (useReranking) {
155-
documents = await rerankResultsImpl(
174+
const rerankOut = await rerankResultsImpl(
156175
this.indexSession.ensureClient(),
157176
this.rerankModel,
158177
query,
159178
mergedResults,
160179
topK
161180
);
181+
documents = rerankOut.results;
182+
degraded = rerankOut.degraded;
183+
degradation_reason = rerankOut.degradation_reason;
162184
} else {
163185
documents = sliceMergedHitsToSearchResults(mergedResults, topK);
164186
}
@@ -167,7 +189,12 @@ export class PineconeClient {
167189
`Retrieved ${documents.length} documents from hybrid search (dense: ${denseHits.length}, sparse: ${sparseHits.length})`
168190
);
169191

170-
return documents;
192+
return {
193+
results: documents,
194+
degraded,
195+
...(degradation_reason !== undefined ? { degradation_reason } : {}),
196+
hybrid_leg_failed: hybridLegFailed,
197+
};
171198
}
172199

173200
async keywordSearch(params: KeywordSearchParams): Promise<SearchResult[]> {

src/pinecone/rerank.test.ts

Lines changed: 15 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -7,10 +7,11 @@ const sampleMerged: MergedHit[] = [
77
];
88

99
describe('rerankResults', () => {
10-
it('returns empty array when there are no merged hits', async () => {
10+
it('returns empty outcome when there are no merged hits', async () => {
1111
const pc = {} as Parameters<typeof rerankResults>[0];
1212
const out = await rerankResults(pc, 'any-model', 'q', [], 5);
13-
expect(out).toEqual([]);
13+
expect(out.results).toEqual([]);
14+
expect(out.degraded).toBe(false);
1415
});
1516

1617
it('maps successful inference.rerank response', async () => {
@@ -26,21 +27,24 @@ describe('rerankResults', () => {
2627

2728
const out = await rerankResults(pc, 'm', 'q', sampleMerged, 5);
2829

29-
expect(out).toHaveLength(1);
30-
expect(out[0]?.reranked).toBe(true);
31-
expect(out[0]?.id).toBe('1');
32-
expect(out[0]?.content).toBe('hello');
33-
expect(out[0]?.score).toBeCloseTo(0.99);
30+
expect(out.results).toHaveLength(1);
31+
expect(out.degraded).toBe(false);
32+
expect(out.results[0]?.reranked).toBe(true);
33+
expect(out.results[0]?.id).toBe('1');
34+
expect(out.results[0]?.content).toBe('hello');
35+
expect(out.results[0]?.score).toBeCloseTo(0.99);
3436
});
3537

36-
it('returns unreranked slice when rerank throws', async () => {
38+
it('returns unreranked slice with degraded when rerank throws', async () => {
3739
const rerank = vi.fn().mockRejectedValue(new Error('rerank unavailable'));
3840
const pc = { inference: { rerank } } as Parameters<typeof rerankResults>[0];
3941

4042
const out = await rerankResults(pc, 'm', 'q', sampleMerged, 5);
4143

42-
expect(out).toHaveLength(1);
43-
expect(out[0]?.reranked).toBe(false);
44-
expect(out[0]?.content).toBe('hello');
44+
expect(out.results).toHaveLength(1);
45+
expect(out.degraded).toBe(true);
46+
expect(out.degradation_reason).toMatch(/^rerank_failed:/);
47+
expect(out.results[0]?.reranked).toBe(false);
48+
expect(out.results[0]?.content).toBe('hello');
4549
});
4650
});

0 commit comments

Comments
 (0)