Peterase commited on
Commit
4d63907
Β·
1 Parent(s): d0668ca

fix: temporal queries now prioritize live results end-to-end

Browse files

Ranker (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], # SearchResult objects from Qdrant
75
- live_results: List[Dict[str, Any]], # Dicts from DuckDuckGo
76
- strategy, # SearchStrategy object
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
- Args:
84
- db_results: SearchResult objects from Qdrant
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
- # 1. Normalize both result types to common format
 
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
- # 3. Apply composite scoring
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
- # 5. Use existing BGE reranker for final ranking
125
- # Take top 20 candidates for reranking (balance quality vs speed)
126
- top_candidates = unique_results[:20]
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
- Factors:
248
- - Relevance (vector/base score): 40%
249
- - Freshness (recency): 30%
250
- - Source quality (reputation): 20%
251
- - Source type boost (live vs DB): 10%
252
-
253
- Args:
254
- result: Normalized result dict
255
-
256
- Returns:
257
- Composite score (0.0 to 1.0+)
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
-
263
- # Boost live results slightly (they're fresher by definition)
264
- source_type_boost = 1.0 if result.get("source_type") == "live" else 0.5
265
-
266
- composite = (
267
- relevance * 0.4 +
268
- freshness * 0.3 +
269
- source_quality * 0.2 +
270
- source_type_boost * 0.1
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
- # Raised from 0.15 β†’ 0.25 based on live testing.
637
- # The airport article (bbc_swahili) was scoring ~0.18 on GERD queries
638
- # because it mentioned "Addis Ababa" in a rankings list.
639
- # 0.25 cuts it while keeping genuinely relevant multilingual content.
640
- RERANK_THRESHOLD = 0.25
 
 
 
 
 
 
 
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 by rerank score")
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
- return score >= 0.35
 
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
- # Guarantee at least 1 non-English result if available
688
- non_english = [d for d in quality_docs if d.get("metadata", {}).get("_search_lang", "en") != "en"]
689
- if non_english:
690
- final_pool = [non_english[0]] + [d for d in quality_docs if d is not non_english[0]]
 
 
 
 
 
 
 
 
 
 
 
 
691
  final_pool = final_pool[:top_k]
 
 
 
 
692
  else:
693
- final_pool = quality_docs[:top_k]
 
 
 
 
 
 
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(f"[RAG] Final {len(deduped_final)} docs β€” languages: {langs_in_result}")
 
 
 
 
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)