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.
- markdown_memory_vec-0.1.0.dist-info/METADATA +219 -0
- markdown_memory_vec-0.1.0.dist-info/RECORD +13 -0
- markdown_memory_vec-0.1.0.dist-info/WHEEL +4 -0
- markdown_memory_vec-0.1.0.dist-info/entry_points.txt +2 -0
- markdown_memory_vec-0.1.0.dist-info/licenses/LICENSE +21 -0
- memory_vec/__init__.py +73 -0
- memory_vec/__main__.py +109 -0
- memory_vec/embedder.py +137 -0
- memory_vec/indexer.py +307 -0
- memory_vec/interfaces.py +118 -0
- memory_vec/search.py +234 -0
- memory_vec/service.py +326 -0
- memory_vec/store.py +470 -0
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)
|
memory_vec/interfaces.py
ADDED
|
@@ -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
|