Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
10 changes: 6 additions & 4 deletions docs/TOOLS.md
Original file line number Diff line number Diff line change
Expand Up @@ -211,13 +211,15 @@ 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 |
| ----- | -------- | ------- |
| `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.

Expand Down
8 changes: 8 additions & 0 deletions src/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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>;
120 changes: 105 additions & 15 deletions src/core/pinecone-client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down Expand Up @@ -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;

Expand Down Expand Up @@ -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',
Expand All @@ -155,6 +169,82 @@ 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);
stubSingleLegHybridFailure(testClient, { failedLeg: 'dense', survivorHits: [] });

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_REASON);
});

it('reports hybrid_leg_failed and degraded when sparse fails and dense returns empty', async () => {
const testClient = stubPineconeClient(client);
stubSingleLegHybridFailure(testClient, { failedLeg: 'sparse', survivorHits: [] });

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_REASON);
});

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);
stubSingleLegHybridFailure(testClient, { failedLeg: 'dense', survivorHits: [] });

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_REASON);
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'));
Expand Down
28 changes: 17 additions & 11 deletions src/core/pinecone-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -164,17 +170,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';
}

Expand Down Expand Up @@ -205,6 +203,14 @@ export class PineconeClient {
}
}

const survivorEmpty =
(hybridLegFailed === 'dense' && sparseHits.length === 0) ||
(hybridLegFailed === 'sparse' && denseHits.length === 0);
if (hybridLegFailed && survivorEmpty) {
degraded = true;
degradation_reason = HYBRID_LEG_FAILED_REASON[hybridLegFailed];
}

logInfo(
`Retrieved ${documents.length} documents from hybrid search (dense: ${denseHits.length}, sparse: ${sparseHits.length})`
);
Expand Down
14 changes: 14 additions & 0 deletions src/core/rerank-trace.test.ts
Original file line number Diff line number Diff line change
@@ -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';

Expand All @@ -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');
});
Expand Down
5 changes: 3 additions & 2 deletions src/core/server/tools/guided-query-tool.context.test.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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',
})
);
Expand All @@ -115,7 +116,7 @@ describe('guided_query tool handler (ServerContext instance path)', () => {
const resultExperimental = result['experimental'] as Record<string, unknown>;
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 () => {
Expand Down
9 changes: 6 additions & 3 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

/**
Expand All @@ -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
Expand Down