Skip to content
Open
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
1 change: 0 additions & 1 deletion package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

46 changes: 39 additions & 7 deletions src/retriever.ts
Original file line number Diff line number Diff line change
Expand Up @@ -916,24 +916,56 @@ export class MemoryRetriever {

trace?.startStage("parallel_search", []);
failureStage = "hybrid.parallelSearch";
const [vectorResults, bm25Results] = await Promise.all([
const settledResults = await Promise.allSettled([
this.runVectorSearch(
queryVector,
candidatePoolSize,
scopeFilter,
category,
).catch((error) => {
throw attachFailureStage(error, "hybrid.vectorSearch");
}),
),
this.runBM25Search(
bm25Query,
candidatePoolSize,
scopeFilter,
category,
).catch((error) => {
throw attachFailureStage(error, "hybrid.bm25Search");
}),
),
]);

const vectorResult_ = settledResults[0];
const bm25Result_ = settledResults[1];

let vectorResults: RetrievalResult[];
let bm25Results: RetrievalResult[];

if (vectorResult_.status === "rejected") {
const error = attachFailureStage(vectorResult_.reason, "hybrid.vectorSearch");
console.warn(`[Retriever] vector search failed: ${error.message}`);
vectorResults = [];
} else {
vectorResults = vectorResult_.value;
}

if (bm25Result_.status === "rejected") {
const error = attachFailureStage(bm25Result_.reason, "hybrid.bm25Search");
console.warn(`[Retriever] bm25 search failed: ${error.message}`);
bm25Results = [];
} else {
bm25Results = bm25Result_.value;
}

// Check if BOTH backends failed (rejected), not just empty results
// Empty result sets are valid; only throw when both promises reject
const bothFailed =
vectorResult_.status === "rejected" && bm25Result_.status === "rejected";

if (bothFailed) {
const vectorError = vectorResult_.reason?.message || "unknown";
const bm25Error = bm25Result_.reason?.message || "unknown";
throw attachFailureStage(
new Error(`both vector and BM25 search failed: ${vectorError}, ${bm25Error}`),
"hybrid.parallelSearch",
);
}
if (diagnostics) {
diagnostics.vectorResultCount = vectorResults.length;
diagnostics.bm25ResultCount = bm25Results.length;
Expand Down
69 changes: 44 additions & 25 deletions test/query-expander.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -255,7 +255,7 @@ describe("retriever BM25 query expansion gating", () => {
});

it("distinguishes vector-search failures inside the hybrid parallel stage", async () => {
const { retriever } = createRetrieverHarness(
const { retriever, bm25Queries } = createRetrieverHarness(
{},
{
async vectorSearch() {
Expand All @@ -264,51 +264,70 @@ describe("retriever BM25 query expansion gating", () => {
},
);

await assert.rejects(
retriever.retrieve({
query: "普通查询",
limit: 1,
source: "manual",
}),
/simulated vector search failure/,
);
// With graceful degradation, BM25 results should be returned even though vector failed
const results = await retriever.retrieve({
query: "普通查询",
limit: 1,
source: "manual",
});

// Results from BM25 should be returned
assert.ok(results.length > 0);
assert.ok(bm25Queries.length > 0);

// Overall retrieval did not fail (graceful degradation)
assert.equal(
retriever.getLastDiagnostics()?.failureStage,
"hybrid.vectorSearch",
undefined,
);
// Vector result count should be 0 (failed)
assert.equal(
retriever.getLastDiagnostics()?.errorMessage,
"simulated vector search failure",
retriever.getLastDiagnostics()?.vectorResultCount,
0,
);
// BM25 result count should be > 0 (succeeded)
assert.ok(
(retriever.getLastDiagnostics()?.bm25ResultCount ?? 0) > 0,
);
});

it("distinguishes bm25-search failures inside the hybrid parallel stage", async () => {
const { retriever } = createRetrieverHarness(
const { retriever, bm25Queries } = createRetrieverHarness(
{},
{
async bm25Search() {
async bm25Search(query) {
bm25Queries.push(query);
throw new Error("simulated bm25 search failure");
},
},
);

await assert.rejects(
retriever.retrieve({
query: "普通查询",
limit: 1,
source: "manual",
}),
/simulated bm25 search failure/,
);
// With graceful degradation, vector results should be returned even though BM25 failed
const results = await retriever.retrieve({
query: "普通查询",
limit: 1,
source: "manual",
});

// Results from vector should be returned (empty by default in harness)
assert.equal(results.length, 0);
// BM25 was attempted (even though it failed)
assert.equal(bm25Queries.length, 1);

// Overall retrieval did not fail (graceful degradation)
assert.equal(
retriever.getLastDiagnostics()?.failureStage,
"hybrid.bm25Search",
undefined,
);
// Vector result count should be 0 (empty by default in harness)
assert.equal(
retriever.getLastDiagnostics()?.vectorResultCount,
0,
);
// BM25 result count should be 0 (failed)
assert.equal(
retriever.getLastDiagnostics()?.errorMessage,
"simulated bm25 search failure",
retriever.getLastDiagnostics()?.bm25ResultCount,
0,
);
});
});
Expand Down
Loading
Loading