Skip to content

Commit 02a8ec6

Browse files
author
zho
committed
report hybrid leg failure when survivor is empty
1 parent 8bbcfe2 commit 02a8ec6

4 files changed

Lines changed: 135 additions & 16 deletions

File tree

docs/TOOLS.md

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -215,9 +215,11 @@ When reranking is requested but the rerank API fails, the server still returns *
215215

216216
| Field | When set | Meaning |
217217
| ----- | -------- | ------- |
218-
| `experimental.degraded` | `true` | Rerank was attempted and failed (or another degradation path fired) |
219-
| `experimental.degradation_reason` | string | Human-readable detail for MCP/LLM clients (e.g. `rerank_failed: timeout after 5000ms`) |
220-
| `experimental.hybrid_leg_failed` | `'dense'` \| `'sparse'` | Exactly one hybrid search leg failed while the other returned hits |
218+
| `experimental.degraded` | `true` | Rerank was attempted and failed, **or** one hybrid leg failed with an empty survivor |
219+
| `experimental.degradation_reason` | string | Human-readable detail (e.g. `rerank_failed: timeout after 5000ms`, `dense_leg_failed`, `sparse_leg_failed`) |
220+
| `experimental.hybrid_leg_failed` | `'dense'` \| `'sparse'` | Exactly one hybrid search leg failed (survivor may have hits or be empty) |
221+
222+
When `hybrid_leg_failed` is set and `degraded` is `false`, the survivor leg returned hits (partial hybrid). When both are set with zero `results`, a leg failed — not a confidently empty namespace.
221223

222224
Treat **`experimental.degraded: true`** as lower confidence even when `status` is `success`. Combine with per-row `reranked`, `preset`, and `use_reranking`. Structured stderr logs may include additional detail.
223225

src/core/pinecone-client.test.ts

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -155,6 +155,120 @@ describe('PineconeClient', () => {
155155
expect(out.degraded).toBe(false);
156156
});
157157

158+
it('reports hybrid_leg_failed and degraded when dense fails and sparse returns empty', async () => {
159+
const testClient = stubPineconeClient(client);
160+
const denseRef = {} as SearchableIndex;
161+
const sparseRef = {} as SearchableIndex;
162+
163+
testClient.ensureIndexes = async () => ({
164+
denseIndex: denseRef,
165+
sparseIndex: sparseRef,
166+
});
167+
168+
testClient.searchIndex = async (index) => {
169+
if (index === denseRef) {
170+
throw new Error('dense failure');
171+
}
172+
return [];
173+
};
174+
175+
const out = await client.query({
176+
query: 'hybrid search',
177+
namespace: 'test',
178+
topK: 5,
179+
useReranking: false,
180+
});
181+
182+
expect(out.results).toHaveLength(0);
183+
expect(out.hybrid_leg_failed).toBe('dense');
184+
expect(out.degraded).toBe(true);
185+
expect(out.degradation_reason).toBe('dense_leg_failed');
186+
});
187+
188+
it('reports hybrid_leg_failed and degraded when sparse fails and dense returns empty', async () => {
189+
const testClient = stubPineconeClient(client);
190+
const denseRef = {} as SearchableIndex;
191+
const sparseRef = {} as SearchableIndex;
192+
193+
testClient.ensureIndexes = async () => ({
194+
denseIndex: denseRef,
195+
sparseIndex: sparseRef,
196+
});
197+
198+
testClient.searchIndex = async (index) => {
199+
if (index === sparseRef) {
200+
throw new Error('sparse failure');
201+
}
202+
return [];
203+
};
204+
205+
const out = await client.query({
206+
query: 'hybrid search',
207+
namespace: 'test',
208+
topK: 5,
209+
useReranking: false,
210+
});
211+
212+
expect(out.results).toHaveLength(0);
213+
expect(out.hybrid_leg_failed).toBe('sparse');
214+
expect(out.degraded).toBe(true);
215+
expect(out.degradation_reason).toBe('sparse_leg_failed');
216+
});
217+
218+
it('returns no degradation when both legs succeed with empty hits', async () => {
219+
const testClient = stubPineconeClient(client);
220+
testClient.ensureIndexes = async () => ({
221+
denseIndex: {} as SearchableIndex,
222+
sparseIndex: {} as SearchableIndex,
223+
});
224+
testClient.searchIndex = async () => [];
225+
226+
const out = await client.query({
227+
query: 'hybrid search',
228+
namespace: 'test',
229+
topK: 5,
230+
useReranking: false,
231+
});
232+
233+
expect(out.results).toHaveLength(0);
234+
expect(out.hybrid_leg_failed).toBeNull();
235+
expect(out.degraded).toBe(false);
236+
expect(out.degradation_reason).toBeUndefined();
237+
});
238+
239+
it('prioritizes leg-failure degradation_reason over rerank_skipped_no_model when both apply', async () => {
240+
const noModelClient = new PineconeClient({
241+
apiKey: 'test-api-key',
242+
indexName: 'test-index',
243+
});
244+
const testClient = stubPineconeClient(noModelClient);
245+
const denseRef = {} as SearchableIndex;
246+
const sparseRef = {} as SearchableIndex;
247+
248+
testClient.ensureIndexes = async () => ({
249+
denseIndex: denseRef,
250+
sparseIndex: sparseRef,
251+
});
252+
testClient.searchIndex = async (index) => {
253+
if (index === denseRef) {
254+
throw new Error('dense failure');
255+
}
256+
return [];
257+
};
258+
259+
const out = await noModelClient.query({
260+
query: 'hybrid search',
261+
namespace: 'test',
262+
topK: 5,
263+
useReranking: true,
264+
});
265+
266+
expect(out.hybrid_leg_failed).toBe('dense');
267+
expect(out.degraded).toBe(true);
268+
expect(out.degradation_reason).toBe('dense_leg_failed');
269+
expect(out.rerank_skipped_reason).toBe('no_model');
270+
});
271+
158272
it('should throw when both dense and sparse searches fail', async () => {
159273
const testClient = stubPineconeClient(client);
160274
stubDualLegSearchFailure(testClient, new Error('index failure'));

src/core/pinecone-client.ts

Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -164,17 +164,9 @@ export class PineconeClient {
164164
}
165165

166166
let hybridLegFailed: HybridLegFailed = null;
167-
if (
168-
denseResult.status === 'rejected' &&
169-
sparseResult.status === 'fulfilled' &&
170-
sparseHits.length > 0
171-
) {
167+
if (denseResult.status === 'rejected' && sparseResult.status === 'fulfilled') {
172168
hybridLegFailed = 'dense';
173-
} else if (
174-
sparseResult.status === 'rejected' &&
175-
denseResult.status === 'fulfilled' &&
176-
denseHits.length > 0
177-
) {
169+
} else if (sparseResult.status === 'rejected' && denseResult.status === 'fulfilled') {
178170
hybridLegFailed = 'sparse';
179171
}
180172

@@ -205,6 +197,14 @@ export class PineconeClient {
205197
}
206198
}
207199

200+
if (hybridLegFailed === 'dense' && sparseHits.length === 0) {
201+
degraded = true;
202+
degradation_reason = 'dense_leg_failed';
203+
} else if (hybridLegFailed === 'sparse' && denseHits.length === 0) {
204+
degraded = true;
205+
degradation_reason = 'sparse_leg_failed';
206+
}
207+
208208
logInfo(
209209
`Retrieved ${documents.length} documents from hybrid search (dense: ${denseHits.length}, sparse: ${sparseHits.length})`
210210
);

src/types.ts

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@ export interface SearchResult {
3636
reranked: boolean;
3737
}
3838

39-
/** Which hybrid leg failed when the other produced hits (partial hybrid success). */
39+
/** Which hybrid leg failed when exactly one of dense/sparse search rejected. */
4040
export type HybridLegFailed = 'dense' | 'sparse' | null;
4141

4242
/**
@@ -47,11 +47,14 @@ export type RerankSkippedReason = 'no_model';
4747

4848
export interface HybridQueryResult {
4949
results: SearchResult[];
50-
/** True when reranking was attempted and failed (rows may have `reranked: false`). */
50+
/**
51+
* True when reranking was attempted and failed (rows may have `reranked: false`),
52+
* or when exactly one hybrid leg failed and the surviving leg returned zero hits.
53+
*/
5154
degraded: boolean;
5255
/** Present when {@link degraded} is true; suitable for LLM-facing tool output. */
5356
degradation_reason?: string;
54-
/** Set when exactly one of dense/sparse search failed but the other succeeded. */
57+
/** Set when exactly one of dense/sparse search failed (regardless of survivor hit count). */
5558
hybrid_leg_failed: HybridLegFailed;
5659
/**
5760
* Set when `useReranking` was true but no rerank model is configured on the client

0 commit comments

Comments
 (0)