From 02a8ec6cdadf9b731f3d3e5da09c97cb030c2f46 Mon Sep 17 00:00:00 2001 From: zho Date: Tue, 21 Jul 2026 02:45:42 +0800 Subject: [PATCH 1/3] report hybrid leg failure when survivor is empty --- docs/TOOLS.md | 8 ++- src/core/pinecone-client.test.ts | 114 +++++++++++++++++++++++++++++++ src/core/pinecone-client.ts | 20 +++--- src/types.ts | 9 ++- 4 files changed, 135 insertions(+), 16 deletions(-) diff --git a/docs/TOOLS.md b/docs/TOOLS.md index 260a5ba..cf52435 100644 --- a/docs/TOOLS.md +++ b/docs/TOOLS.md @@ -215,9 +215,11 @@ When reranking is requested but the rerank API fails, the server still returns * | Field | When set | Meaning | | ----- | -------- | ------- | -| `experimental.degraded` | `true` | Rerank was attempted and failed (or another degradation path fired) | -| `experimental.degradation_reason` | string | Human-readable detail for MCP/LLM clients (e.g. `rerank_failed: timeout after 5000ms`) | -| `experimental.hybrid_leg_failed` | `'dense'` \| `'sparse'` | Exactly one hybrid search leg failed while the other returned hits | +| `experimental.degraded` | `true` | Rerank was attempted and failed, **or** one hybrid leg failed with an empty survivor | +| `experimental.degradation_reason` | string | Human-readable detail (e.g. `rerank_failed: timeout after 5000ms`, `dense_leg_failed`, `sparse_leg_failed`) | +| `experimental.hybrid_leg_failed` | `'dense'` \| `'sparse'` | Exactly one hybrid search leg failed (survivor may have hits or be empty) | + +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. 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. diff --git a/src/core/pinecone-client.test.ts b/src/core/pinecone-client.test.ts index 3cd6831..27f286f 100644 --- a/src/core/pinecone-client.test.ts +++ b/src/core/pinecone-client.test.ts @@ -155,6 +155,120 @@ describe('PineconeClient', () => { expect(out.degraded).toBe(false); }); + it('reports hybrid_leg_failed and degraded when dense fails and sparse returns empty', async () => { + const testClient = stubPineconeClient(client); + const denseRef = {} as SearchableIndex; + const sparseRef = {} as SearchableIndex; + + testClient.ensureIndexes = async () => ({ + denseIndex: denseRef, + sparseIndex: sparseRef, + }); + + testClient.searchIndex = async (index) => { + if (index === denseRef) { + throw new Error('dense failure'); + } + return []; + }; + + const out = await client.query({ + query: 'hybrid search', + namespace: 'test', + topK: 5, + useReranking: false, + }); + + expect(out.results).toHaveLength(0); + expect(out.hybrid_leg_failed).toBe('dense'); + expect(out.degraded).toBe(true); + expect(out.degradation_reason).toBe('dense_leg_failed'); + }); + + it('reports hybrid_leg_failed and degraded when sparse fails and dense returns empty', async () => { + const testClient = stubPineconeClient(client); + const denseRef = {} as SearchableIndex; + const sparseRef = {} as SearchableIndex; + + testClient.ensureIndexes = async () => ({ + denseIndex: denseRef, + sparseIndex: sparseRef, + }); + + testClient.searchIndex = async (index) => { + if (index === sparseRef) { + throw new Error('sparse failure'); + } + return []; + }; + + const out = await client.query({ + query: 'hybrid search', + namespace: 'test', + topK: 5, + useReranking: false, + }); + + expect(out.results).toHaveLength(0); + expect(out.hybrid_leg_failed).toBe('sparse'); + expect(out.degraded).toBe(true); + expect(out.degradation_reason).toBe('sparse_leg_failed'); + }); + + it('returns no degradation when both legs succeed with empty hits', async () => { + const testClient = stubPineconeClient(client); + testClient.ensureIndexes = async () => ({ + denseIndex: {} as SearchableIndex, + sparseIndex: {} as SearchableIndex, + }); + testClient.searchIndex = async () => []; + + const out = await client.query({ + query: 'hybrid search', + namespace: 'test', + topK: 5, + useReranking: false, + }); + + expect(out.results).toHaveLength(0); + expect(out.hybrid_leg_failed).toBeNull(); + expect(out.degraded).toBe(false); + expect(out.degradation_reason).toBeUndefined(); + }); + + it('prioritizes leg-failure degradation_reason over rerank_skipped_no_model when both apply', async () => { + const noModelClient = new PineconeClient({ + apiKey: 'test-api-key', + indexName: 'test-index', + }); + const testClient = stubPineconeClient(noModelClient); + const denseRef = {} as SearchableIndex; + const sparseRef = {} as SearchableIndex; + + testClient.ensureIndexes = async () => ({ + denseIndex: denseRef, + sparseIndex: sparseRef, + }); + testClient.searchIndex = async (index) => { + if (index === denseRef) { + throw new Error('dense failure'); + } + return []; + }; + + const out = await noModelClient.query({ + query: 'hybrid search', + namespace: 'test', + topK: 5, + useReranking: true, + }); + + expect(out.hybrid_leg_failed).toBe('dense'); + expect(out.degraded).toBe(true); + expect(out.degradation_reason).toBe('dense_leg_failed'); + expect(out.rerank_skipped_reason).toBe('no_model'); + }); + it('should throw when both dense and sparse searches fail', async () => { const testClient = stubPineconeClient(client); stubDualLegSearchFailure(testClient, new Error('index failure')); diff --git a/src/core/pinecone-client.ts b/src/core/pinecone-client.ts index 8da14f8..97a31e2 100644 --- a/src/core/pinecone-client.ts +++ b/src/core/pinecone-client.ts @@ -164,17 +164,9 @@ export class PineconeClient { } let hybridLegFailed: HybridLegFailed = null; - if ( - denseResult.status === 'rejected' && - sparseResult.status === 'fulfilled' && - sparseHits.length > 0 - ) { + if (denseResult.status === 'rejected' && sparseResult.status === 'fulfilled') { hybridLegFailed = 'dense'; - } else if ( - sparseResult.status === 'rejected' && - denseResult.status === 'fulfilled' && - denseHits.length > 0 - ) { + } else if (sparseResult.status === 'rejected' && denseResult.status === 'fulfilled') { hybridLegFailed = 'sparse'; } @@ -205,6 +197,14 @@ export class PineconeClient { } } + if (hybridLegFailed === 'dense' && sparseHits.length === 0) { + degraded = true; + degradation_reason = 'dense_leg_failed'; + } else if (hybridLegFailed === 'sparse' && denseHits.length === 0) { + degraded = true; + degradation_reason = 'sparse_leg_failed'; + } + logInfo( `Retrieved ${documents.length} documents from hybrid search (dense: ${denseHits.length}, sparse: ${sparseHits.length})` ); diff --git a/src/types.ts b/src/types.ts index e11b5c1..456c7b7 100644 --- a/src/types.ts +++ b/src/types.ts @@ -36,7 +36,7 @@ export interface SearchResult { reranked: boolean; } -/** Which hybrid leg failed when the other produced hits (partial hybrid success). */ +/** Which hybrid leg failed when exactly one of dense/sparse search rejected. */ export type HybridLegFailed = 'dense' | 'sparse' | null; /** @@ -47,11 +47,14 @@ export type RerankSkippedReason = 'no_model'; export interface HybridQueryResult { results: SearchResult[]; - /** True when reranking was attempted and failed (rows may have `reranked: false`). */ + /** + * True when reranking was attempted and failed (rows may have `reranked: false`), + * or when exactly one hybrid leg failed and the surviving leg returned zero hits. + */ degraded: boolean; /** Present when {@link degraded} is true; suitable for LLM-facing tool output. */ degradation_reason?: string; - /** Set when exactly one of dense/sparse search failed but the other succeeded. */ + /** Set when exactly one of dense/sparse search failed (regardless of survivor hit count). */ hybrid_leg_failed: HybridLegFailed; /** * Set when `useReranking` was true but no rerank model is configured on the client From afe585987e5b28ac42def867d55cfb8e61a89462 Mon Sep 17 00:00:00 2001 From: zho Date: Tue, 21 Jul 2026 06:02:59 +0800 Subject: [PATCH 2/3] hybrid empty-survivor degradation and PR #231 review follow-ups --- CHANGELOG.md | 1 + docs/TOOLS.md | 2 +- src/constants.ts | 8 ++ src/core/pinecone-client.test.ts | 94 +++++++------------ src/core/pinecone-client.ts | 18 ++-- src/core/rerank-trace.test.ts | 14 +++ .../tools/guided-query-tool.context.test.ts | 5 +- 7 files changed, 74 insertions(+), 68 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index aa8b260..82846dc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -70,6 +70,7 @@ Tagged releases are published to npm from GitHub Actions when a **GitHub Release ### Fixed - 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. +- **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) ## [0.2.0] - 2026-05-29 diff --git a/docs/TOOLS.md b/docs/TOOLS.md index cf52435..01c7612 100644 --- a/docs/TOOLS.md +++ b/docs/TOOLS.md @@ -211,7 +211,7 @@ Each row: `document_id`, `paper_number` (deprecated alias), `title`, `author`, ` ### Rerank and hybrid degradation -When reranking is requested but the rerank API fails, the server still returns **`status: 'success'`** with rows where `reranked: false`, plus **experimental** envelope fields: +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: | Field | When set | Meaning | | ----- | -------- | ------- | diff --git a/src/constants.ts b/src/constants.ts index cf40275..9df1be9 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -77,3 +77,11 @@ export const ALLIANCE_SERVER_INSTRUCTIONS = * @deprecated Use {@link ALLIANCE_SERVER_INSTRUCTIONS} or {@link CORE_SERVER_INSTRUCTIONS}. */ export const SERVER_INSTRUCTIONS = ALLIANCE_SERVER_INSTRUCTIONS; + +export const DENSE_LEG_FAILED_REASON = 'dense_leg_failed' as const; +export const SPARSE_LEG_FAILED_REASON = 'sparse_leg_failed' as const; + +export const HYBRID_LEG_FAILED_REASON = { + dense: DENSE_LEG_FAILED_REASON, + sparse: SPARSE_LEG_FAILED_REASON, +} as const satisfies Record<'dense' | 'sparse', string>; diff --git a/src/core/pinecone-client.test.ts b/src/core/pinecone-client.test.ts index 27f286f..29c8440 100644 --- a/src/core/pinecone-client.test.ts +++ b/src/core/pinecone-client.test.ts @@ -3,6 +3,7 @@ import { PineconeClient } from './pinecone-client.js'; import { resolveConfig } from './config.js'; import type { SearchableIndex, PineconeHit } from '../types.js'; import * as rerankModule from './pinecone/rerank.js'; +import { DENSE_LEG_FAILED_REASON, SPARSE_LEG_FAILED_REASON } from '../constants.js'; /** Stubs for private methods (assigned at runtime; avoid intersecting private `PineconeClient` members). */ type PineconeClientMethodStubs = { @@ -31,6 +32,29 @@ function stubDualLegSearchFailure(testClient: PineconeClientMethodStubs, searchE }; } +function stubSingleLegHybridFailure( + testClient: PineconeClientMethodStubs, + options: { + failedLeg: 'dense' | 'sparse'; + survivorHits: PineconeHit[]; + } +): void { + const denseRef = {} as SearchableIndex; + const sparseRef = {} as SearchableIndex; + const failedRef = options.failedLeg === 'dense' ? denseRef : sparseRef; + + testClient.ensureIndexes = async () => ({ + denseIndex: denseRef, + sparseIndex: sparseRef, + }); + testClient.searchIndex = async (index) => { + if (index === failedRef) { + throw new Error(`${options.failedLeg} failure`); + } + return options.survivorHits; + }; +} + describe('PineconeClient', () => { let client: PineconeClient; @@ -120,26 +144,16 @@ describe('PineconeClient', () => { it('should continue hybrid search when one index fails', async () => { const testClient = stubPineconeClient(client); - const denseRef = {} as SearchableIndex; - const sparseRef = {} as SearchableIndex; - - testClient.ensureIndexes = async () => ({ - denseIndex: denseRef, - sparseIndex: sparseRef, - }); - - testClient.searchIndex = async (index) => { - if (index === denseRef) { - throw new Error('dense failure'); - } - return [ + stubSingleLegHybridFailure(testClient, { + failedLeg: 'dense', + survivorHits: [ { _id: 'doc-1', _score: 0.9, fields: { chunk_text: 'hybrid content', author: 'tester' }, }, - ]; - }; + ], + }); const out = await client.query({ query: 'hybrid search', @@ -157,20 +171,7 @@ describe('PineconeClient', () => { it('reports hybrid_leg_failed and degraded when dense fails and sparse returns empty', async () => { const testClient = stubPineconeClient(client); - const denseRef = {} as SearchableIndex; - const sparseRef = {} as SearchableIndex; - - testClient.ensureIndexes = async () => ({ - denseIndex: denseRef, - sparseIndex: sparseRef, - }); - - testClient.searchIndex = async (index) => { - if (index === denseRef) { - throw new Error('dense failure'); - } - return []; - }; + stubSingleLegHybridFailure(testClient, { failedLeg: 'dense', survivorHits: [] }); const out = await client.query({ query: 'hybrid search', @@ -182,25 +183,12 @@ describe('PineconeClient', () => { expect(out.results).toHaveLength(0); expect(out.hybrid_leg_failed).toBe('dense'); expect(out.degraded).toBe(true); - expect(out.degradation_reason).toBe('dense_leg_failed'); + expect(out.degradation_reason).toBe(DENSE_LEG_FAILED_REASON); }); it('reports hybrid_leg_failed and degraded when sparse fails and dense returns empty', async () => { const testClient = stubPineconeClient(client); - const denseRef = {} as SearchableIndex; - const sparseRef = {} as SearchableIndex; - - testClient.ensureIndexes = async () => ({ - denseIndex: denseRef, - sparseIndex: sparseRef, - }); - - testClient.searchIndex = async (index) => { - if (index === sparseRef) { - throw new Error('sparse failure'); - } - return []; - }; + stubSingleLegHybridFailure(testClient, { failedLeg: 'sparse', survivorHits: [] }); const out = await client.query({ query: 'hybrid search', @@ -212,7 +200,7 @@ describe('PineconeClient', () => { expect(out.results).toHaveLength(0); expect(out.hybrid_leg_failed).toBe('sparse'); expect(out.degraded).toBe(true); - expect(out.degradation_reason).toBe('sparse_leg_failed'); + expect(out.degradation_reason).toBe(SPARSE_LEG_FAILED_REASON); }); it('returns no degradation when both legs succeed with empty hits', async () => { @@ -242,19 +230,7 @@ describe('PineconeClient', () => { indexName: 'test-index', }); const testClient = stubPineconeClient(noModelClient); - const denseRef = {} as SearchableIndex; - const sparseRef = {} as SearchableIndex; - - testClient.ensureIndexes = async () => ({ - denseIndex: denseRef, - sparseIndex: sparseRef, - }); - testClient.searchIndex = async (index) => { - if (index === denseRef) { - throw new Error('dense failure'); - } - return []; - }; + stubSingleLegHybridFailure(testClient, { failedLeg: 'dense', survivorHits: [] }); const out = await noModelClient.query({ query: 'hybrid search', @@ -265,7 +241,7 @@ describe('PineconeClient', () => { expect(out.hybrid_leg_failed).toBe('dense'); expect(out.degraded).toBe(true); - expect(out.degradation_reason).toBe('dense_leg_failed'); + expect(out.degradation_reason).toBe(DENSE_LEG_FAILED_REASON); expect(out.rerank_skipped_reason).toBe('no_model'); }); diff --git a/src/core/pinecone-client.ts b/src/core/pinecone-client.ts index 97a31e2..1ab8e95 100644 --- a/src/core/pinecone-client.ts +++ b/src/core/pinecone-client.ts @@ -14,7 +14,13 @@ import type { HybridQueryResult, HybridLegFailed, } from '../types.js'; -import { DEFAULT_TOP_K, MAX_TOP_K, COUNT_TOP_K, COUNT_FIELDS } from '../constants.js'; +import { + DEFAULT_TOP_K, + MAX_TOP_K, + COUNT_TOP_K, + COUNT_FIELDS, + HYBRID_LEG_FAILED_REASON, +} from '../constants.js'; import { DEFAULT_REQUEST_TIMEOUT_MS } from './config.js'; import { PineconeIndexSession, type NamespacesWithMetadataResult } from './pinecone/indexes.js'; import { @@ -197,12 +203,12 @@ export class PineconeClient { } } - if (hybridLegFailed === 'dense' && sparseHits.length === 0) { - degraded = true; - degradation_reason = 'dense_leg_failed'; - } else if (hybridLegFailed === 'sparse' && denseHits.length === 0) { + const survivorEmpty = + (hybridLegFailed === 'dense' && sparseHits.length === 0) || + (hybridLegFailed === 'sparse' && denseHits.length === 0); + if (hybridLegFailed && survivorEmpty) { degraded = true; - degradation_reason = 'sparse_leg_failed'; + degradation_reason = HYBRID_LEG_FAILED_REASON[hybridLegFailed]; } logInfo( diff --git a/src/core/rerank-trace.test.ts b/src/core/rerank-trace.test.ts index 1eb785a..600df7d 100644 --- a/src/core/rerank-trace.test.ts +++ b/src/core/rerank-trace.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it } from 'vitest'; +import { SPARSE_LEG_FAILED_REASON } from '../constants.js'; import { guidedRerankStatus } from './rerank-trace.js'; import { makeHybridQueryResult } from './server/tools/test-helpers.js'; @@ -25,6 +26,19 @@ describe('guidedRerankStatus', () => { ).toBe('failed'); }); + it('returns success when degraded from empty-survivor hybrid leg failure', () => { + expect( + guidedRerankStatus( + true, + makeHybridQueryResult({ + degraded: true, + degradation_reason: SPARSE_LEG_FAILED_REASON, + hybrid_leg_failed: 'sparse', + }) + ) + ).toBe('success'); + }); + it('returns success when rerank was requested and not skipped or failed', () => { expect(guidedRerankStatus(true, makeHybridQueryResult())).toBe('success'); }); diff --git a/src/core/server/tools/guided-query-tool.context.test.ts b/src/core/server/tools/guided-query-tool.context.test.ts index 65974c5..23cc0d8 100644 --- a/src/core/server/tools/guided-query-tool.context.test.ts +++ b/src/core/server/tools/guided-query-tool.context.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it, vi } from 'vitest'; +import { SPARSE_LEG_FAILED_REASON } from '../../../constants.js'; import { registerBuiltinUrlGenerators } from '../../../alliance/url-builtins.js'; import { guidedQueryResponseSchema } from '../response-schemas.js'; import { registerQueryTool } from './query-tool.js'; @@ -94,7 +95,7 @@ describe('guided_query tool handler (ServerContext instance path)', () => { const query = vi.fn().mockResolvedValue( makeHybridQueryResult({ degraded: true, - degradation_reason: 'sparse_leg_empty', + degradation_reason: SPARSE_LEG_FAILED_REASON, hybrid_leg_failed: 'sparse', }) ); @@ -115,7 +116,7 @@ describe('guided_query tool handler (ServerContext instance path)', () => { const resultExperimental = result['experimental'] as Record; expect(resultExperimental['degraded']).toBe(true); expect(resultExperimental['hybrid_leg_failed']).toBe('sparse'); - expect(resultExperimental['degradation_reason']).toBe('sparse_leg_empty'); + expect(resultExperimental['degradation_reason']).toBe(SPARSE_LEG_FAILED_REASON); }); it('enriches urls via ctx builtins when enrich_urls is true', async () => { From f0460149d6edd9cb2c63b1a36b6ac5b93a0420eb Mon Sep 17 00:00:00 2001 From: zho Date: Tue, 21 Jul 2026 13:54:50 +0800 Subject: [PATCH 3/3] moved fixed items on top --- CHANGELOG.md | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 82846dc..5a78fa9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,6 +26,10 @@ Tagged releases are published to npm from GitHub Actions when a **GitHub Release - **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) +### Fixed + +- **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) + ## [0.4.0] - 2026-06-24 ### Added @@ -70,7 +74,6 @@ Tagged releases are published to npm from GitHub Actions when a **GitHub Release ### Fixed - 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. -- **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) ## [0.2.0] - 2026-05-29