|
7 | 7 |
|
8 | 8 | import copy |
9 | 9 | import math |
| 10 | +import os |
10 | 11 |
|
| 12 | +from contextlib import suppress |
11 | 13 | from typing import Any |
12 | 14 |
|
13 | 15 | from memos.api.handlers.base_handler import BaseHandler, HandlerDependencies |
14 | 16 | from memos.api.handlers.formatters_handler import rerank_knowledge_mem |
15 | 17 | from memos.api.product_models import APISearchRequest, SearchResponse |
| 18 | +from memos.dream.contextualization import CONTEXT_MEMORY_TYPE |
16 | 19 | from memos.log import get_logger |
17 | 20 | from memos.memories.textual.tree_text_memory.retrieve.retrieve_utils import ( |
18 | 21 | cosine_similarity_matrix, |
|
25 | 28 |
|
26 | 29 | logger = get_logger(__name__) |
27 | 30 |
|
| 31 | +_ENV_CONTEXT_RECALL = "MEMOS_DREAM_CONTEXT_RECALL" |
| 32 | +_ENV_CONTEXT_RECALL_TOP_K = "MEMOS_DREAM_CONTEXT_RECALL_TOP_K" |
| 33 | +_DEFAULT_CONTEXT_RECALL_TOP_K = 2 |
| 34 | + |
| 35 | + |
| 36 | +def _env_enabled(name: str, default: str = "off") -> bool: |
| 37 | + return os.getenv(name, default).strip().lower() not in {"0", "false", "no", "off"} |
| 38 | + |
| 39 | + |
| 40 | +def _env_int(name: str, default: int) -> int: |
| 41 | + with suppress(TypeError, ValueError): |
| 42 | + return int(os.getenv(name, str(default))) |
| 43 | + return default |
| 44 | + |
28 | 45 |
|
29 | 46 | class SearchHandler(BaseHandler): |
30 | 47 | """ |
@@ -71,6 +88,7 @@ def handle_search_memories(self, search_req: APISearchRequest) -> SearchResponse |
71 | 88 | # Search and deduplicate |
72 | 89 | cube_view = self._build_cube_view(search_req_local) |
73 | 90 | results = cube_view.search_memories(search_req_local) |
| 91 | + self._merge_context_recall(results=results, search_req=search_req_local) |
74 | 92 | if not search_req_local.relativity: |
75 | 93 | search_req_local.relativity = 0 |
76 | 94 | self.logger.info(f"[SearchHandler] Relativity filter: {search_req_local.relativity}") |
@@ -102,6 +120,105 @@ def handle_search_memories(self, search_req: APISearchRequest) -> SearchResponse |
102 | 120 | data=results, |
103 | 121 | ) |
104 | 122 |
|
| 123 | + def _merge_context_recall( |
| 124 | + self, *, results: dict[str, Any], search_req: APISearchRequest |
| 125 | + ) -> None: |
| 126 | + if not _env_enabled(_ENV_CONTEXT_RECALL, "off"): |
| 127 | + return |
| 128 | + |
| 129 | + top_k = max(0, _env_int(_ENV_CONTEXT_RECALL_TOP_K, _DEFAULT_CONTEXT_RECALL_TOP_K)) |
| 130 | + if top_k <= 0: |
| 131 | + return |
| 132 | + |
| 133 | + context_buckets = self._recall_context_buckets(search_req=search_req, top_k=top_k) |
| 134 | + if not context_buckets: |
| 135 | + return |
| 136 | + |
| 137 | + results.setdefault("text_mem", []).extend(context_buckets) |
| 138 | + |
| 139 | + def _recall_context_buckets( |
| 140 | + self, *, search_req: APISearchRequest, top_k: int |
| 141 | + ) -> list[dict[str, Any]]: |
| 142 | + graph_db = self.graph_db or getattr(self.searcher, "graph_store", None) |
| 143 | + embedder = self.embedder or getattr(self.searcher, "embedder", None) |
| 144 | + if graph_db is None or embedder is None: |
| 145 | + self.logger.info( |
| 146 | + "[SearchHandler] Context recall skipped: graph_db or embedder unavailable." |
| 147 | + ) |
| 148 | + return [] |
| 149 | + |
| 150 | + try: |
| 151 | + query_embedding = embedder.embed([search_req.query])[0] |
| 152 | + except Exception: |
| 153 | + self.logger.warning("[SearchHandler] Context recall embedding failed.", exc_info=True) |
| 154 | + return [] |
| 155 | + |
| 156 | + buckets: list[dict[str, Any]] = [] |
| 157 | + for cube_id in self._resolve_cube_ids(search_req): |
| 158 | + try: |
| 159 | + hits = graph_db.search_by_embedding( |
| 160 | + query_embedding, |
| 161 | + top_k=top_k, |
| 162 | + scope=CONTEXT_MEMORY_TYPE, |
| 163 | + status="activated", |
| 164 | + user_name=cube_id, |
| 165 | + return_fields=[ |
| 166 | + "memory", |
| 167 | + "key", |
| 168 | + "created_at", |
| 169 | + "updated_at", |
| 170 | + "source", |
| 171 | + "internal_info", |
| 172 | + ], |
| 173 | + ) |
| 174 | + except Exception: |
| 175 | + self.logger.warning( |
| 176 | + "[SearchHandler] Context recall search failed for cube=%s.", |
| 177 | + cube_id, |
| 178 | + exc_info=True, |
| 179 | + ) |
| 180 | + continue |
| 181 | + |
| 182 | + memories = [self._format_context_hit(hit) for hit in hits or [] if hit.get("memory")] |
| 183 | + if not memories: |
| 184 | + continue |
| 185 | + buckets.append( |
| 186 | + { |
| 187 | + "cube_id": cube_id, |
| 188 | + "memories": memories, |
| 189 | + "total_nodes": len(memories), |
| 190 | + } |
| 191 | + ) |
| 192 | + return buckets |
| 193 | + |
| 194 | + @staticmethod |
| 195 | + def _format_context_hit(hit: dict[str, Any]) -> dict[str, Any]: |
| 196 | + context_id = str(hit.get("id", "")) |
| 197 | + score = float(hit.get("score", 0.0) or 0.0) |
| 198 | + metadata = { |
| 199 | + "id": context_id, |
| 200 | + "memory": hit.get("memory", ""), |
| 201 | + "memory_type": CONTEXT_MEMORY_TYPE, |
| 202 | + "source": hit.get("source") or "dream", |
| 203 | + "key": hit.get("key", ""), |
| 204 | + "relativity": score, |
| 205 | + "score": score, |
| 206 | + "embedding": [], |
| 207 | + "sources": [], |
| 208 | + "usage": [], |
| 209 | + "ref_id": f"[{context_id.split('-')[0]}]" if context_id else "[context]", |
| 210 | + } |
| 211 | + for field in ("created_at", "updated_at", "internal_info"): |
| 212 | + if hit.get(field) is not None: |
| 213 | + metadata[field] = hit[field] |
| 214 | + |
| 215 | + return { |
| 216 | + "id": context_id, |
| 217 | + "memory": hit.get("memory", ""), |
| 218 | + "metadata": metadata, |
| 219 | + "ref_id": metadata["ref_id"], |
| 220 | + } |
| 221 | + |
105 | 222 | @staticmethod |
106 | 223 | def _apply_relativity_threshold(results: dict[str, Any], relativity: float) -> dict[str, Any]: |
107 | 224 | if relativity <= 0: |
|
0 commit comments