Skip to content

Commit 99d999d

Browse files
jonathanMLDevzho
andauthored
report hybrid leg failure when survivor is empty (cppalliance#231)
* report hybrid leg failure when survivor is empty * hybrid empty-survivor degradation and PR cppalliance#231 review follow-ups * moved fixed items on top --------- Co-authored-by: zho <jornathanm910923@gmail.com>
1 parent 8bbcfe2 commit 99d999d

8 files changed

Lines changed: 163 additions & 35 deletions

File tree

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,10 @@ Tagged releases are published to npm from GitHub Actions when a **GitHub Release
2626

2727
- **Breaking (library):** Trimmed internal-only re-exports from the package root and `/alliance` entry to shrink the public surface and blast radius: `trimOptional`, `createUnconfiguredAllianceContext` (core), and the concrete URL generators `generatorMailing`, `generatorSlackCpplang` (alliance). These were internal helpers, not part of the documented API; register built-ins via `registerBuiltinUrlGenerators` and build contexts via `createServer` / `createIsolatedContext`. A snapshot test now guards the runtime export surface so internal symbols cannot leak back in. See [MIGRATION.md](docs/MIGRATION.md#internal-only-re-exports-removed-203). (#203)
2828

29+
### Fixed
30+
31+
- **Hybrid query degradation:** When exactly one search leg fails and the surviving leg returns zero hits, `query` / `query_documents` / `guided_query` now set `experimental.hybrid_leg_failed` and `experimental.degraded: true` with `degradation_reason` `dense_leg_failed` or `sparse_leg_failed`, so empty results from a leg failure are distinguishable from a legitimately empty namespace. (#228)
32+
2933
## [0.4.0] - 2026-06-24
3034

3135
### Added

docs/TOOLS.md

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -211,13 +211,15 @@ Each row: `document_id`, `paper_number` (deprecated alias), `title`, `author`, `
211211

212212
### Rerank and hybrid degradation
213213

214-
When reranking is requested but the rerank API fails, the server still returns **`status: 'success'`** with rows where `reranked: false`, plus **experimental** envelope fields:
214+
Hybrid `query` / `query_documents` responses use **`status: 'success'`** even when confidence is reduced. The server sets **experimental** envelope fields when reranking fails or when exactly one hybrid leg fails and the surviving leg returns zero hits:
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/constants.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,3 +77,11 @@ export const ALLIANCE_SERVER_INSTRUCTIONS =
7777
* @deprecated Use {@link ALLIANCE_SERVER_INSTRUCTIONS} or {@link CORE_SERVER_INSTRUCTIONS}.
7878
*/
7979
export const SERVER_INSTRUCTIONS = ALLIANCE_SERVER_INSTRUCTIONS;
80+
81+
export const DENSE_LEG_FAILED_REASON = 'dense_leg_failed' as const;
82+
export const SPARSE_LEG_FAILED_REASON = 'sparse_leg_failed' as const;
83+
84+
export const HYBRID_LEG_FAILED_REASON = {
85+
dense: DENSE_LEG_FAILED_REASON,
86+
sparse: SPARSE_LEG_FAILED_REASON,
87+
} as const satisfies Record<'dense' | 'sparse', string>;

src/core/pinecone-client.test.ts

Lines changed: 105 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { PineconeClient } from './pinecone-client.js';
33
import { resolveConfig } from './config.js';
44
import type { SearchableIndex, PineconeHit } from '../types.js';
55
import * as rerankModule from './pinecone/rerank.js';
6+
import { DENSE_LEG_FAILED_REASON, SPARSE_LEG_FAILED_REASON } from '../constants.js';
67

78
/** Stubs for private methods (assigned at runtime; avoid intersecting private `PineconeClient` members). */
89
type PineconeClientMethodStubs = {
@@ -31,6 +32,29 @@ function stubDualLegSearchFailure(testClient: PineconeClientMethodStubs, searchE
3132
};
3233
}
3334

35+
function stubSingleLegHybridFailure(
36+
testClient: PineconeClientMethodStubs,
37+
options: {
38+
failedLeg: 'dense' | 'sparse';
39+
survivorHits: PineconeHit[];
40+
}
41+
): void {
42+
const denseRef = {} as SearchableIndex;
43+
const sparseRef = {} as SearchableIndex;
44+
const failedRef = options.failedLeg === 'dense' ? denseRef : sparseRef;
45+
46+
testClient.ensureIndexes = async () => ({
47+
denseIndex: denseRef,
48+
sparseIndex: sparseRef,
49+
});
50+
testClient.searchIndex = async (index) => {
51+
if (index === failedRef) {
52+
throw new Error(`${options.failedLeg} failure`);
53+
}
54+
return options.survivorHits;
55+
};
56+
}
57+
3458
describe('PineconeClient', () => {
3559
let client: PineconeClient;
3660

@@ -120,26 +144,16 @@ describe('PineconeClient', () => {
120144

121145
it('should continue hybrid search when one index fails', async () => {
122146
const testClient = stubPineconeClient(client);
123-
const denseRef = {} as SearchableIndex;
124-
const sparseRef = {} as SearchableIndex;
125-
126-
testClient.ensureIndexes = async () => ({
127-
denseIndex: denseRef,
128-
sparseIndex: sparseRef,
129-
});
130-
131-
testClient.searchIndex = async (index) => {
132-
if (index === denseRef) {
133-
throw new Error('dense failure');
134-
}
135-
return [
147+
stubSingleLegHybridFailure(testClient, {
148+
failedLeg: 'dense',
149+
survivorHits: [
136150
{
137151
_id: 'doc-1',
138152
_score: 0.9,
139153
fields: { chunk_text: 'hybrid content', author: 'tester' },
140154
},
141-
];
142-
};
155+
],
156+
});
143157

144158
const out = await client.query({
145159
query: 'hybrid search',
@@ -155,6 +169,82 @@ describe('PineconeClient', () => {
155169
expect(out.degraded).toBe(false);
156170
});
157171

172+
it('reports hybrid_leg_failed and degraded when dense fails and sparse returns empty', async () => {
173+
const testClient = stubPineconeClient(client);
174+
stubSingleLegHybridFailure(testClient, { failedLeg: 'dense', survivorHits: [] });
175+
176+
const out = await client.query({
177+
query: 'hybrid search',
178+
namespace: 'test',
179+
topK: 5,
180+
useReranking: false,
181+
});
182+
183+
expect(out.results).toHaveLength(0);
184+
expect(out.hybrid_leg_failed).toBe('dense');
185+
expect(out.degraded).toBe(true);
186+
expect(out.degradation_reason).toBe(DENSE_LEG_FAILED_REASON);
187+
});
188+
189+
it('reports hybrid_leg_failed and degraded when sparse fails and dense returns empty', async () => {
190+
const testClient = stubPineconeClient(client);
191+
stubSingleLegHybridFailure(testClient, { failedLeg: 'sparse', survivorHits: [] });
192+
193+
const out = await client.query({
194+
query: 'hybrid search',
195+
namespace: 'test',
196+
topK: 5,
197+
useReranking: false,
198+
});
199+
200+
expect(out.results).toHaveLength(0);
201+
expect(out.hybrid_leg_failed).toBe('sparse');
202+
expect(out.degraded).toBe(true);
203+
expect(out.degradation_reason).toBe(SPARSE_LEG_FAILED_REASON);
204+
});
205+
206+
it('returns no degradation when both legs succeed with empty hits', async () => {
207+
const testClient = stubPineconeClient(client);
208+
testClient.ensureIndexes = async () => ({
209+
denseIndex: {} as SearchableIndex,
210+
sparseIndex: {} as SearchableIndex,
211+
});
212+
testClient.searchIndex = async () => [];
213+
214+
const out = await client.query({
215+
query: 'hybrid search',
216+
namespace: 'test',
217+
topK: 5,
218+
useReranking: false,
219+
});
220+
221+
expect(out.results).toHaveLength(0);
222+
expect(out.hybrid_leg_failed).toBeNull();
223+
expect(out.degraded).toBe(false);
224+
expect(out.degradation_reason).toBeUndefined();
225+
});
226+
227+
it('prioritizes leg-failure degradation_reason over rerank_skipped_no_model when both apply', async () => {
228+
const noModelClient = new PineconeClient({
229+
apiKey: 'test-api-key',
230+
indexName: 'test-index',
231+
});
232+
const testClient = stubPineconeClient(noModelClient);
233+
stubSingleLegHybridFailure(testClient, { failedLeg: 'dense', survivorHits: [] });
234+
235+
const out = await noModelClient.query({
236+
query: 'hybrid search',
237+
namespace: 'test',
238+
topK: 5,
239+
useReranking: true,
240+
});
241+
242+
expect(out.hybrid_leg_failed).toBe('dense');
243+
expect(out.degraded).toBe(true);
244+
expect(out.degradation_reason).toBe(DENSE_LEG_FAILED_REASON);
245+
expect(out.rerank_skipped_reason).toBe('no_model');
246+
});
247+
158248
it('should throw when both dense and sparse searches fail', async () => {
159249
const testClient = stubPineconeClient(client);
160250
stubDualLegSearchFailure(testClient, new Error('index failure'));

src/core/pinecone-client.ts

Lines changed: 17 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,13 @@ import type {
1414
HybridQueryResult,
1515
HybridLegFailed,
1616
} from '../types.js';
17-
import { DEFAULT_TOP_K, MAX_TOP_K, COUNT_TOP_K, COUNT_FIELDS } from '../constants.js';
17+
import {
18+
DEFAULT_TOP_K,
19+
MAX_TOP_K,
20+
COUNT_TOP_K,
21+
COUNT_FIELDS,
22+
HYBRID_LEG_FAILED_REASON,
23+
} from '../constants.js';
1824
import { DEFAULT_REQUEST_TIMEOUT_MS } from './config.js';
1925
import { PineconeIndexSession, type NamespacesWithMetadataResult } from './pinecone/indexes.js';
2026
import {
@@ -164,17 +170,9 @@ export class PineconeClient {
164170
}
165171

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

@@ -205,6 +203,14 @@ export class PineconeClient {
205203
}
206204
}
207205

206+
const survivorEmpty =
207+
(hybridLegFailed === 'dense' && sparseHits.length === 0) ||
208+
(hybridLegFailed === 'sparse' && denseHits.length === 0);
209+
if (hybridLegFailed && survivorEmpty) {
210+
degraded = true;
211+
degradation_reason = HYBRID_LEG_FAILED_REASON[hybridLegFailed];
212+
}
213+
208214
logInfo(
209215
`Retrieved ${documents.length} documents from hybrid search (dense: ${denseHits.length}, sparse: ${sparseHits.length})`
210216
);

src/core/rerank-trace.test.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { describe, expect, it } from 'vitest';
2+
import { SPARSE_LEG_FAILED_REASON } from '../constants.js';
23
import { guidedRerankStatus } from './rerank-trace.js';
34
import { makeHybridQueryResult } from './server/tools/test-helpers.js';
45

@@ -25,6 +26,19 @@ describe('guidedRerankStatus', () => {
2526
).toBe('failed');
2627
});
2728

29+
it('returns success when degraded from empty-survivor hybrid leg failure', () => {
30+
expect(
31+
guidedRerankStatus(
32+
true,
33+
makeHybridQueryResult({
34+
degraded: true,
35+
degradation_reason: SPARSE_LEG_FAILED_REASON,
36+
hybrid_leg_failed: 'sparse',
37+
})
38+
)
39+
).toBe('success');
40+
});
41+
2842
it('returns success when rerank was requested and not skipped or failed', () => {
2943
expect(guidedRerankStatus(true, makeHybridQueryResult())).toBe('success');
3044
});

src/core/server/tools/guided-query-tool.context.test.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { describe, expect, it, vi } from 'vitest';
2+
import { SPARSE_LEG_FAILED_REASON } from '../../../constants.js';
23
import { registerBuiltinUrlGenerators } from '../../../alliance/url-builtins.js';
34
import { guidedQueryResponseSchema } from '../response-schemas.js';
45
import { registerQueryTool } from './query-tool.js';
@@ -94,7 +95,7 @@ describe('guided_query tool handler (ServerContext instance path)', () => {
9495
const query = vi.fn().mockResolvedValue(
9596
makeHybridQueryResult({
9697
degraded: true,
97-
degradation_reason: 'sparse_leg_empty',
98+
degradation_reason: SPARSE_LEG_FAILED_REASON,
9899
hybrid_leg_failed: 'sparse',
99100
})
100101
);
@@ -115,7 +116,7 @@ describe('guided_query tool handler (ServerContext instance path)', () => {
115116
const resultExperimental = result['experimental'] as Record<string, unknown>;
116117
expect(resultExperimental['degraded']).toBe(true);
117118
expect(resultExperimental['hybrid_leg_failed']).toBe('sparse');
118-
expect(resultExperimental['degradation_reason']).toBe('sparse_leg_empty');
119+
expect(resultExperimental['degradation_reason']).toBe(SPARSE_LEG_FAILED_REASON);
119120
});
120121

121122
it('enriches urls via ctx builtins when enrich_urls is true', async () => {

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)