Skip to content

Commit afe5859

Browse files
author
zho
committed
hybrid empty-survivor degradation and PR #231 review follow-ups
1 parent 02a8ec6 commit afe5859

7 files changed

Lines changed: 74 additions & 68 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,7 @@ Tagged releases are published to npm from GitHub Actions when a **GitHub Release
7070
### Fixed
7171

7272
- Legacy module facades no longer silently diverge from an explicit `ServerContext` passed to `setupCoreServer` / `setupAllianceServer`. Mixing legacy facades with `{ context: ctx }` setup now throws with migration guidance instead of dual-state behavior.
73+
- **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)
7374

7475
## [0.2.0] - 2026-05-29
7576

docs/TOOLS.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -211,7 +211,7 @@ 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
| ----- | -------- | ------- |

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: 35 additions & 59 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',
@@ -157,20 +171,7 @@ describe('PineconeClient', () => {
157171

158172
it('reports hybrid_leg_failed and degraded when dense fails and sparse returns empty', async () => {
159173
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+
stubSingleLegHybridFailure(testClient, { failedLeg: 'dense', survivorHits: [] });
174175

175176
const out = await client.query({
176177
query: 'hybrid search',
@@ -182,25 +183,12 @@ describe('PineconeClient', () => {
182183
expect(out.results).toHaveLength(0);
183184
expect(out.hybrid_leg_failed).toBe('dense');
184185
expect(out.degraded).toBe(true);
185-
expect(out.degradation_reason).toBe('dense_leg_failed');
186+
expect(out.degradation_reason).toBe(DENSE_LEG_FAILED_REASON);
186187
});
187188

188189
it('reports hybrid_leg_failed and degraded when sparse fails and dense returns empty', async () => {
189190
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-
};
191+
stubSingleLegHybridFailure(testClient, { failedLeg: 'sparse', survivorHits: [] });
204192

205193
const out = await client.query({
206194
query: 'hybrid search',
@@ -212,7 +200,7 @@ describe('PineconeClient', () => {
212200
expect(out.results).toHaveLength(0);
213201
expect(out.hybrid_leg_failed).toBe('sparse');
214202
expect(out.degraded).toBe(true);
215-
expect(out.degradation_reason).toBe('sparse_leg_failed');
203+
expect(out.degradation_reason).toBe(SPARSE_LEG_FAILED_REASON);
216204
});
217205

218206
it('returns no degradation when both legs succeed with empty hits', async () => {
@@ -242,19 +230,7 @@ describe('PineconeClient', () => {
242230
indexName: 'test-index',
243231
});
244232
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-
};
233+
stubSingleLegHybridFailure(testClient, { failedLeg: 'dense', survivorHits: [] });
258234

259235
const out = await noModelClient.query({
260236
query: 'hybrid search',
@@ -265,7 +241,7 @@ describe('PineconeClient', () => {
265241

266242
expect(out.hybrid_leg_failed).toBe('dense');
267243
expect(out.degraded).toBe(true);
268-
expect(out.degradation_reason).toBe('dense_leg_failed');
244+
expect(out.degradation_reason).toBe(DENSE_LEG_FAILED_REASON);
269245
expect(out.rerank_skipped_reason).toBe('no_model');
270246
});
271247

src/core/pinecone-client.ts

Lines changed: 12 additions & 6 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 {
@@ -197,12 +203,12 @@ export class PineconeClient {
197203
}
198204
}
199205

200-
if (hybridLegFailed === 'dense' && sparseHits.length === 0) {
201-
degraded = true;
202-
degradation_reason = 'dense_leg_failed';
203-
} else if (hybridLegFailed === 'sparse' && denseHits.length === 0) {
206+
const survivorEmpty =
207+
(hybridLegFailed === 'dense' && sparseHits.length === 0) ||
208+
(hybridLegFailed === 'sparse' && denseHits.length === 0);
209+
if (hybridLegFailed && survivorEmpty) {
204210
degraded = true;
205-
degradation_reason = 'sparse_leg_failed';
211+
degradation_reason = HYBRID_LEG_FAILED_REASON[hybridLegFailed];
206212
}
207213

208214
logInfo(

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 () => {

0 commit comments

Comments
 (0)