markdown-memory-vec 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
memory_vec/indexer.py ADDED
@@ -0,0 +1,307 @@
1
+ """
2
+ Markdown-to-vector indexing pipeline.
3
+
4
+ Reads Markdown files, splits them into overlapping chunks, computes
5
+ SHA-256 hashes for deduplication, embeds the text, and stores the
6
+ resulting vectors in a :class:`SqliteVecStore`.
7
+
8
+ Key design principles (following OpenClaw memsearch):
9
+ - Markdown files remain the **source of truth**; the vector index is a
10
+ derived acceleration structure.
11
+ - SHA-256 content hashing ensures we never re-embed unchanged chunks.
12
+ - YAML frontmatter is parsed for metadata (importance, type, tags).
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import logging
18
+ import re
19
+ from pathlib import Path
20
+ from typing import Any, Optional
21
+
22
+ import yaml
23
+
24
+ from .interfaces import IEmbedder
25
+ from .store import SqliteVecStore, content_hash
26
+
27
+ logger = logging.getLogger(__name__)
28
+
29
+ # ---------------------------------------------------------------------------
30
+ # Chunking constants (reference: OpenClaw ~400 tokens, 80 overlap)
31
+ # ---------------------------------------------------------------------------
32
+ _APPROX_CHARS_PER_TOKEN = 4 # rough heuristic for English text
33
+ _DEFAULT_CHUNK_TOKENS = 400
34
+ _DEFAULT_OVERLAP_TOKENS = 80
35
+ _CHUNK_SIZE = _DEFAULT_CHUNK_TOKENS * _APPROX_CHARS_PER_TOKEN # ~1600 chars
36
+ _OVERLAP_SIZE = _DEFAULT_OVERLAP_TOKENS * _APPROX_CHARS_PER_TOKEN # ~320 chars
37
+
38
+ # Regex for YAML frontmatter delimited by ---
39
+ _FRONTMATTER_RE = re.compile(r"^---\s*\n(.*?)\n---\s*\n", re.DOTALL)
40
+
41
+
42
+ # ---------------------------------------------------------------------------
43
+ # Frontmatter parsing
44
+ # ---------------------------------------------------------------------------
45
+ def _parse_frontmatter(text: str) -> tuple[dict[str, Any], str]:
46
+ """Extract YAML frontmatter (if any) and return ``(metadata, body)``.
47
+
48
+ If the file has no frontmatter, returns empty metadata and the
49
+ original text.
50
+ """
51
+ match = _FRONTMATTER_RE.match(text)
52
+ if not match:
53
+ return {}, text
54
+ raw_yaml = match.group(1)
55
+ body = text[match.end() :]
56
+ try:
57
+ meta = yaml.safe_load(raw_yaml)
58
+ if not isinstance(meta, dict):
59
+ meta = {}
60
+ except yaml.YAMLError:
61
+ meta = {}
62
+ return meta, body
63
+
64
+
65
+ # ---------------------------------------------------------------------------
66
+ # Chunking
67
+ # ---------------------------------------------------------------------------
68
+ def _chunk_text(
69
+ text: str,
70
+ chunk_size: int = _CHUNK_SIZE,
71
+ overlap_size: int = _OVERLAP_SIZE,
72
+ ) -> list[str]:
73
+ """Split *text* into overlapping chunks.
74
+
75
+ Strategy:
76
+ 1. Split on ``\\n\\n`` (paragraph boundaries) first.
77
+ 2. Accumulate paragraphs until the chunk exceeds *chunk_size*.
78
+ 3. Adjacent chunks share *overlap_size* characters of trailing context.
79
+ 4. Files shorter than *chunk_size* are returned as a single chunk.
80
+ """
81
+ if not text.strip():
82
+ return []
83
+
84
+ # Short text — no need to split
85
+ if len(text) <= chunk_size:
86
+ return [text.strip()]
87
+
88
+ paragraphs = text.split("\n\n")
89
+ chunks: list[str] = []
90
+ current: list[str] = []
91
+ current_len = 0
92
+
93
+ for para in paragraphs:
94
+ para = para.strip()
95
+ if not para:
96
+ continue
97
+ para_len = len(para)
98
+
99
+ if current_len + para_len > chunk_size and current:
100
+ # Flush current chunk
101
+ chunk_text_val = "\n\n".join(current).strip()
102
+ if chunk_text_val:
103
+ chunks.append(chunk_text_val)
104
+
105
+ # Build overlap from the tail of current
106
+ overlap_buf: list[str] = []
107
+ overlap_len = 0
108
+ for p in reversed(current):
109
+ if overlap_len + len(p) > overlap_size:
110
+ break
111
+ overlap_buf.insert(0, p)
112
+ overlap_len += len(p)
113
+ current = overlap_buf
114
+ current_len = overlap_len
115
+
116
+ current.append(para)
117
+ current_len += para_len
118
+
119
+ # Final chunk
120
+ if current:
121
+ chunk_text_val = "\n\n".join(current).strip()
122
+ if chunk_text_val:
123
+ chunks.append(chunk_text_val)
124
+
125
+ return chunks
126
+
127
+
128
+ # ---------------------------------------------------------------------------
129
+ # Indexer
130
+ # ---------------------------------------------------------------------------
131
+ class MemoryIndexer:
132
+ """Build and maintain the vector index for Markdown memory files.
133
+
134
+ Parameters
135
+ ----------
136
+ store:
137
+ The :class:`SqliteVecStore` to write embeddings to.
138
+ embedder:
139
+ An :class:`IEmbedder` implementation used for text → vector.
140
+ memory_root:
141
+ Root directory of the memory files. When provided, all stored
142
+ ``file_path`` values are converted to paths **relative** to this
143
+ root so that the index is portable and free of duplicates caused
144
+ by mixing absolute / relative paths.
145
+ chunk_size:
146
+ Target chunk size in characters (default ~1600 ≈ 400 tokens).
147
+ overlap_size:
148
+ Overlap between adjacent chunks in characters (default ~320 ≈ 80 tokens).
149
+ """
150
+
151
+ def __init__(
152
+ self,
153
+ store: SqliteVecStore,
154
+ embedder: IEmbedder,
155
+ memory_root: Optional[str | Path] = None,
156
+ chunk_size: int = _CHUNK_SIZE,
157
+ overlap_size: int = _OVERLAP_SIZE,
158
+ ) -> None:
159
+ self._store = store
160
+ self._embedder = embedder
161
+ self._memory_root: Optional[Path] = Path(memory_root).resolve() if memory_root else None
162
+ self._chunk_size = chunk_size
163
+ self._overlap_size = overlap_size
164
+
165
+ # -- public API ----------------------------------------------------------
166
+
167
+ def index_file(self, file_path: str | Path) -> int:
168
+ """Index a single Markdown file.
169
+
170
+ Returns the number of *new or updated* chunks that were embedded.
171
+ Chunks whose SHA-256 hash has not changed are skipped.
172
+ """
173
+ path = Path(file_path).resolve()
174
+ if not path.exists() or not path.is_file():
175
+ logger.warning("index_file: %s does not exist or is not a file", path)
176
+ return 0
177
+
178
+ raw_text = path.read_text(encoding="utf-8")
179
+ meta, body = _parse_frontmatter(raw_text)
180
+
181
+ importance = float(meta.get("importance", 0.5))
182
+ memory_type = str(meta.get("type", "semantic"))
183
+ tags: list[str] = meta.get("tags", []) or []
184
+ if not isinstance(tags, list):
185
+ tags = [str(tags)]
186
+
187
+ chunks = _chunk_text(body, self._chunk_size, self._overlap_size)
188
+ if not chunks:
189
+ return 0
190
+
191
+ # Use relative path (relative to memory_root) as the canonical key
192
+ # to avoid duplicates from absolute vs relative path differences.
193
+ if self._memory_root and path.is_relative_to(self._memory_root):
194
+ file_key = str(path.relative_to(self._memory_root))
195
+ else:
196
+ file_key = str(path)
197
+ existing_hashes = self._store.get_hashes_for_file(file_key)
198
+
199
+ new_or_updated = 0
200
+ # Track which chunk indexes we process this round
201
+ current_indexes: set[int] = set()
202
+
203
+ for idx, chunk in enumerate(chunks):
204
+ current_indexes.add(idx)
205
+ chunk_hash = content_hash(chunk)
206
+
207
+ old_hash = existing_hashes.get(idx)
208
+ if old_hash == chunk_hash:
209
+ # Unchanged — skip re-embedding
210
+ continue
211
+
212
+ if old_hash is not None:
213
+ # Hash changed — delete old then re-insert
214
+ self._delete_chunk(file_key, idx)
215
+
216
+ embedding = self._embedder.embed(chunk)
217
+ self._store.insert_embedding(
218
+ embedding=embedding,
219
+ file_path=file_key,
220
+ chunk_index=idx,
221
+ chunk_text=chunk,
222
+ hash_value=chunk_hash,
223
+ importance=importance,
224
+ memory_type=memory_type,
225
+ tags=tags,
226
+ )
227
+ new_or_updated += 1
228
+
229
+ # Remove stale chunks (old chunks beyond new chunk count)
230
+ for old_idx in set(existing_hashes.keys()) - current_indexes:
231
+ self._delete_chunk(file_key, old_idx)
232
+
233
+ if new_or_updated:
234
+ logger.info("Indexed %s: %d chunks embedded (%d total)", path.name, new_or_updated, len(chunks))
235
+ return new_or_updated
236
+
237
+ def index_directory(self, dir_path: str | Path) -> int:
238
+ """Recursively index all ``.md`` files under *dir_path*.
239
+
240
+ Returns the total number of new/updated chunks.
241
+ """
242
+ root = Path(dir_path)
243
+ if not root.is_dir():
244
+ logger.warning("index_directory: %s is not a directory", root)
245
+ return 0
246
+
247
+ total = 0
248
+ for md_file in sorted(root.rglob("*.md")):
249
+ total += self.index_file(md_file)
250
+ return total
251
+
252
+ def reindex_all(self, memory_root: str | Path) -> int:
253
+ """Drop all existing index data and rebuild from scratch.
254
+
255
+ Parameters
256
+ ----------
257
+ memory_root:
258
+ Root directory containing Markdown memory files.
259
+
260
+ Returns the total number of chunks indexed.
261
+ """
262
+ root = Path(memory_root)
263
+ # Clear everything via the store's public clear() method
264
+ self._store.clear()
265
+
266
+ return self.index_directory(root)
267
+
268
+ def remove_file(self, file_path: str | Path) -> int:
269
+ """Remove all indexed chunks for *file_path*.
270
+
271
+ Returns the number of rows deleted.
272
+ """
273
+ path = Path(file_path).resolve()
274
+ if self._memory_root and path.is_relative_to(self._memory_root):
275
+ file_key = str(path.relative_to(self._memory_root))
276
+ else:
277
+ file_key = str(path)
278
+ return self._store.delete_by_file(file_key)
279
+
280
+ # -- internal helpers ----------------------------------------------------
281
+
282
+ def _delete_chunk(self, file_path: str, chunk_index: int) -> None:
283
+ """Delete a specific chunk (by file_path + chunk_index) from both tables."""
284
+ conn = self._store.connection
285
+ rows = conn.execute(
286
+ "SELECT id FROM memory_vec_meta WHERE file_path = ? AND chunk_index = ?",
287
+ (file_path, chunk_index),
288
+ ).fetchall()
289
+ for (rowid,) in rows:
290
+ self._store.delete_embedding(rowid)
291
+
292
+
293
+ # ---------------------------------------------------------------------------
294
+ # Module-level helpers exposed for testing / scripting
295
+ # ---------------------------------------------------------------------------
296
+ def chunk_text(
297
+ text: str,
298
+ chunk_size: int = _CHUNK_SIZE,
299
+ overlap_size: int = _OVERLAP_SIZE,
300
+ ) -> list[str]:
301
+ """Public wrapper around the internal chunking function."""
302
+ return _chunk_text(text, chunk_size, overlap_size)
303
+
304
+
305
+ def parse_frontmatter(text: str) -> tuple[dict[str, Any], str]:
306
+ """Public wrapper around YAML frontmatter parsing."""
307
+ return _parse_frontmatter(text)
@@ -0,0 +1,118 @@
1
+ """
2
+ Interfaces for vector infrastructure components.
3
+
4
+ These are the contracts that concrete implementations (store, embedder) must fulfill.
5
+ This file serves as the integration point between components.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from abc import ABC, abstractmethod
11
+ from dataclasses import dataclass
12
+ from typing import Any, Dict, List, Optional, Sequence
13
+
14
+
15
+ @dataclass
16
+ class VectorSearchResult:
17
+ """Raw result from a vector KNN search."""
18
+
19
+ id: str
20
+ distance: float # Lower = more similar (L2) or higher = more similar (cosine)
21
+ metadata: Dict[str, Any]
22
+
23
+
24
+ @dataclass
25
+ class VectorRecord:
26
+ """A record stored in the vector store."""
27
+
28
+ id: str
29
+ embedding: List[float]
30
+ metadata: Dict[str, Any]
31
+
32
+
33
+ class IEmbedder(ABC):
34
+ """Interface for text embedding models."""
35
+
36
+ @abstractmethod
37
+ def embed(self, text: str) -> List[float]:
38
+ """Embed a single text string into a vector.
39
+
40
+ Args:
41
+ text: The text to embed.
42
+
43
+ Returns:
44
+ A list of floats representing the embedding vector.
45
+ """
46
+ ...
47
+
48
+ @abstractmethod
49
+ def embed_batch(self, texts: List[str]) -> List[List[float]]:
50
+ """Embed multiple texts into vectors.
51
+
52
+ Args:
53
+ texts: A list of texts to embed.
54
+
55
+ Returns:
56
+ A list of embedding vectors.
57
+ """
58
+ ...
59
+
60
+ @property
61
+ @abstractmethod
62
+ def dimension(self) -> int:
63
+ """Return the dimension of the embedding vectors."""
64
+ ...
65
+
66
+
67
+ class ISqliteVecStore(ABC):
68
+ """Interface for sqlite-vec based vector storage.
69
+
70
+ Concrete implementations use sqlite-vec for KNN search over embedding vectors.
71
+ """
72
+
73
+ @abstractmethod
74
+ def add(self, records: Sequence[VectorRecord]) -> None:
75
+ """Add records to the vector store.
76
+
77
+ Args:
78
+ records: Sequence of VectorRecord to add.
79
+ """
80
+ ...
81
+
82
+ @abstractmethod
83
+ def search(
84
+ self,
85
+ query_embedding: List[float],
86
+ top_k: int = 10,
87
+ filter_metadata: Optional[Dict[str, Any]] = None,
88
+ ) -> List[VectorSearchResult]:
89
+ """Perform KNN search.
90
+
91
+ Args:
92
+ query_embedding: The query vector.
93
+ top_k: Number of results to return.
94
+ filter_metadata: Optional metadata filters.
95
+
96
+ Returns:
97
+ List of VectorSearchResult sorted by relevance.
98
+ """
99
+ ...
100
+
101
+ @abstractmethod
102
+ def delete(self, ids: Sequence[str]) -> None:
103
+ """Delete records by IDs.
104
+
105
+ Args:
106
+ ids: IDs of records to delete.
107
+ """
108
+ ...
109
+
110
+ @abstractmethod
111
+ def clear(self) -> None:
112
+ """Delete all records from the store."""
113
+ ...
114
+
115
+ @abstractmethod
116
+ def count(self) -> int:
117
+ """Return the number of records in the store."""
118
+ ...
memory_vec/search.py ADDED
@@ -0,0 +1,234 @@
1
+ """
2
+ Hybrid search service combining semantic similarity, importance weighting, and temporal decay.
3
+
4
+ The hybrid retrieval formula:
5
+ score = α × semantic_similarity(query, memory) # sqlite-vec KNN
6
+ + β × importance_weight(memory.importance) # frontmatter
7
+ + γ × temporal_decay(memory.last_accessed) # frontmatter
8
+
9
+ temporal_decay = exp(-λ × days_since_access)
10
+ Default weights: α=0.6, β=0.2, γ=0.2, λ=0.05
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import logging
16
+ import math
17
+ from dataclasses import dataclass, field
18
+ from datetime import datetime, timezone
19
+ from typing import Any, Dict, List, Optional
20
+
21
+ logger = logging.getLogger(__name__)
22
+
23
+
24
+ @dataclass
25
+ class SearchResult:
26
+ """Result of a hybrid search combining semantic, importance, and temporal signals."""
27
+
28
+ file_path: str
29
+ chunk_text: str
30
+ chunk_index: int
31
+ semantic_score: float # 0.0-1.0, cosine similarity
32
+ importance: float # 0.0-1.0, from frontmatter
33
+ temporal_decay: float # 0.0-1.0, exp(-λ × days)
34
+ hybrid_score: float # Weighted combination
35
+ memory_type: Optional[str] = None
36
+ tags: list[str] = field(default_factory=list)
37
+ last_accessed: Optional[datetime] = None
38
+
39
+
40
+ class HybridSearchService:
41
+ """
42
+ Hybrid search service that combines:
43
+ 1. Semantic similarity (via sqlite-vec KNN search)
44
+ 2. Importance weighting (from memory frontmatter)
45
+ 3. Temporal decay (based on last access time)
46
+
47
+ The combination formula is:
48
+ score = α × semantic + β × importance + γ × temporal_decay
49
+
50
+ All imports of vector infrastructure (ISqliteVecStore, IEmbedder) are optional
51
+ to support graceful degradation when sqlite-vec is not installed.
52
+ """
53
+
54
+ def __init__(
55
+ self,
56
+ vec_store: Any, # ISqliteVecStore — typed as Any for optional import safety
57
+ embedder: Any, # IEmbedder — typed as Any for optional import safety
58
+ alpha: float = 0.6,
59
+ beta: float = 0.2,
60
+ gamma: float = 0.2,
61
+ decay_lambda: float = 0.05,
62
+ ):
63
+ """
64
+ Initialize the hybrid search service.
65
+
66
+ Args:
67
+ vec_store: Vector store implementing ISqliteVecStore interface.
68
+ embedder: Embedding model implementing IEmbedder interface.
69
+ alpha: Weight for semantic similarity (default 0.6).
70
+ beta: Weight for importance score (default 0.2).
71
+ gamma: Weight for temporal decay (default 0.2).
72
+ decay_lambda: Decay rate for temporal scoring (default 0.05).
73
+
74
+ Raises:
75
+ ValueError: If weights don't sum to approximately 1.0.
76
+ """
77
+ weight_sum = alpha + beta + gamma
78
+ if abs(weight_sum - 1.0) > 1e-6:
79
+ raise ValueError(f"Weights must sum to 1.0, got α={alpha} + β={beta} + γ={gamma} = {weight_sum}")
80
+
81
+ self.vec_store = vec_store
82
+ self.embedder = embedder
83
+ self.alpha = alpha
84
+ self.beta = beta
85
+ self.gamma = gamma
86
+ self.decay_lambda = decay_lambda
87
+
88
+ def search(
89
+ self,
90
+ query: str,
91
+ top_k: int = 10,
92
+ memory_type: Optional[str] = None,
93
+ min_score: float = 0.0,
94
+ ) -> list[SearchResult]:
95
+ """
96
+ Perform hybrid search combining semantic, importance, and temporal signals.
97
+
98
+ Args:
99
+ query: The search query text.
100
+ top_k: Maximum number of results to return.
101
+ memory_type: Optional filter by memory type (e.g., "semantic", "episodic").
102
+ min_score: Minimum hybrid score threshold (0.0-1.0).
103
+
104
+ Returns:
105
+ List of SearchResult sorted by hybrid_score descending.
106
+ """
107
+ if not query.strip():
108
+ return []
109
+
110
+ # Step 1: Embed the query
111
+ try:
112
+ query_embedding = self.embedder.embed(query)
113
+ except Exception:
114
+ logger.warning("Failed to embed query, returning empty results", exc_info=True)
115
+ return []
116
+
117
+ # Step 2: KNN search via vector store
118
+ # Request more candidates than top_k to allow for filtering and re-ranking
119
+ candidate_k = min(top_k * 3, 100)
120
+ filter_metadata: Optional[Dict[str, Any]] = None
121
+ if memory_type:
122
+ filter_metadata = {"memory_type": memory_type}
123
+
124
+ try:
125
+ raw_results = self.vec_store.search(
126
+ query_embedding=query_embedding,
127
+ top_k=candidate_k,
128
+ filter_metadata=filter_metadata,
129
+ )
130
+ except Exception:
131
+ logger.warning("Vector search failed, returning empty results", exc_info=True)
132
+ return []
133
+
134
+ # Step 3: Compute hybrid scores
135
+ results: list[SearchResult] = []
136
+ for raw in raw_results:
137
+ metadata = raw.metadata or {}
138
+
139
+ # Extract fields from metadata
140
+ file_path = metadata.get("file_path", "")
141
+ chunk_text = metadata.get("chunk_text", "")
142
+ chunk_index = metadata.get("chunk_index", 0)
143
+ importance = float(metadata.get("importance", 0.5))
144
+ tags = metadata.get("tags", [])
145
+ mem_type = metadata.get("memory_type")
146
+ last_accessed_str = metadata.get("last_accessed")
147
+
148
+ # Parse last_accessed
149
+ last_accessed: Optional[datetime] = None
150
+ if last_accessed_str:
151
+ try:
152
+ last_accessed = datetime.fromisoformat(str(last_accessed_str))
153
+ except (ValueError, TypeError):
154
+ last_accessed = None
155
+
156
+ # Normalize semantic score: convert cosine distance to similarity.
157
+ # sqlite-vec with distance_metric=cosine returns distance in [0, 2]:
158
+ # 0 = identical, 1 = orthogonal, 2 = opposite.
159
+ # Similarity = 1 - distance maps to [-1, 1]; we clamp to [0, 1].
160
+ semantic_score = max(0.0, min(1.0, 1.0 - raw.distance))
161
+
162
+ # Compute temporal decay
163
+ temporal_decay = self.compute_temporal_decay(last_accessed)
164
+
165
+ # Clamp importance to [0, 1]
166
+ importance = max(0.0, min(1.0, importance))
167
+
168
+ # Compute hybrid score
169
+ hybrid_score = self.compute_hybrid_score(semantic_score, importance, temporal_decay)
170
+
171
+ if hybrid_score >= min_score:
172
+ results.append(
173
+ SearchResult(
174
+ file_path=file_path,
175
+ chunk_text=chunk_text,
176
+ chunk_index=chunk_index,
177
+ semantic_score=semantic_score,
178
+ importance=importance,
179
+ temporal_decay=temporal_decay,
180
+ hybrid_score=hybrid_score,
181
+ memory_type=mem_type,
182
+ tags=tags if isinstance(tags, list) else [],
183
+ last_accessed=last_accessed,
184
+ )
185
+ )
186
+
187
+ # Step 4: Sort by hybrid score and return top_k
188
+ results.sort(key=lambda r: r.hybrid_score, reverse=True)
189
+ return results[:top_k]
190
+
191
+ def compute_temporal_decay(self, last_accessed: Optional[datetime]) -> float:
192
+ """
193
+ Compute temporal decay factor: exp(-λ × days_since_access).
194
+
195
+ Args:
196
+ last_accessed: When the memory was last accessed. If None, returns 0.5
197
+ (neutral — neither penalized nor boosted).
198
+
199
+ Returns:
200
+ A float in [0, 1] where 1.0 means "just accessed" and approaches 0.0
201
+ for very old memories.
202
+ """
203
+ if last_accessed is None:
204
+ return 0.5 # Neutral default for memories without access time
205
+
206
+ now = datetime.now(timezone.utc)
207
+
208
+ # Ensure last_accessed is timezone-aware
209
+ if last_accessed.tzinfo is None:
210
+ last_accessed = last_accessed.replace(tzinfo=timezone.utc)
211
+
212
+ delta = now - last_accessed
213
+ days_since_access = max(0.0, delta.total_seconds() / 86400.0)
214
+
215
+ return math.exp(-self.decay_lambda * days_since_access)
216
+
217
+ def compute_hybrid_score(
218
+ self,
219
+ semantic_score: float,
220
+ importance: float,
221
+ temporal_decay: float,
222
+ ) -> float:
223
+ """
224
+ Compute the weighted hybrid score.
225
+
226
+ Args:
227
+ semantic_score: Cosine similarity score [0, 1].
228
+ importance: Importance weight from frontmatter [0, 1].
229
+ temporal_decay: Temporal decay factor [0, 1].
230
+
231
+ Returns:
232
+ Weighted hybrid score = α × semantic + β × importance + γ × temporal_decay.
233
+ """
234
+ return self.alpha * semantic_score + self.beta * importance + self.gamma * temporal_decay