Spaces:
Running
Running
fix: temporal queries now prioritize live results end-to-end
Browse filesRanker (hybrid_result_ranker.py):
- Temporal composite score: freshness=50%, live_boost=25%, relevance=15%, quality=10%
- General composite score: relevance=40%, freshness=30%, quality=20%, live_boost=10%
- Temporal candidate pool: 12 live + 8 DB (was 20 mixed) sent to reranker
- Live results now dominate reranker input for temporal queries
RAG pipeline (rag_chat_use_case.py):
- Adaptive rerank threshold: 0.25 for BGE, -0.05 for Jina API scores
- Adaptive keyword overlap threshold: 0.35 for BGE, 0.02 for Jina API
- Temporal final pool: ceil(top_k/2) live + rest DB (was 1 non-English guarantee)
- Final log shows live vs DB count explicitly
src/core/ranking/hybrid_result_ranker.py
CHANGED
|
@@ -71,64 +71,64 @@ class HybridResultRanker:
|
|
| 71 |
|
| 72 |
def merge_and_rank(
|
| 73 |
self,
|
| 74 |
-
db_results: List[Any],
|
| 75 |
-
live_results: List[Dict[str, Any]],
|
| 76 |
-
strategy,
|
| 77 |
query: str,
|
| 78 |
final_top_n: int = 10
|
| 79 |
) -> List[Dict[str, Any]]:
|
| 80 |
"""
|
| 81 |
Merge results from database and live sources, then rerank.
|
| 82 |
-
|
| 83 |
-
|
| 84 |
-
|
| 85 |
-
live_results: Normalized dicts from DuckDuckGo
|
| 86 |
-
strategy: SearchStrategy with weights
|
| 87 |
-
query: Original user query
|
| 88 |
-
final_top_n: Number of final results to return
|
| 89 |
-
|
| 90 |
-
Returns:
|
| 91 |
-
List of top-N ranked results
|
| 92 |
"""
|
| 93 |
-
|
|
|
|
| 94 |
all_results = []
|
| 95 |
-
|
| 96 |
-
# Normalize database results
|
| 97 |
for r in db_results:
|
| 98 |
all_results.append(self._normalize_db_result(r, strategy.db_weight))
|
| 99 |
-
|
| 100 |
-
# Normalize live results
|
| 101 |
for r in live_results:
|
| 102 |
all_results.append(self._normalize_live_result(r, strategy.live_weight))
|
| 103 |
-
|
| 104 |
if not all_results:
|
| 105 |
logger.warning("No results to rank")
|
| 106 |
return []
|
| 107 |
-
|
| 108 |
logger.info(
|
| 109 |
f"Merging {len(db_results)} DB + {len(live_results)} live = "
|
| 110 |
-
f"{len(all_results)} total results"
|
|
|
|
| 111 |
)
|
| 112 |
-
|
| 113 |
-
# 2. Deduplicate by URL and title similarity
|
| 114 |
unique_results = self._deduplicate(all_results)
|
| 115 |
logger.info(f"After deduplication: {len(unique_results)} unique results")
|
| 116 |
-
|
| 117 |
-
#
|
| 118 |
for r in unique_results:
|
| 119 |
-
r["composite_score"] = self._calculate_composite_score(r)
|
| 120 |
-
|
| 121 |
-
# 4. Pre-sort by composite score
|
| 122 |
unique_results.sort(key=lambda x: x["composite_score"], reverse=True)
|
| 123 |
-
|
| 124 |
-
#
|
| 125 |
-
#
|
| 126 |
-
|
| 127 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 128 |
if not top_candidates:
|
| 129 |
return []
|
| 130 |
-
|
| 131 |
-
# Reranker expects list of dicts with "content" key
|
| 132 |
try:
|
| 133 |
reranked = self.reranker.rerank(
|
| 134 |
query=query,
|
|
@@ -240,36 +240,46 @@ class HybridResultRanker:
|
|
| 240 |
|
| 241 |
return unique
|
| 242 |
|
| 243 |
-
def _calculate_composite_score(self, result: Dict[str, Any]) -> float:
|
| 244 |
"""
|
| 245 |
Calculate composite score from multiple factors.
|
| 246 |
-
|
| 247 |
-
|
| 248 |
-
|
| 249 |
-
|
| 250 |
-
|
| 251 |
-
|
| 252 |
-
|
| 253 |
-
|
| 254 |
-
|
| 255 |
-
|
| 256 |
-
|
| 257 |
-
|
| 258 |
"""
|
| 259 |
-
relevance
|
| 260 |
-
freshness
|
| 261 |
source_quality = result.get("source_quality", 0.6)
|
| 262 |
-
|
| 263 |
-
|
| 264 |
-
|
| 265 |
-
|
| 266 |
-
|
| 267 |
-
|
| 268 |
-
|
| 269 |
-
|
| 270 |
-
|
| 271 |
-
|
| 272 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 273 |
return composite
|
| 274 |
|
| 275 |
def _calculate_freshness(self, published_at: Any) -> float:
|
|
|
|
| 71 |
|
| 72 |
def merge_and_rank(
|
| 73 |
self,
|
| 74 |
+
db_results: List[Any],
|
| 75 |
+
live_results: List[Dict[str, Any]],
|
| 76 |
+
strategy,
|
| 77 |
query: str,
|
| 78 |
final_top_n: int = 10
|
| 79 |
) -> List[Dict[str, Any]]:
|
| 80 |
"""
|
| 81 |
Merge results from database and live sources, then rerank.
|
| 82 |
+
|
| 83 |
+
For temporal queries (live_weight >= 0.7): live results are pre-boosted
|
| 84 |
+
so they appear in the top candidates sent to the reranker.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 85 |
"""
|
| 86 |
+
is_temporal = strategy.live_weight >= 0.7
|
| 87 |
+
|
| 88 |
all_results = []
|
|
|
|
|
|
|
| 89 |
for r in db_results:
|
| 90 |
all_results.append(self._normalize_db_result(r, strategy.db_weight))
|
|
|
|
|
|
|
| 91 |
for r in live_results:
|
| 92 |
all_results.append(self._normalize_live_result(r, strategy.live_weight))
|
| 93 |
+
|
| 94 |
if not all_results:
|
| 95 |
logger.warning("No results to rank")
|
| 96 |
return []
|
| 97 |
+
|
| 98 |
logger.info(
|
| 99 |
f"Merging {len(db_results)} DB + {len(live_results)} live = "
|
| 100 |
+
f"{len(all_results)} total results "
|
| 101 |
+
f"(temporal_boost={'ON' if is_temporal else 'OFF'})"
|
| 102 |
)
|
| 103 |
+
|
|
|
|
| 104 |
unique_results = self._deduplicate(all_results)
|
| 105 |
logger.info(f"After deduplication: {len(unique_results)} unique results")
|
| 106 |
+
|
| 107 |
+
# Composite scoring β temporal queries boost live results heavily
|
| 108 |
for r in unique_results:
|
| 109 |
+
r["composite_score"] = self._calculate_composite_score(r, is_temporal)
|
| 110 |
+
|
|
|
|
| 111 |
unique_results.sort(key=lambda x: x["composite_score"], reverse=True)
|
| 112 |
+
|
| 113 |
+
# For temporal queries: guarantee at least half the reranker candidates
|
| 114 |
+
# are live results so the reranker can actually compare them
|
| 115 |
+
if is_temporal and live_results:
|
| 116 |
+
live_pool = [r for r in unique_results if r.get("source_type") == "live"]
|
| 117 |
+
db_pool = [r for r in unique_results if r.get("source_type") != "live"]
|
| 118 |
+
# Take up to 12 live + 8 DB = 20 candidates
|
| 119 |
+
top_candidates = (live_pool[:12] + db_pool[:8])
|
| 120 |
+
# Re-sort by composite score so reranker gets best candidates first
|
| 121 |
+
top_candidates.sort(key=lambda x: x["composite_score"], reverse=True)
|
| 122 |
+
logger.info(
|
| 123 |
+
f"Temporal candidate pool: {len(live_pool[:12])} live + "
|
| 124 |
+
f"{len(db_pool[:8])} DB = {len(top_candidates)} total"
|
| 125 |
+
)
|
| 126 |
+
else:
|
| 127 |
+
top_candidates = unique_results[:20]
|
| 128 |
+
|
| 129 |
if not top_candidates:
|
| 130 |
return []
|
| 131 |
+
|
|
|
|
| 132 |
try:
|
| 133 |
reranked = self.reranker.rerank(
|
| 134 |
query=query,
|
|
|
|
| 240 |
|
| 241 |
return unique
|
| 242 |
|
| 243 |
+
def _calculate_composite_score(self, result: Dict[str, Any], is_temporal: bool = False) -> float:
|
| 244 |
"""
|
| 245 |
Calculate composite score from multiple factors.
|
| 246 |
+
|
| 247 |
+
Weights for TEMPORAL queries (live_weight >= 0.7):
|
| 248 |
+
- Freshness: 50% (recency is critical)
|
| 249 |
+
- Source type: 25% (live results strongly preferred)
|
| 250 |
+
- Relevance: 15%
|
| 251 |
+
- Source quality: 10%
|
| 252 |
+
|
| 253 |
+
Weights for GENERAL/HISTORICAL queries:
|
| 254 |
+
- Relevance: 40%
|
| 255 |
+
- Freshness: 30%
|
| 256 |
+
- Source quality: 20%
|
| 257 |
+
- Source type: 10%
|
| 258 |
"""
|
| 259 |
+
relevance = result.get("score", 0.5)
|
| 260 |
+
freshness = result.get("freshness_score", 0.5)
|
| 261 |
source_quality = result.get("source_quality", 0.6)
|
| 262 |
+
is_live = result.get("source_type") == "live"
|
| 263 |
+
|
| 264 |
+
if is_temporal:
|
| 265 |
+
# Temporal: freshness + live source dominate
|
| 266 |
+
source_type_boost = 1.0 if is_live else 0.2
|
| 267 |
+
composite = (
|
| 268 |
+
freshness * 0.50 +
|
| 269 |
+
source_type_boost * 0.25 +
|
| 270 |
+
relevance * 0.15 +
|
| 271 |
+
source_quality * 0.10
|
| 272 |
+
)
|
| 273 |
+
else:
|
| 274 |
+
# General/historical: relevance + quality dominate
|
| 275 |
+
source_type_boost = 1.0 if is_live else 0.5
|
| 276 |
+
composite = (
|
| 277 |
+
relevance * 0.40 +
|
| 278 |
+
freshness * 0.30 +
|
| 279 |
+
source_quality * 0.20 +
|
| 280 |
+
source_type_boost * 0.10
|
| 281 |
+
)
|
| 282 |
+
|
| 283 |
return composite
|
| 284 |
|
| 285 |
def _calculate_freshness(self, published_at: Any) -> float:
|
src/core/use_cases/rag_chat_use_case.py
CHANGED
|
@@ -633,19 +633,25 @@ JSON:"""
|
|
| 633 |
logger.info(f"[RAG] After blocked-source filter: {len(quality_docs)} docs")
|
| 634 |
|
| 635 |
# ββ Relevance threshold β drop docs the reranker scored too low βββββββ
|
| 636 |
-
#
|
| 637 |
-
#
|
| 638 |
-
#
|
| 639 |
-
#
|
| 640 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 641 |
above_threshold = [d for d in quality_docs if d.get("rerank_score", 1.0) >= RERANK_THRESHOLD]
|
| 642 |
if above_threshold:
|
| 643 |
quality_docs = above_threshold
|
| 644 |
-
logger.info(f"[RAG] {len(quality_docs)} docs above rerank threshold {RERANK_THRESHOLD}")
|
| 645 |
else:
|
| 646 |
-
# All scores low β keep top 3 anyway rather than returning nothing
|
| 647 |
quality_docs = quality_docs[:3]
|
| 648 |
-
logger.info(f"[RAG] All docs below threshold β keeping top 3
|
| 649 |
|
| 650 |
# ββ Keyword overlap filter β soft filter, keeps docs with ANY query term β
|
| 651 |
# Only drops docs with ZERO overlap AND low rerank score.
|
|
@@ -668,8 +674,10 @@ JSON:"""
|
|
| 668 |
return True
|
| 669 |
# Also keep docs with high rerank/vector score even without exact match
|
| 670 |
# (semantic match via embeddings is valid)
|
|
|
|
| 671 |
score = doc.get("rerank_score") or doc.get("score", 0)
|
| 672 |
-
|
|
|
|
| 673 |
|
| 674 |
overlapping = [d for d in quality_docs if _has_overlap(d)]
|
| 675 |
if overlapping:
|
|
@@ -684,13 +692,35 @@ JSON:"""
|
|
| 684 |
)[:5]
|
| 685 |
logger.info(f"[RAG] No keyword overlap β keeping top 5 by score ({len(quality_docs)} docs)")
|
| 686 |
|
| 687 |
-
#
|
| 688 |
-
|
| 689 |
-
|
| 690 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 691 |
final_pool = final_pool[:top_k]
|
|
|
|
|
|
|
|
|
|
|
|
|
| 692 |
else:
|
| 693 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 694 |
|
| 695 |
# Deduplicate by doc_id
|
| 696 |
seen: set = set()
|
|
@@ -702,8 +732,13 @@ JSON:"""
|
|
| 702 |
seen.add(did)
|
| 703 |
deduped_final.append(d)
|
| 704 |
|
|
|
|
| 705 |
langs_in_result = list({d.get("metadata", {}).get("_search_lang", "en") for d in deduped_final})
|
| 706 |
-
logger.info(
|
|
|
|
|
|
|
|
|
|
|
|
|
| 707 |
|
| 708 |
# ββ Step 8: Token limitation ββββββββββββββββββββββββββββββββββββββββββ
|
| 709 |
return self._limit_context(query, deduped_final)
|
|
|
|
| 633 |
logger.info(f"[RAG] After blocked-source filter: {len(quality_docs)} docs")
|
| 634 |
|
| 635 |
# ββ Relevance threshold β drop docs the reranker scored too low βββββββ
|
| 636 |
+
# Threshold is adaptive based on reranker type:
|
| 637 |
+
# - BGE (normalized 0-1): threshold 0.25
|
| 638 |
+
# - Jina API (raw logits, range ~-0.2 to +0.5): threshold -0.05
|
| 639 |
+
# We detect which reranker was used by checking the score range.
|
| 640 |
+
top_score = max((d.get("rerank_score", 0) for d in quality_docs), default=0)
|
| 641 |
+
if top_score > 0.5:
|
| 642 |
+
# BGE-style normalized scores
|
| 643 |
+
RERANK_THRESHOLD = 0.25
|
| 644 |
+
else:
|
| 645 |
+
# Jina API raw logit scores β much lower range
|
| 646 |
+
RERANK_THRESHOLD = -0.05
|
| 647 |
+
|
| 648 |
above_threshold = [d for d in quality_docs if d.get("rerank_score", 1.0) >= RERANK_THRESHOLD]
|
| 649 |
if above_threshold:
|
| 650 |
quality_docs = above_threshold
|
| 651 |
+
logger.info(f"[RAG] {len(quality_docs)} docs above rerank threshold {RERANK_THRESHOLD:.2f} (top_score={top_score:.3f})")
|
| 652 |
else:
|
|
|
|
| 653 |
quality_docs = quality_docs[:3]
|
| 654 |
+
logger.info(f"[RAG] All docs below threshold {RERANK_THRESHOLD:.2f} β keeping top 3")
|
| 655 |
|
| 656 |
# ββ Keyword overlap filter β soft filter, keeps docs with ANY query term β
|
| 657 |
# Only drops docs with ZERO overlap AND low rerank score.
|
|
|
|
| 674 |
return True
|
| 675 |
# Also keep docs with high rerank/vector score even without exact match
|
| 676 |
# (semantic match via embeddings is valid)
|
| 677 |
+
# Adaptive threshold: BGE uses 0.35, Jina API uses 0.02
|
| 678 |
score = doc.get("rerank_score") or doc.get("score", 0)
|
| 679 |
+
score_threshold = 0.35 if top_score > 0.5 else 0.02
|
| 680 |
+
return score >= score_threshold
|
| 681 |
|
| 682 |
overlapping = [d for d in quality_docs if _has_overlap(d)]
|
| 683 |
if overlapping:
|
|
|
|
| 692 |
)[:5]
|
| 693 |
logger.info(f"[RAG] No keyword overlap β keeping top 5 by score ({len(quality_docs)} docs)")
|
| 694 |
|
| 695 |
+
# ββ Final pool: guarantee live results for temporal queries ββββββββββ
|
| 696 |
+
# For temporal queries (live_weight >= 0.7): ensure at least half the
|
| 697 |
+
# final docs are live results so the LLM gets fresh content.
|
| 698 |
+
# For other queries: guarantee at least 1 non-English result.
|
| 699 |
+
is_temporal_query = (
|
| 700 |
+
use_hybrid and strategy is not None and
|
| 701 |
+
getattr(strategy, 'live_weight', 0) >= 0.7
|
| 702 |
+
)
|
| 703 |
+
|
| 704 |
+
if is_temporal_query:
|
| 705 |
+
live_docs = [d for d in quality_docs if d.get("source_type") == "live" or d.get("is_live")]
|
| 706 |
+
db_docs = [d for d in quality_docs if not (d.get("source_type") == "live" or d.get("is_live"))]
|
| 707 |
+
# Take ceil(top_k/2) live + rest from DB
|
| 708 |
+
live_slots = max(1, (top_k + 1) // 2)
|
| 709 |
+
db_slots = top_k - min(live_slots, len(live_docs))
|
| 710 |
+
final_pool = live_docs[:live_slots] + db_docs[:db_slots]
|
| 711 |
final_pool = final_pool[:top_k]
|
| 712 |
+
logger.info(
|
| 713 |
+
f"[RAG] Temporal final pool: {len(live_docs[:live_slots])} live + "
|
| 714 |
+
f"{len(db_docs[:db_slots])} DB = {len(final_pool)} docs"
|
| 715 |
+
)
|
| 716 |
else:
|
| 717 |
+
# Guarantee at least 1 non-English result if available
|
| 718 |
+
non_english = [d for d in quality_docs if d.get("metadata", {}).get("_search_lang", "en") != "en"]
|
| 719 |
+
if non_english:
|
| 720 |
+
final_pool = [non_english[0]] + [d for d in quality_docs if d is not non_english[0]]
|
| 721 |
+
final_pool = final_pool[:top_k]
|
| 722 |
+
else:
|
| 723 |
+
final_pool = quality_docs[:top_k]
|
| 724 |
|
| 725 |
# Deduplicate by doc_id
|
| 726 |
seen: set = set()
|
|
|
|
| 732 |
seen.add(did)
|
| 733 |
deduped_final.append(d)
|
| 734 |
|
| 735 |
+
live_count = sum(1 for d in deduped_final if d.get("source_type") == "live" or d.get("is_live"))
|
| 736 |
langs_in_result = list({d.get("metadata", {}).get("_search_lang", "en") for d in deduped_final})
|
| 737 |
+
logger.info(
|
| 738 |
+
f"[RAG] Final {len(deduped_final)} docs β "
|
| 739 |
+
f"{live_count} live, {len(deduped_final)-live_count} DB β "
|
| 740 |
+
f"languages: {langs_in_result}"
|
| 741 |
+
)
|
| 742 |
|
| 743 |
# ββ Step 8: Token limitation ββββββββββββββββββββββββββββββββββββββββββ
|
| 744 |
return self._limit_context(query, deduped_final)
|