feat: add source_status and degraded fields to HybridRAG response

Each retrieval leg (vector, graph, web, volatile, documents) now returns
(results, timing, error) instead of swallowing exceptions to an empty list.
The response reports per-leg status ('ok'/'failed'/'disabled') in
source_status and sets degraded=true when any enabled leg failed. Failed
legs still contribute no results (behavior unchanged) and are logged at
WARNING. Both fields are additive with defaults, so deploy order relative
to consumers does not matter.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-14 10:01:04 +02:00
co-authored by Claude Fable 5
parent 94482bcb59
commit 5c2284922d
3 changed files with 63 additions and 22 deletions
+5
View File
@@ -88,6 +88,11 @@ class HybridRAGResponse(BaseModel):
timing: TimingBreakdown = Field(..., description="Performance breakdown")
config_used: HybridRAGConfig = Field(..., description="Configuration used")
search_id: Optional[str] = Field(None, description="Search ID for Librarian tracking")
source_status: Dict[str, str] = Field(
default={},
description="Per-leg retrieval status ('ok', 'failed', or 'disabled') keyed by: vector, graph, web, volatile, documents"
)
degraded: bool = Field(default=False, description="True when any enabled retrieval leg reported 'failed'")
class HybridRAGRequest(BaseModel):
+54 -22
View File
@@ -44,6 +44,15 @@ class HybridRAGService:
Service for HybridRAG multi-source search with fusion and re-ranking.
"""
# Maps internal retrieval leg names to source_status keys in the response
SOURCE_STATUS_KEYS = {
"vector": "vector",
"graph": "graph",
"web": "web",
"volatile": "volatile",
"document": "documents",
}
def __init__(
self,
vector_service: VectorService,
@@ -185,6 +194,14 @@ class HybridRAGService:
)
timing["persistence_ms"] = (time.time() - phase6_start) * 1000
# Degradation signaling: a failed leg contributes no results, but the
# response says so instead of silently pretending the leg was empty
source_status = raw_results.get("source_status", {})
degraded = any(status == "failed" for status in source_status.values())
if degraded:
failed_legs = [leg for leg, status in source_status.items() if status == "failed"]
logger.warning(f"HybridRAG search degraded: failed legs: {failed_legs}")
# Build response
return HybridRAGResponse(
query=query,
@@ -195,7 +212,9 @@ class HybridRAGService:
total_results=len(result_models),
timing=TimingBreakdown(**timing),
config_used=config,
search_id=search_id
search_id=search_id,
source_status=source_status,
degraded=degraded
)
async def _extract_keywords_and_synonyms(self, query: str) -> Dict[str, Any]:
@@ -293,10 +312,14 @@ JSON:"""
user: str,
config: HybridRAGConfig,
keywords_data: Dict[str, Any]
) -> Dict[str, List]:
) -> Dict[str, Any]:
"""
Phase 1: Retrieve results from all sources in parallel.
Each retrieval leg returns (results, timing_ms, error) so that a
failed leg still contributes no results but is reported in
"source_status" instead of being silently swallowed.
Args:
query: Search query
user: User identifier
@@ -304,7 +327,8 @@ JSON:"""
keywords_data: Extracted keywords/synonyms
Returns:
Dictionary with results from each source and timing
Dictionary with results from each source, timing, and per-leg
"source_status" ('ok', 'failed', or 'disabled')
"""
tasks = {}
timing = {}
@@ -331,10 +355,10 @@ JSON:"""
}
for r in response.results
]
return results, (time.time() - start) * 1000
return results, (time.time() - start) * 1000, None
except Exception as e:
logger.error(f"Vector search failed: {e}", exc_info=True)
return [], (time.time() - start) * 1000
logger.warning(f"Vector search failed: {e}", exc_info=True)
return [], (time.time() - start) * 1000, e
tasks["vector"] = vector_search()
@@ -369,10 +393,10 @@ JSON:"""
}
for r in results
]
return formatted, (time.time() - start) * 1000
return formatted, (time.time() - start) * 1000, None
except Exception as e:
logger.error(f"Graph search failed: {e}", exc_info=True)
return [], (time.time() - start) * 1000
logger.warning(f"Graph search failed: {e}", exc_info=True)
return [], (time.time() - start) * 1000, e
tasks["graph"] = graph_search()
@@ -408,10 +432,10 @@ JSON:"""
}
for r in results
]
return formatted, (time.time() - start) * 1000
return formatted, (time.time() - start) * 1000, None
except Exception as e:
logger.error(f"Web search failed: {e}", exc_info=True)
return [], (time.time() - start) * 1000
logger.warning(f"Web search failed: {e}", exc_info=True)
return [], (time.time() - start) * 1000, e
tasks["web"] = web_search()
@@ -439,10 +463,10 @@ JSON:"""
}
for r in results
]
return formatted, (time.time() - start) * 1000
return formatted, (time.time() - start) * 1000, None
except Exception as e:
logger.error(f"Volatile search failed: {e}", exc_info=True)
return [], (time.time() - start) * 1000
logger.warning(f"Volatile search failed: {e}", exc_info=True)
return [], (time.time() - start) * 1000, e
tasks["volatile"] = volatile_search()
@@ -458,7 +482,7 @@ JSON:"""
# Check if collection exists
exists = await self.vector.qdrant.collection_exists(collection_name)
if not exists:
return [], (time.time() - start) * 1000
return [], (time.time() - start) * 1000, None
# Get query embedding
query_embedding = await self.vector.ollama.embed_text(query)
@@ -496,22 +520,30 @@ JSON:"""
"source": "document"
})
return formatted, (time.time() - start) * 1000
return formatted, (time.time() - start) * 1000, None
except Exception as e:
logger.error(f"Document search failed: {e}", exc_info=True)
return [], (time.time() - start) * 1000
logger.warning(f"Document search failed: {e}", exc_info=True)
return [], (time.time() - start) * 1000, e
tasks["document"] = document_search()
# Execute all searches in parallel
# No return_exceptions needed: each leg captures its own exception
# and reports it via the (results, timing, error) tuple.
results_dict = await asyncio.gather(*tasks.values())
# Combine results with timing
output = {"timing": {}}
# Combine results with timing and per-leg status
output = {"timing": {}, "source_status": {}}
for i, source in enumerate(tasks.keys()):
results, source_timing = results_dict[i]
results, source_timing, error = results_dict[i]
output[source] = results
output["timing"][f"{source}_ms"] = source_timing
status_key = self.SOURCE_STATUS_KEYS[source]
output["source_status"][status_key] = "failed" if error is not None else "ok"
# Legs that were not attempted are reported as disabled
for status_key in self.SOURCE_STATUS_KEYS.values():
output["source_status"].setdefault(status_key, "disabled")
logger.info(
f"Parallel retrieval: vector={len(output.get('vector', []))}, "