2222
2323import asyncio
2424import logging
25+ import time
2526from typing import Any , Callable , Dict , List , Optional
2627
2728from dotenv import load_dotenv
4243logger = logging .getLogger ("xmem.pipelines.retrieval" )
4344
4445
46+ _CACHE_TTL_SECONDS = 60.0
47+ _LATENCY_SAMPLE_LIMIT = 200
48+
49+
4550# ═══════════════════════════════════════════════════════════════════════════
4651# Tool schemas — These are the "function signatures" exposed to the LLM
4752# ═══════════════════════════════════════════════════════════════════════════
@@ -133,6 +138,9 @@ def __init__(
133138
134139 self .embed_fn = embed_fn
135140 self ._snippet_stores : Dict [str , BaseVectorStore ] = {}
141+ self ._profile_catalog_cache : Dict [str , tuple [float , List [Dict [str , str ]], List [Any ]]] = {}
142+ self ._retrieval_plan_cache : Dict [tuple [str , str , int , str ], tuple [float , AIMessage ]] = {}
143+ self ._latency_samples : Dict [str , List [float ]] = {}
136144
137145 logger .info ("RetrievalPipeline initialized" )
138146
@@ -155,7 +163,7 @@ async def run(
155163 logger .info ("=" * 60 )
156164
157165 # ── Step 0: Fetch available profile catalog for this user ─────
158- profile_catalog , profile_records = self ._fetch_profile_catalog (user_id )
166+ profile_catalog , profile_records = self ._get_profile_catalog (user_id )
159167 catalog_text = self ._format_catalog (profile_catalog )
160168 logger .info ("Available profiles: %s" , catalog_text )
161169
@@ -169,7 +177,11 @@ async def run(
169177 HumanMessage (content = query ),
170178 ]
171179
172- ai_response : AIMessage = await self .model_with_tools .ainvoke (messages )
180+ plan_key = (user_id , query .strip (), top_k , catalog_text )
181+ ai_response = self ._get_cached_retrieval_plan (plan_key )
182+ if ai_response is None :
183+ ai_response = await self .model_with_tools .ainvoke (messages )
184+ self ._cache_retrieval_plan (plan_key , ai_response )
173185 logger .info ("LLM response received (tool_calls=%d)" , len (ai_response .tool_calls or []))
174186
175187 # ── Step 2: Execute tool calls ────────────────────────────────
@@ -237,16 +249,7 @@ async def _process_tool_call(tc):
237249 answer = ai_response .content
238250 logger .info ("LLM answered without tool calls" )
239251
240- if isinstance (answer , list ):
241- parts = []
242- for c in answer :
243- if isinstance (c , dict ) and "text" in c :
244- parts .append (c ["text" ])
245- elif isinstance (c , str ):
246- parts .append (c )
247- else :
248- parts .append (str (c ))
249- answer = "\n " .join (parts )
252+ answer = self ._coerce_answer (answer )
250253
251254 confidence = min (1.0 , len (sources ) * 0.2 ) if sources else 0.1
252255
@@ -263,6 +266,52 @@ async def _process_tool_call(tc):
263266 confidence = confidence ,
264267 )
265268
269+ async def search_raw (
270+ self ,
271+ query : str ,
272+ user_id : str ,
273+ domains : List [str ],
274+ top_k : int = 10 ,
275+ ) -> List [SourceRecord ]:
276+ """Return ranked memory hits without asking the LLM for a retrieval plan."""
277+
278+ domain_set = set (domains )
279+ results : List [SourceRecord ] = []
280+
281+ if "profile" in domain_set :
282+ results .extend (await self ._search_profile_raw (query , user_id , top_k ))
283+ if "temporal" in domain_set :
284+ results .extend (await self ._search_temporal (query , user_id , top_k ))
285+ if "summary" in domain_set :
286+ results .extend (await self ._search_summary (query , user_id , top_k ))
287+ if "snippet" in domain_set :
288+ results .extend (await self ._search_snippet (query , user_id , top_k ))
289+
290+ return sorted (results , key = lambda record : record .score , reverse = True )
291+
292+ async def answer_from_sources (self , query : str , sources : List [SourceRecord ]) -> str :
293+ """Generate an answer from already-fetched sources without tool selection."""
294+
295+ context_text = self ._format_tool_results (sources )
296+ answer_prompt = ANSWER_PROMPT .format (context = context_text , query = query )
297+ final_response = await self .model .ainvoke ([HumanMessage (content = answer_prompt )])
298+ return self ._coerce_answer (final_response .content )
299+
300+ def record_latency (self , mode : str , elapsed_ms : float ) -> None :
301+ """Track bounded latency samples for raw, answer, and agentic modes."""
302+
303+ samples = self ._latency_samples .setdefault (mode , [])
304+ samples .append (float (elapsed_ms ))
305+ if len (samples ) > _LATENCY_SAMPLE_LIMIT :
306+ del samples [0 : len (samples ) - _LATENCY_SAMPLE_LIMIT ]
307+
308+ def get_latency_snapshot (self ) -> Dict [str , Dict [str , float | int ]]:
309+ return {
310+ mode : self ._percentiles (samples )
311+ for mode , samples in self ._latency_samples .items ()
312+ if samples
313+ }
314+
266315 # ------------------------------------------------------------------
267316 # Tool execution
268317 # ------------------------------------------------------------------
@@ -349,6 +398,35 @@ def _search_profile(
349398
350399 # -- Temporal: Neo4j semantic search ───────────────────────────────
351400
401+ async def _search_profile_raw (
402+ self ,
403+ query : str ,
404+ user_id : str ,
405+ top_k : int = 10 ,
406+ ) -> List [SourceRecord ]:
407+ """Semantic profile search for the low-latency raw search endpoint."""
408+
409+ try :
410+ results = await self .vector_store .search_by_text (
411+ query_text = query ,
412+ top_k = top_k ,
413+ filters = {"user_id" : user_id , "domain" : "profile" },
414+ )
415+ except Exception as exc :
416+ logger .warning ("Profile raw search failed, using cached catalog: %s" , exc )
417+ _ , results = self ._get_profile_catalog (user_id )
418+
419+ records = []
420+ for r in results [:top_k ]:
421+ records .append (SourceRecord (
422+ domain = "profile" ,
423+ content = r .content ,
424+ score = r .score ,
425+ metadata = {"id" : r .id , ** r .metadata },
426+ ))
427+ logger .info ("Profile raw [%s]: %d results" , query , len (records ))
428+ return records
429+
352430 async def _search_temporal (
353431 self ,
354432 query : str ,
@@ -486,6 +564,20 @@ async def _search_snippet(
486564 # Profile catalog (tells the LLM what profile keys exist)
487565 # ------------------------------------------------------------------
488566
567+ def _get_profile_catalog (self , user_id : str ):
568+ cached = self ._profile_catalog_cache .get (user_id )
569+ now = time .monotonic ()
570+ if cached and cached [0 ] > now :
571+ return cached [1 ], cached [2 ]
572+
573+ catalog , results = self ._fetch_profile_catalog (user_id )
574+ self ._profile_catalog_cache [user_id ] = (
575+ now + _CACHE_TTL_SECONDS ,
576+ catalog ,
577+ results ,
578+ )
579+ return catalog , results
580+
489581 def _fetch_profile_catalog (self , user_id : str ):
490582 """Fetch all profile entries for a user.
491583
@@ -538,6 +630,58 @@ def _format_catalog(self, catalog: List[Dict[str, str]]) -> str:
538630 lines .append (f" - { t } / { st } " )
539631 return "\n " .join (lines )
540632
633+ def _get_cached_retrieval_plan (
634+ self ,
635+ key : tuple [str , str , int , str ],
636+ ) -> AIMessage | None :
637+ cached = self ._retrieval_plan_cache .get (key )
638+ if not cached :
639+ return None
640+ expires_at , response = cached
641+ if expires_at <= time .monotonic ():
642+ self ._retrieval_plan_cache .pop (key , None )
643+ return None
644+ return response
645+
646+ def _cache_retrieval_plan (
647+ self ,
648+ key : tuple [str , str , int , str ],
649+ response : AIMessage ,
650+ ) -> None :
651+ self ._retrieval_plan_cache [key ] = (
652+ time .monotonic () + _CACHE_TTL_SECONDS ,
653+ response ,
654+ )
655+
656+ def _coerce_answer (self , answer : Any ) -> str :
657+ if isinstance (answer , list ):
658+ parts = []
659+ for c in answer :
660+ if isinstance (c , dict ) and "text" in c :
661+ parts .append (c ["text" ])
662+ elif isinstance (c , str ):
663+ parts .append (c )
664+ else :
665+ parts .append (str (c ))
666+ return "\n " .join (parts )
667+ return str (answer )
668+
669+ def _percentiles (self , samples : List [float ]) -> Dict [str , float | int ]:
670+ ordered = sorted (samples )
671+
672+ def pick (percentile : float ) -> float :
673+ if not ordered :
674+ return 0.0
675+ index = min (len (ordered ) - 1 , round ((len (ordered ) - 1 ) * percentile ))
676+ return round (ordered [index ], 2 )
677+
678+ return {
679+ "count" : len (ordered ),
680+ "p50" : pick (0.50 ),
681+ "p95" : pick (0.95 ),
682+ "p99" : pick (0.99 ),
683+ }
684+
541685 # ------------------------------------------------------------------
542686 # Formatting helpers
543687 # ------------------------------------------------------------------
0 commit comments