Skip to content
This repository was archived by the owner on Jun 3, 2026. It is now read-only.

Commit 26564c6

Browse files
Add low-latency memory search path
1 parent cc7f56e commit 26564c6

8 files changed

Lines changed: 393 additions & 28 deletions

File tree

src/api/app.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@
3333
from src.api.routes.enterprise import router as enterprise_router
3434
from src.api.routes.health import router as health_router
3535
from src.api.routes.memory import router as memory_router
36+
from src.api.routes.memory import search_router as memory_search_router
3637
from src.api.routes.memory import scrape_router as memory_scrape_router
3738
from src.api.routes.memory_graph import router as memory_graph_router
3839
from src.api.routes.scanner import router as scanner_router
@@ -155,6 +156,7 @@ async def lifespan(app: FastAPI):
155156
# ── Routes ────────────────────────────────────────────────────────
156157
app.include_router(health_router)
157158
app.include_router(memory_scrape_router)
159+
app.include_router(memory_search_router)
158160
app.include_router(memory_router)
159161
app.include_router(memory_graph_router)
160162
app.include_router(code_router)

src/api/routes/__init__.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
from .health import router as health_router
22
from .memory import router as memory_router
3+
from .memory import search_router as memory_search_router
34

4-
__all__ = ["health_router", "memory_router"]
5+
__all__ = ["health_router", "memory_router", "memory_search_router"]

src/api/routes/memory.py

Lines changed: 38 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,11 @@
6363
dependencies=[Depends(enforce_rate_limit)],
6464
)
6565

66+
search_router = APIRouter(
67+
tags=["memory"],
68+
dependencies=[Depends(require_ready), Depends(enforce_rate_limit)],
69+
)
70+
6671

6772
# Helpers
6873
def _model_name(model: Any) -> str:
@@ -667,6 +672,7 @@ async def retrieve_memory(req: RetrieveRequest, request: Request, user: dict = D
667672
confidence=result.confidence,
668673
)
669674
elapsed = round((time.perf_counter() - start) * 1000, 2)
675+
pipeline.record_latency("agentic", elapsed)
670676
return _wrap(request, data, elapsed)
671677

672678
except Exception as exc:
@@ -676,10 +682,15 @@ async def retrieve_memory(req: RetrieveRequest, request: Request, user: dict = D
676682

677683

678684
# POST /v1/memory/search
685+
@search_router.post(
686+
"/search",
687+
response_model=APIResponse,
688+
summary="Raw semantic search across memory domains with optional answer synthesis",
689+
)
679690
@router.post(
680691
"/search",
681692
response_model=APIResponse,
682-
summary="Raw semantic search across memory domains (no LLM answer)",
693+
summary="Raw semantic search across memory domains with optional answer synthesis",
683694
)
684695
async def search_memory(req: SearchRequest, request: Request, user: dict = Depends(require_api_key)):
685696
start = time.perf_counter()
@@ -689,17 +700,34 @@ async def search_memory(req: SearchRequest, request: Request, user: dict = Depen
689700
user_id = user.get("username") or user.get("name") or user["id"]
690701

691702
try:
692-
all_results: List[SourceRecord] = []
693-
694-
if "profile" in req.domains:
695-
all_results.extend(_search_profile(pipeline, user_id))
696-
if "temporal" in req.domains:
697-
all_results.extend(_search_temporal(pipeline, req.query, user_id, req.top_k))
698-
if "summary" in req.domains:
699-
all_results.extend(await _search_summary(pipeline, req.query, user_id, req.top_k))
703+
all_results = await pipeline.search_raw(
704+
query=req.query,
705+
user_id=user_id,
706+
domains=req.domains,
707+
top_k=req.top_k,
708+
)
709+
answer = ""
710+
if req.answer:
711+
answer = await pipeline.answer_from_sources(req.query, all_results)
700712

701-
data = SearchResponse(results=all_results, total=len(all_results))
702713
elapsed = round((time.perf_counter() - start) * 1000, 2)
714+
pipeline.record_latency("answer" if req.answer else "raw", elapsed)
715+
data = SearchResponse(
716+
results=[
717+
SourceRecord(
718+
domain=s.domain,
719+
content=s.content,
720+
score=round(s.score, 3),
721+
metadata=s.metadata,
722+
)
723+
for s in all_results
724+
],
725+
total=len(all_results),
726+
answer=answer,
727+
model=_model_name(pipeline.model) if req.answer else "",
728+
confidence=min(1.0, len(all_results) * 0.2) if answer else 0.0,
729+
latency=pipeline.get_latency_snapshot(),
730+
)
703731
return _wrap(request, data, elapsed)
704732

705733
except Exception as exc:

src/api/schemas.py

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,6 @@
77

88
from __future__ import annotations
99

10-
from datetime import datetime
1110
from enum import Enum
1211
from typing import Any, Dict, List, Optional
1312

@@ -159,15 +158,19 @@ class SearchRequest(BaseModel):
159158
..., min_length=1, max_length=256, pattern=r"^[\w.\-@]+$",
160159
)
161160
domains: List[str] = Field(
162-
default=["profile", "temporal", "summary"],
161+
default=["profile", "temporal", "summary", "snippet"],
163162
description="Which memory domains to search",
164163
)
165164
top_k: int = Field(default=10, ge=1, le=100)
165+
answer: bool = Field(
166+
default=False,
167+
description="When true, synthesize an answer from the raw hits without agentic tool selection.",
168+
)
166169

167170
@field_validator("domains")
168171
@classmethod
169172
def validate_domains(cls, v: List[str]) -> List[str]:
170-
allowed = {"profile", "temporal", "summary"}
173+
allowed = {"profile", "temporal", "summary", "snippet"}
171174
for d in v:
172175
if d not in allowed:
173176
raise ValueError(f"Invalid domain '{d}'. Allowed: {allowed}")
@@ -177,6 +180,10 @@ def validate_domains(cls, v: List[str]) -> List[str]:
177180
class SearchResponse(BaseModel):
178181
results: List[SourceRecord] = Field(default_factory=list)
179182
total: int = 0
183+
answer: str = ""
184+
model: str = ""
185+
confidence: float = 0.0
186+
latency: Dict[str, Dict[str, float | int]] = Field(default_factory=dict)
180187

181188

182189
# ── Scrape (extract from shared chat links) ────────────────────────────────

src/pipelines/retrieval.py

Lines changed: 156 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222

2323
import asyncio
2424
import logging
25+
import time
2526
from typing import Any, Callable, Dict, List, Optional
2627

2728
from dotenv import load_dotenv
@@ -42,6 +43,10 @@
4243
logger = 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

Comments
 (0)