2222from . import graph , hot_memory , index_db , retrieval_events
2323from . import strategy as strategy_mod
2424from .embeddings .fusion import rrf_fuse
25- from .models import ClaimStatus , ContextItem , ContextPack , ContextQuality
25+ from .models import (
26+ ClaimStatus ,
27+ ContextItem ,
28+ ContextPack ,
29+ ContextQuality ,
30+ PageStatus ,
31+ )
2632from .scoping import (
2733 ViewerContext ,
2834 filter_hits ,
5157_STRATEGY_POOL_FACTOR = 5
5258_STRATEGY_POOL_MIN = 50
5359
60+ # same sizing for kb.search lifecycle filtering: backends cap before status
61+ # filtering, so without over-fetch a window full of retracted hits under-fills
62+ # the requested limit (#581 / coderabbit).
63+ _LIFECYCLE_POOL_FACTOR = _STRATEGY_POOL_FACTOR
64+ _LIFECYCLE_POOL_MIN = _STRATEGY_POOL_MIN
65+
5466_VALID_BACKENDS = ("auto" , "hybrid" , "embedding" , "fts5" , "substring" )
5567_RERANKER_CACHE : Any | None = None
5668
@@ -469,6 +481,11 @@ def search_kb(
469481 agent = agent ,
470482 )
471483 fetch_limit = scoped_fetch_limit (limit , viewer )
484+ # over-fetch before lifecycle filtering so retracted/archived hits that
485+ # consume the backend window can be replaced by later live candidates.
486+ candidate_limit = max (
487+ fetch_limit * _LIFECYCLE_POOL_FACTOR , _LIFECYCLE_POOL_MIN ,
488+ )
472489 hits : list [tuple [str , str , str , float ]] = []
473490 used = backend_arg
474491
@@ -481,42 +498,45 @@ def search_kb(
481498
482499 if backend_arg in ("auto" , "hybrid" ):
483500 emb = index_db .search_semantic (
484- store .kb_dir , query , limit = fetch_limit * 2 , min_score = min_score ,
501+ store .kb_dir , query , limit = candidate_limit * 2 , min_score = min_score ,
485502 )
486503 try :
487- fts = index_db .search (store .kb_dir , query , limit = fetch_limit * 2 )
504+ fts = index_db .search (store .kb_dir , query , limit = candidate_limit * 2 )
488505 except sqlite3 .Error :
489506 fts = []
490- hits = rrf_fuse (emb , fts , limit = fetch_limit )
507+ hits = rrf_fuse (emb , fts , limit = candidate_limit )
491508 if emb and fts :
492509 used = "hybrid"
493510 elif emb :
494511 used = "embedding"
495512 elif fts :
496513 used = "fts5"
497514 if not hits and backend_arg == "auto" :
498- hits = store .search_substring (query , limit = fetch_limit )
515+ hits = store .search_substring (query , limit = candidate_limit )
499516 used = "substring"
500517 elif backend_arg == "embedding" :
501518 hits = index_db .search_semantic (
502- store .kb_dir , query , limit = fetch_limit , min_score = min_score ,
519+ store .kb_dir , query , limit = candidate_limit , min_score = min_score ,
503520 )
504521 used = "embedding"
505522 elif backend_arg == "fts5" :
506523 try :
507- hits = index_db .search (store .kb_dir , query , limit = fetch_limit )
524+ hits = index_db .search (store .kb_dir , query , limit = candidate_limit )
508525 except sqlite3 .Error :
509526 hits = []
510527 used = "fts5"
511528 else : # substring
512- hits = store .search_substring (query , limit = fetch_limit )
529+ hits = store .search_substring (query , limit = candidate_limit )
513530 used = "substring"
514531
515532 semantic_ok = index_db .semantic_search_available ()
516- scoped = filter_hits (store , hits , viewer , limit = limit )
533+ # scope first without a limit so status filtering can refill the window —
534+ # otherwise a page of retracted hits would leave search under-filled.
535+ scoped = filter_hits (store , hits , viewer , limit = None )
536+ live = _filter_live_hits (store , scoped , limit = limit )
517537 hits_list = [
518538 {"kind" : k , "id" : i , "snippet" : sn , "score" : sc , "backend" : used }
519- for k , i , sn , sc in scoped
539+ for k , i , sn , sc in live
520540 ]
521541 result : dict [str , Any ] = {
522542 "backend" : used ,
@@ -541,6 +561,40 @@ def search_kb(
541561 )
542562
543563
564+ def _filter_live_hits (
565+ store : KBStore ,
566+ hits : list [tuple [str , str , str , float ]],
567+ * ,
568+ limit : int | None = None ,
569+ ) -> list [tuple [str , str , str , float ]]:
570+ """Drop retracted claims and archived pages from search hits.
571+
572+ ``kb.context`` already applies ``_RETRACTED_CLAIM_STATUSES``; ``kb.search``
573+ must do the same or archive/supersede/redact become decorative on the
574+ surface agents use for detail after recall (#581).
575+ """
576+ kept : list [tuple [str , str , str , float ]] = []
577+ for kind , artifact_id , summary , score in hits :
578+ if kind == "claim" :
579+ try :
580+ claim = store .get_claim (artifact_id )
581+ except ArtifactNotFoundError :
582+ continue
583+ if claim .status in _RETRACTED_CLAIM_STATUSES :
584+ continue
585+ elif kind == "page" :
586+ try :
587+ page = store .get_page (artifact_id )
588+ except ArtifactNotFoundError :
589+ continue
590+ if page .status is PageStatus .ARCHIVED :
591+ continue
592+ kept .append ((kind , artifact_id , summary , score ))
593+ if limit is not None and len (kept ) >= limit :
594+ break
595+ return kept
596+
597+
544598def _enrich_summary (store : KBStore , kind : str , artifact_id : str , summary : str ) -> str :
545599 """Return a non-empty summary, falling back to the stored artifact text."""
546600 if summary :
0 commit comments