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.
@@ -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()