sqlite-graph-memory 0.3.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.
- sqlite_graph_memory/__init__.py +26 -0
- sqlite_graph_memory/brain_ask.py +310 -0
- sqlite_graph_memory/index_notes.py +151 -0
- sqlite_graph_memory/mcp_server.py +271 -0
- sqlite_graph_memory/schema.sql +45 -0
- sqlite_graph_memory/turnstate_hook.py +243 -0
- sqlite_graph_memory/turnstate_show.py +85 -0
- sqlite_graph_memory-0.3.0.dist-info/METADATA +452 -0
- sqlite_graph_memory-0.3.0.dist-info/RECORD +12 -0
- sqlite_graph_memory-0.3.0.dist-info/WHEEL +4 -0
- sqlite_graph_memory-0.3.0.dist-info/entry_points.txt +6 -0
- sqlite_graph_memory-0.3.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
"""Graph RAG on SQLite for AI agents — vectors for entry points, hand-written
|
|
2
|
+
``[[wikilinks]]`` as the graph, cross-encoder rerank on top.
|
|
3
|
+
|
|
4
|
+
The package ships four command-line entry points rather than a library API,
|
|
5
|
+
because that is how the pilot is actually used:
|
|
6
|
+
|
|
7
|
+
``sgm-index``
|
|
8
|
+
Walk a folder of markdown notes, extract ``[[wikilinks]]``, embed the
|
|
9
|
+
chunks and write everything into one SQLite file.
|
|
10
|
+
``sgm-ask``
|
|
11
|
+
Answer a question over that file: vector search for entry points, one hop
|
|
12
|
+
along the wikilink graph, then rerank.
|
|
13
|
+
``sgm-turnstate-hook`` / ``sgm-turnstate-show``
|
|
14
|
+
Record and inspect per-session agent memory ("what happened last turn").
|
|
15
|
+
|
|
16
|
+
Importing the submodules directly is supported and stable — the tests do it —
|
|
17
|
+
but the heavy ML dependencies are loaded lazily inside the functions that need
|
|
18
|
+
them, so importing this package costs nothing beyond ``numpy``. Install the
|
|
19
|
+
``embeddings`` extra to get ``sentence-transformers`` and ``torch``; without it
|
|
20
|
+
the indexer and the reranker will tell you what is missing instead of failing
|
|
21
|
+
on an ``ImportError`` at startup.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
__version__ = "0.3.0"
|
|
25
|
+
|
|
26
|
+
__all__ = ["__version__"]
|
|
@@ -0,0 +1,310 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
"""brain_ask.py — vector retrieval + graph expansion + A/B harness over a markdown vault.
|
|
3
|
+
|
|
4
|
+
Pipeline: e5-base dense retrieve (top-60, dedup by file) -> optional 1-hop wikilink
|
|
5
|
+
graph expansion -> cross-encoder rerank -> top-12.
|
|
6
|
+
|
|
7
|
+
USAGE:
|
|
8
|
+
python brain_ask.py "question" # vector-only (Direct memory)
|
|
9
|
+
python brain_ask.py --graph "question" # vector + graph expansion (Associative)
|
|
10
|
+
python brain_ask.py --ab "question" # run BOTH, diff, log to SQLite (ab_recall)
|
|
11
|
+
python brain_ask.py --ask "question" # emit a context bundle for an LLM agent
|
|
12
|
+
|
|
13
|
+
Config (env, all optional):
|
|
14
|
+
BRAIN_INDEX_DIR dir holding the embedding index (default: ./index)
|
|
15
|
+
TURNSTATE_DB SQLite db for A/B telemetry (default: ./turnstate.db)
|
|
16
|
+
BRAIN_ANSWER_OUT file to mirror the answer into (default: ./_brain_answer.txt)
|
|
17
|
+
BRAIN_AB_JSON if set, --ab also writes a structured JSON diff to this path
|
|
18
|
+
"""
|
|
19
|
+
import os, re, sys, pickle, datetime, sqlite3, time
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
import numpy as np
|
|
22
|
+
|
|
23
|
+
# Make stdout/stderr UTF-8 so non-ASCII notes never crash a Windows console.
|
|
24
|
+
for _s in (sys.stdout, sys.stderr):
|
|
25
|
+
try: _s.reconfigure(encoding='utf-8')
|
|
26
|
+
except Exception: pass
|
|
27
|
+
|
|
28
|
+
INDEX_DIR = Path(os.getenv('BRAIN_INDEX_DIR', 'index'))
|
|
29
|
+
AB_DB = Path(os.getenv('TURNSTATE_DB', 'turnstate.db'))
|
|
30
|
+
EMB = INDEX_DIR / '_brain_e5.npy'
|
|
31
|
+
META = INDEX_DIR / '_brain_e5_meta.pkl'
|
|
32
|
+
E5_MODEL = 'intfloat/multilingual-e5-base'
|
|
33
|
+
RERANK_MODEL = 'cross-encoder/mmarco-mMiniLMv2-L12-H384-v1'
|
|
34
|
+
TOPK_RETRIEVE = 60
|
|
35
|
+
TOPN = 12
|
|
36
|
+
GHOPS, GMAX = 15, 40 # graph-expansion: expand from top-GHOPS hits, add at most GMAX neighbours
|
|
37
|
+
|
|
38
|
+
# Known single-token product/tool names for the entity gate (see looks_like_entity).
|
|
39
|
+
# Extend with the tools/projects that appear in YOUR vault.
|
|
40
|
+
ENTITY_TOOLS = {'obsidian', 'syncthing', 'sqlite', 'telegram', 'whatsapp', 'n8n'}
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
_EMBEDDINGS_HINT = (
|
|
45
|
+
"This step needs the optional embedding stack (sentence-transformers, torch).\n"
|
|
46
|
+
"Install it with: pip install 'sqlite-graph-memory[embeddings]'\n"
|
|
47
|
+
"The base install stays small on purpose: the graph half, the SQLite schema\n"
|
|
48
|
+
"and the turnstate memory work without it."
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _require_sentence_transformers():
|
|
53
|
+
"""Import sentence_transformers, or exit with instructions.
|
|
54
|
+
|
|
55
|
+
Raises SystemExit (not ImportError) so the CLI prints one actionable line
|
|
56
|
+
rather than a traceback through site-packages.
|
|
57
|
+
"""
|
|
58
|
+
try:
|
|
59
|
+
import sentence_transformers # noqa: F401
|
|
60
|
+
except ModuleNotFoundError as exc:
|
|
61
|
+
if exc.name not in ("sentence_transformers", "torch"):
|
|
62
|
+
raise
|
|
63
|
+
raise SystemExit(_EMBEDDINGS_HINT) from exc
|
|
64
|
+
return sentence_transformers
|
|
65
|
+
|
|
66
|
+
def pick_device():
|
|
67
|
+
try:
|
|
68
|
+
import torch
|
|
69
|
+
return 'cuda' if torch.cuda.is_available() else 'cpu'
|
|
70
|
+
except Exception:
|
|
71
|
+
return 'cpu'
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def looks_like_entity(q):
|
|
75
|
+
"""True if the query is a NAME / tool / project lookup.
|
|
76
|
+
|
|
77
|
+
Why: in our A/B runs graph expansion helped THEME queries but hurt ENTITY queries
|
|
78
|
+
(a name pulls in every note that links to the person's card — mostly noise).
|
|
79
|
+
CONSERVATIVE: only fires when confident; on doubt returns False so graph stays ON.
|
|
80
|
+
Themes (lowercase common-noun phrases) never trip it; names/tools (capitalised /
|
|
81
|
+
CamelCase / snake_case / known tool token) do. Detection is query-side only."""
|
|
82
|
+
q = (q or '').strip()
|
|
83
|
+
toks = q.split()
|
|
84
|
+
if not toks or len(toks) > 5:
|
|
85
|
+
return False
|
|
86
|
+
for idx, t in enumerate(toks):
|
|
87
|
+
core = t.strip('«».,:;"\'()?!')
|
|
88
|
+
if not core:
|
|
89
|
+
continue
|
|
90
|
+
low = core.lower()
|
|
91
|
+
if low in ENTITY_TOOLS: return True # known product/project name
|
|
92
|
+
if '_' in low: return True # snake_case tool name
|
|
93
|
+
if len(core) >= 4 and any(c.isupper() for c in core[1:]) and any(c.islower() for c in core):
|
|
94
|
+
return True # CamelCase (NotebookLM, PyTorch)
|
|
95
|
+
if core[:1].isupper() and (idx > 0 or len(toks) <= 2):
|
|
96
|
+
if not (core.isupper() and len(core) <= 3): # ignore bare topic-acronyms (AI, RAG, DAO)
|
|
97
|
+
return True # proper noun (Feynman, Turing)
|
|
98
|
+
return False
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
FENCED_CODE_RX = re.compile(r'(?:```[\s\S]*?```|~~~[\s\S]*?~~~)')
|
|
102
|
+
WIKILINK_RX = re.compile(r'\[\[([^\]\|#]+)')
|
|
103
|
+
|
|
104
|
+
def _links_in(path):
|
|
105
|
+
"""Outgoing wikilink TARGETS in a note (alias/heading stripped, code fences ignored)."""
|
|
106
|
+
try: t = Path(path).read_text(encoding='utf-8', errors='ignore')
|
|
107
|
+
except Exception: return []
|
|
108
|
+
t = FENCED_CODE_RX.sub('', t)
|
|
109
|
+
return [m.strip() for m in WIKILINK_RX.findall(t)]
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
# --------------------------------------------------------------------------- pipeline
|
|
114
|
+
# The retrieval pipeline lives in these functions so that anything measuring it
|
|
115
|
+
# (eval/run_eval.py) runs THE SAME CODE the agent runs, instead of a re-implementation
|
|
116
|
+
# that quietly drifts from it. main() below is a CLI over them.
|
|
117
|
+
|
|
118
|
+
def load_index(emb_path=None, meta_path=None):
|
|
119
|
+
"""-> (embedding matrix, meta list). Defaults to the index BRAIN_INDEX_DIR points at."""
|
|
120
|
+
meta = pickle.loads(Path(meta_path or META).read_bytes())
|
|
121
|
+
emb = np.load(emb_path or EMB)
|
|
122
|
+
return emb, meta
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def load_encoder(dev=None):
|
|
126
|
+
_require_sentence_transformers()
|
|
127
|
+
from sentence_transformers import SentenceTransformer
|
|
128
|
+
return SentenceTransformer(E5_MODEL, device=dev or pick_device())
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def load_reranker(dev=None):
|
|
132
|
+
_require_sentence_transformers()
|
|
133
|
+
from sentence_transformers import CrossEncoder
|
|
134
|
+
return CrossEncoder(RERANK_MODEL, device=dev or pick_device())
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def query_sims(enc, emb, query):
|
|
138
|
+
"""Cosine similarity of one query against every chunk. e5 wants the 'query: ' prefix."""
|
|
139
|
+
qv = enc.encode(['query: ' + query], normalize_embeddings=True,
|
|
140
|
+
convert_to_numpy=True)[0].astype('float32')
|
|
141
|
+
return emb @ qv
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def retrieve_candidates(sims, meta, topk=TOPK_RETRIEVE):
|
|
145
|
+
"""Dense retrieve -> best chunk per file -> top-K unique files (as chunk indices)."""
|
|
146
|
+
order, seen = [], set()
|
|
147
|
+
for i in np.argsort(-sims):
|
|
148
|
+
m = meta[i]
|
|
149
|
+
if m['path'] in seen: continue
|
|
150
|
+
seen.add(m['path']); order.append(int(i))
|
|
151
|
+
if len(order) >= topk: break
|
|
152
|
+
return order
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def index_by_basename(meta):
|
|
156
|
+
"""basename(no .md) -> meta indices, used to resolve [[wikilink]] targets to chunks."""
|
|
157
|
+
by_base = {}
|
|
158
|
+
for j, mm in enumerate(meta):
|
|
159
|
+
by_base.setdefault(Path(mm['path']).stem.lower(), []).append(j)
|
|
160
|
+
return by_base
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def expand_1hop(sims, meta, base_order, by_base=None, ghops=GHOPS, gmax=GMAX):
|
|
164
|
+
"""Follow 1-hop OUTGOING wikilinks from the top vector hits and return the neighbour
|
|
165
|
+
chunks to ADD to the candidate set; the reranker decides whether they are relevant.
|
|
166
|
+
|
|
167
|
+
Bounded by ghops/gmax. CRASH-SAFETY: any failure returns [] and the caller degrades to
|
|
168
|
+
vector-only -- worst case you get the old result, never an empty or broken recall."""
|
|
169
|
+
added = []
|
|
170
|
+
if not base_order: return added
|
|
171
|
+
try:
|
|
172
|
+
by_base = by_base or index_by_basename(meta)
|
|
173
|
+
in_order = set(base_order)
|
|
174
|
+
for i in base_order[:ghops]:
|
|
175
|
+
for tgt in _links_in(meta[i]['path']):
|
|
176
|
+
idxs = by_base.get(tgt.lower())
|
|
177
|
+
if not idxs: continue
|
|
178
|
+
best = max(idxs, key=lambda j: sims[j]) # best chunk of neighbour for THIS query
|
|
179
|
+
if best in in_order: continue
|
|
180
|
+
in_order.add(best); added.append(best)
|
|
181
|
+
if len(added) >= gmax: break
|
|
182
|
+
if len(added) >= gmax: break
|
|
183
|
+
except Exception as e:
|
|
184
|
+
sys.stderr.write(("graph-expansion fell back to vector (%s)" % e) + chr(10))
|
|
185
|
+
return []
|
|
186
|
+
return added
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def rerank_candidates(ce, query, cand, meta, topn=TOPN):
|
|
190
|
+
"""Cross-encoder rerank of candidate chunks -> [(chunk index, score)], best first."""
|
|
191
|
+
pairs = [(query, meta[i]['title'] + '. ' + meta[i]['snippet']) for i in cand]
|
|
192
|
+
sc = ce.predict(pairs) if len(pairs) else []
|
|
193
|
+
return sorted(zip(cand, sc), key=lambda x: -x[1])[:topn]
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def main():
|
|
197
|
+
args = sys.argv[1:]
|
|
198
|
+
ask = '--ask' in args
|
|
199
|
+
graph = '--graph' in args; ab = '--ab' in args
|
|
200
|
+
flags = {'--ask', '--graph', '--ab'}
|
|
201
|
+
query = ' '.join(a for a in args if a not in flags)
|
|
202
|
+
if not query or not EMB.exists():
|
|
203
|
+
print('need an embedding index (%s) and a query — run index_notes.py first' % EMB); return
|
|
204
|
+
|
|
205
|
+
dev = pick_device()
|
|
206
|
+
emb, meta = load_index()
|
|
207
|
+
enc = load_encoder(dev)
|
|
208
|
+
sims = query_sims(enc, emb, query)
|
|
209
|
+
|
|
210
|
+
# dense retrieve -> dedup by path (best chunk per file) -> top-K unique files
|
|
211
|
+
order = retrieve_candidates(sims, meta)
|
|
212
|
+
|
|
213
|
+
# Graph expansion: follow 1-hop OUTGOING wikilinks from the top vector hits, pull in
|
|
214
|
+
# those neighbour notes (resolved to already-indexed chunks), then let the reranker
|
|
215
|
+
# decide if they're relevant. Bounded + opt-in (--graph) so base recall is untouched.
|
|
216
|
+
# Serendipity that the reranker keeps honest.
|
|
217
|
+
# CRASH-SAFETY: wrapped so ANY failure degrades to vector-only — worst case you get
|
|
218
|
+
# the old result, never an empty/broken recall.
|
|
219
|
+
# Graph expansion: 1-hop outgoing wikilinks from the top vector hits, bounded and
|
|
220
|
+
# opt-in (--graph / --ab) so base recall is untouched. Serendipity the reranker keeps honest.
|
|
221
|
+
base_order = list(order); added = []
|
|
222
|
+
if (graph or ab) and order:
|
|
223
|
+
added = expand_1hop(sims, meta, base_order)
|
|
224
|
+
graph_order = base_order + added; graph_set = set(added)
|
|
225
|
+
|
|
226
|
+
# rerank on the matched chunk (precise)
|
|
227
|
+
ce = load_reranker(dev)
|
|
228
|
+
def rerank(cand):
|
|
229
|
+
return rerank_candidates(ce, query, cand, meta)
|
|
230
|
+
|
|
231
|
+
# --ab: run BOTH paths in ONE process, diff, log. Answers "does graph REALLY help?"
|
|
232
|
+
if ab:
|
|
233
|
+
A = rerank(base_order); B = rerank(graph_order)
|
|
234
|
+
a_paths = {meta[i]['path'] for i, _ in A}
|
|
235
|
+
promoted = [(r, meta[i]['title']) for r, (i, _) in enumerate(B, 1)
|
|
236
|
+
if i in graph_set] # graph notes that reached top-N
|
|
237
|
+
new_in_B = [(r, meta[i]['title']) for r, (i, _) in enumerate(B, 1)
|
|
238
|
+
if meta[i]['path'] not in a_paths] # any note B surfaced that A missed
|
|
239
|
+
out = [f'A/B RECALL: {query}',
|
|
240
|
+
f'(Direct={len(base_order)} vector / Associative=+{len(added)} graph; '
|
|
241
|
+
f'Associative promoted {len(new_in_B)} new notes into top-{TOPN}, {len(promoted)} of them via graph)', '',
|
|
242
|
+
'--- DIRECT memory (vector) ---']
|
|
243
|
+
for r, (i, sc) in enumerate(A, 1):
|
|
244
|
+
out.append('%2d. [%6.2f] %s' % (r, float(sc), meta[i]['title']))
|
|
245
|
+
out += ['', '--- ASSOCIATIVE memory (vector+graph) ---']
|
|
246
|
+
for r, (i, sc) in enumerate(B, 1):
|
|
247
|
+
mk = (' <== +graph' if i in graph_set else
|
|
248
|
+
(' <== NEW' if meta[i]['path'] not in a_paths else ''))
|
|
249
|
+
out.append('%2d. [%6.2f] %s%s' % (r, float(sc), meta[i]['title'], mk))
|
|
250
|
+
text = '\n'.join(out)
|
|
251
|
+
try:
|
|
252
|
+
con = sqlite3.connect(str(AB_DB), timeout=3.0)
|
|
253
|
+
con.execute("""CREATE TABLE IF NOT EXISTS ab_recall(
|
|
254
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT, ts TEXT, query TEXT,
|
|
255
|
+
cand_added INTEGER, new_in_top INTEGER, promoted_via_graph INTEGER,
|
|
256
|
+
promoted_titles TEXT, source TEXT)""")
|
|
257
|
+
con.execute("INSERT INTO ab_recall(ts,query,cand_added,new_in_top,promoted_via_graph,promoted_titles,source)"
|
|
258
|
+
" VALUES(?,?,?,?,?,?,?)",
|
|
259
|
+
(time.strftime('%Y-%m-%d %H:%M:%S'), query, len(added), len(new_in_B),
|
|
260
|
+
len(promoted), '; '.join(t for _, t in promoted)[:1000],
|
|
261
|
+
os.getenv('AB_SOURCE', 'work')))
|
|
262
|
+
con.commit(); con.close()
|
|
263
|
+
except Exception:
|
|
264
|
+
pass
|
|
265
|
+
jpath = os.getenv('BRAIN_AB_JSON') # structured A/B for a dashboard
|
|
266
|
+
if jpath:
|
|
267
|
+
import json as _json
|
|
268
|
+
def _ser(lst):
|
|
269
|
+
return [{'rank': r, 'title': meta[i]['title'], 'score': round(float(sc), 2),
|
|
270
|
+
'snippet': meta[i].get('snippet', '')[:200],
|
|
271
|
+
'graph': i in graph_set,
|
|
272
|
+
'new': meta[i]['path'] not in a_paths}
|
|
273
|
+
for r, (i, sc) in enumerate(lst, 1)]
|
|
274
|
+
try:
|
|
275
|
+
Path(jpath).write_text(_json.dumps(
|
|
276
|
+
{'query': query, 'added': len(added), 'new_in_top': len(new_in_B),
|
|
277
|
+
'direct': _ser(A), 'associative': _ser(B)}, ensure_ascii=False), encoding='utf-8')
|
|
278
|
+
except Exception:
|
|
279
|
+
pass
|
|
280
|
+
_out = Path(os.getenv('BRAIN_ANSWER_OUT', '_brain_answer.txt'))
|
|
281
|
+
_out.write_text(text, encoding='utf-8'); print(text)
|
|
282
|
+
return
|
|
283
|
+
|
|
284
|
+
# GATE: wiki-graph helps THEME queries, hurts ENTITY/name queries -> skip graph for entities.
|
|
285
|
+
gated = graph and looks_like_entity(query)
|
|
286
|
+
if gated:
|
|
287
|
+
sys.stderr.write("wiki-graph gated OFF (entity-like query) -> Direct only\n")
|
|
288
|
+
order = graph_order if (graph and not gated) else base_order
|
|
289
|
+
reranked = rerank(order)
|
|
290
|
+
scope = 'all' + (' /graph+%d' % len(graph_set) if graph else '')
|
|
291
|
+
lines = [f'QUERY: {query}', f'(scope={scope}; e5 {len(order)} files -> rerank; {len(meta)} chunks)', '']
|
|
292
|
+
if ask:
|
|
293
|
+
lines.append('=== CONTEXT BUNDLE (paste to your agent) ===\n')
|
|
294
|
+
for i, sc in reranked[:8]:
|
|
295
|
+
m = meta[i]
|
|
296
|
+
hdr = "## %s [%s] rr=%.2f%s" % (m['title'], m.get('date', ''), float(sc), ' +graph' if i in graph_set else '')
|
|
297
|
+
lines += [hdr, m['snippet'], '']
|
|
298
|
+
else:
|
|
299
|
+
for rank, (i, sc) in enumerate(reranked, 1):
|
|
300
|
+
m = meta[i]
|
|
301
|
+
gmark = ' +graph' if i in graph_set else ''
|
|
302
|
+
lines.append("%2d. [rr=%6.2f] [%s] %s%s" % (rank, float(sc), m.get('date', ''), m['title'], gmark))
|
|
303
|
+
lines.append(" %s" % m['snippet'][:140])
|
|
304
|
+
_out = Path(os.getenv('BRAIN_ANSWER_OUT', '_brain_answer.txt'))
|
|
305
|
+
_out.write_text('\n'.join(lines), encoding='utf-8')
|
|
306
|
+
print(f'{len(reranked)} hits (scope={scope}) -> {_out}')
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
if __name__ == '__main__':
|
|
310
|
+
main()
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
"""index_notes.py — minimal embedding indexer for a folder of markdown notes.
|
|
3
|
+
|
|
4
|
+
Builds the dense index that brain_ask.py retrieves from:
|
|
5
|
+
index/_brain_e5.npy float32 matrix, one row per chunk (L2-normalized)
|
|
6
|
+
index/_brain_e5_meta.pkl list of dicts: {path, title, snippet, date}
|
|
7
|
+
|
|
8
|
+
Chunking is deliberately simple (fixed-size windows over the note body) — the
|
|
9
|
+
cross-encoder reranker downstream forgives coarse chunk boundaries.
|
|
10
|
+
|
|
11
|
+
USAGE:
|
|
12
|
+
python index_notes.py <notes_dir>
|
|
13
|
+
Config (env, optional):
|
|
14
|
+
BRAIN_INDEX_DIR output dir (default: ./index)
|
|
15
|
+
BRAIN_INDEX_HIDDEN 1 = also index dot-directories (default: skip them,
|
|
16
|
+
so .stversions/.obsidian/.git and Syncthing conflict
|
|
17
|
+
copies never enter the index)
|
|
18
|
+
"""
|
|
19
|
+
import os, re, sys, pickle
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
import numpy as np
|
|
22
|
+
|
|
23
|
+
E5_MODEL = 'intfloat/multilingual-e5-base'
|
|
24
|
+
CHUNK_CHARS = 1200
|
|
25
|
+
CHUNK_OVERLAP = 200
|
|
26
|
+
SNIPPET_CHARS = 600
|
|
27
|
+
|
|
28
|
+
FM_RX = re.compile(r'^---\r?\n(.*?)\r?\n---\r?\n', re.S)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
_EMBEDDINGS_HINT = (
|
|
33
|
+
"This step needs the optional embedding stack (sentence-transformers, torch).\n"
|
|
34
|
+
"Install it with: pip install 'sqlite-graph-memory[embeddings]'\n"
|
|
35
|
+
"The base install stays small on purpose: the graph half, the SQLite schema\n"
|
|
36
|
+
"and the turnstate memory work without it."
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _require_sentence_transformers():
|
|
41
|
+
"""Import sentence_transformers, or exit with instructions.
|
|
42
|
+
|
|
43
|
+
Raises SystemExit (not ImportError) so the CLI prints one actionable line
|
|
44
|
+
rather than a traceback through site-packages.
|
|
45
|
+
"""
|
|
46
|
+
try:
|
|
47
|
+
import sentence_transformers # noqa: F401
|
|
48
|
+
except ModuleNotFoundError as exc:
|
|
49
|
+
if exc.name not in ("sentence_transformers", "torch"):
|
|
50
|
+
raise
|
|
51
|
+
raise SystemExit(_EMBEDDINGS_HINT) from exc
|
|
52
|
+
return sentence_transformers
|
|
53
|
+
|
|
54
|
+
def chunks_of(text):
|
|
55
|
+
step = CHUNK_CHARS - CHUNK_OVERLAP
|
|
56
|
+
for start in range(0, max(len(text), 1), step):
|
|
57
|
+
c = text[start:start + CHUNK_CHARS].strip()
|
|
58
|
+
if len(c) >= 40:
|
|
59
|
+
yield c
|
|
60
|
+
if start + CHUNK_CHARS >= len(text):
|
|
61
|
+
break
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def fm_date(fm):
|
|
65
|
+
m = re.search(r'(?m)^date[^:]*:\s*"?(\d{4}-\d{2}-\d{2})', fm)
|
|
66
|
+
return m.group(1) if m else ''
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
# A synced Obsidian vault is full of markdown that is not a note. `.stversions/`
|
|
70
|
+
# holds Syncthing's old revisions, `*.sync-conflict-*` are its conflict copies,
|
|
71
|
+
# `.obsidian/` is plugin cache and `.git/` is repository internals. Indexing those
|
|
72
|
+
# is worse than noise: a stale revision of a note is semantically almost identical
|
|
73
|
+
# to the live one, so it sits next to it in the top-K and answers with old content.
|
|
74
|
+
CONFLICT_MARKER = 'sync-conflict'
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def iter_notes(notes_dir):
|
|
78
|
+
"""Files that count as a note under ``notes_dir``.
|
|
79
|
+
|
|
80
|
+
Skips dot-directories and Syncthing conflict copies. Set ``BRAIN_INDEX_HIDDEN=1``
|
|
81
|
+
to index hidden folders too — someone will legitimately keep notes in one.
|
|
82
|
+
"""
|
|
83
|
+
root = Path(notes_dir)
|
|
84
|
+
include_hidden = os.getenv('BRAIN_INDEX_HIDDEN', '') not in ('', '0', 'false', 'no')
|
|
85
|
+
out = []
|
|
86
|
+
for p in root.rglob('*.md'):
|
|
87
|
+
rel = p.relative_to(root)
|
|
88
|
+
if not include_hidden and any(part.startswith('.') for part in rel.parts[:-1]):
|
|
89
|
+
continue
|
|
90
|
+
if CONFLICT_MARKER in p.name:
|
|
91
|
+
continue
|
|
92
|
+
out.append(p)
|
|
93
|
+
return sorted(out)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def read_note(path):
|
|
97
|
+
"""Text of one note, or None if it cannot be read.
|
|
98
|
+
|
|
99
|
+
``utf-8-sig`` strips the BOM. Reading BOM'd files as plain utf-8 leaves a
|
|
100
|
+
leading U+FEFF, so the first line is not ``---`` and the frontmatter regex
|
|
101
|
+
below silently sees a note as having none — losing its date and title.
|
|
102
|
+
"""
|
|
103
|
+
try:
|
|
104
|
+
return Path(path).read_text(encoding='utf-8-sig', errors='replace')
|
|
105
|
+
except Exception:
|
|
106
|
+
return None
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def main():
|
|
110
|
+
if len(sys.argv) < 2:
|
|
111
|
+
print(__doc__); return
|
|
112
|
+
notes_dir = Path(sys.argv[1])
|
|
113
|
+
out_dir = Path(os.getenv('BRAIN_INDEX_DIR', 'index'))
|
|
114
|
+
out_dir.mkdir(parents=True, exist_ok=True)
|
|
115
|
+
|
|
116
|
+
meta, texts = [], []
|
|
117
|
+
for p in iter_notes(notes_dir):
|
|
118
|
+
t = read_note(p)
|
|
119
|
+
if t is None:
|
|
120
|
+
continue
|
|
121
|
+
m = FM_RX.match(t)
|
|
122
|
+
fm = m.group(1) if m else ''
|
|
123
|
+
body = t[m.end():] if m else t
|
|
124
|
+
date = fm_date(fm)
|
|
125
|
+
title = p.stem
|
|
126
|
+
for c in chunks_of(body):
|
|
127
|
+
meta.append({'path': str(p), 'title': title,
|
|
128
|
+
'snippet': c[:SNIPPET_CHARS], 'date': date})
|
|
129
|
+
texts.append('passage: ' + c) # e5 expects the passage: prefix
|
|
130
|
+
if not texts:
|
|
131
|
+
print('no indexable .md notes found under', notes_dir); return
|
|
132
|
+
|
|
133
|
+
_require_sentence_transformers()
|
|
134
|
+
from sentence_transformers import SentenceTransformer
|
|
135
|
+
try:
|
|
136
|
+
import torch
|
|
137
|
+
dev = 'cuda' if torch.cuda.is_available() else 'cpu'
|
|
138
|
+
except Exception:
|
|
139
|
+
dev = 'cpu'
|
|
140
|
+
enc = SentenceTransformer(E5_MODEL, device=dev)
|
|
141
|
+
emb = enc.encode(texts, normalize_embeddings=True, convert_to_numpy=True,
|
|
142
|
+
batch_size=64, show_progress_bar=True).astype('float32')
|
|
143
|
+
|
|
144
|
+
np.save(out_dir / '_brain_e5.npy', emb)
|
|
145
|
+
(out_dir / '_brain_e5_meta.pkl').write_bytes(pickle.dumps(meta))
|
|
146
|
+
files = len({m['path'] for m in meta})
|
|
147
|
+
print('indexed %d chunks from %d files -> %s' % (len(meta), files, out_dir))
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
if __name__ == '__main__':
|
|
151
|
+
main()
|