Skip to content

Commit db3f588

Browse files
committed
scripts: backfill_memu_embeddings.py for the post-fix catch-up
memU rows written between 2026-05-06 and the embeddings-fix landing have embedding_json=NULL because the bridge wasn't reading the MEMU_EMBEDDING_BASE_URL env var. The fix only applies to new writes; existing rows need a one-time backfill to make recall work against historical memories. Walks memu_memory_items (text source: summary) and memu_resources (text source: caption) for rows with NULL or empty embedding_json, batches them 32 at a time, posts to {MEMU_EMBEDDING_BASE_URL}/embeddings with the OpenAI-compatible payload Ollama and the OpenAI API both accept, and writes the resulting vectors back. Skips rows with NULL or empty text since there's nothing to embed and the endpoint rejects empty input. Idempotent: the WHERE clause filters on the NULL state, so re-runs only touch rows that still need work. Single transaction per batch, so an interrupt loses at most one batch. Flags: - --dry-run: count pending rows without embedding or writing. - --limit N: stop after N rows per table for incremental runs. - --table: backfill only memu_memory_items or memu_resources. - --batch-size, --db, --verbose for ops control. Validated against the running container's memu.sqlite: - --dry-run: 6,009 memu_memory_items + 329 memu_resources pending. - --limit 100 --table memu_memory_items: wrote 100 768-dim vectors in 1.5s. Re-running --dry-run reports 5,909, confirming idempotency. memu_memory_categories doesn't need backfill: those embeddings were populated on 2026-05-05 and never wiped.
1 parent fbb74d7 commit db3f588

1 file changed

Lines changed: 328 additions & 0 deletions

File tree

Lines changed: 328 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,328 @@
1+
#!/usr/bin/env python3
2+
"""Backfill missing embedding_json values in the memU sqlite store.
3+
4+
Why this exists
5+
---------------
6+
memU writes ``embedding_json = NULL`` whenever the embedding profile
7+
is unconfigured at memorize time. From 2026-05-06 to 2026-05-09 the
8+
docker compose env var ``MEMU_EMBEDDING_BASE_URL`` was set on the
9+
agent container, but the in-process code in ``nerve.memory.memu_bridge``
10+
only consulted ``self.config.openai_api_key`` when deciding whether
11+
to register the embedding profile, so memU saw no embedding provider
12+
and stored every new memory with a NULL vector. The result: vector
13+
search at recall time was disabled for those rows, and queries that
14+
should have hit recent memories returned "No relevant memories
15+
found." See ``notes/lessons/2026-05-09-memu-embeddings-not-wired.md``
16+
for the full RCA.
17+
18+
The companion change in this PR teaches ``memu_bridge`` to read
19+
``MEMU_EMBEDDING_BASE_URL`` first; this script catches up the rows
20+
that were already written with NULL.
21+
22+
Targets
23+
-------
24+
Two tables get backfilled:
25+
26+
- ``memu_memory_items``: text source is the ``summary`` column.
27+
- ``memu_resources``: text source is the ``caption`` column. Rows
28+
with NULL ``caption`` are skipped (nothing to embed).
29+
30+
``memu_memory_categories`` does NOT need backfill: those embeddings
31+
were already populated on 2026-05-05 when the categories were first
32+
created and never wiped.
33+
34+
Endpoint
35+
--------
36+
The script reads the same env vars memU does:
37+
38+
- ``MEMU_EMBEDDING_BASE_URL`` (e.g. ``http://embeddings:11434/v1``)
39+
- ``MEMU_EMBEDDING_API_KEY`` (Ollama ignores this; OpenAI requires it)
40+
- ``MEMU_EMBED_MODEL`` (e.g. ``nomic-embed-text``)
41+
42+
It POSTs to ``{base_url}/embeddings`` with the OpenAI-compatible
43+
payload ``{"model": ..., "input": [text1, text2, ...]}``. Ollama and
44+
OpenAI both accept this format.
45+
46+
Idempotent
47+
----------
48+
The script only selects rows where ``embedding_json IS NULL OR
49+
embedding_json = ''``. Re-running it picks up exactly the rows that
50+
still need work (e.g. if a previous run was interrupted or hit a
51+
transient HTTP error).
52+
53+
Usage
54+
-----
55+
::
56+
57+
# See what would happen, no DB writes:
58+
python3 scripts/backfill_memu_embeddings.py --dry-run
59+
60+
# Backfill the first 100 rows (incremental):
61+
python3 scripts/backfill_memu_embeddings.py --limit 100
62+
63+
# Backfill everything:
64+
python3 scripts/backfill_memu_embeddings.py
65+
66+
# Custom DB path:
67+
python3 scripts/backfill_memu_embeddings.py \\
68+
--db /path/to/memu.sqlite
69+
70+
# Backfill only one table:
71+
python3 scripts/backfill_memu_embeddings.py --table memu_memory_items
72+
"""
73+
74+
from __future__ import annotations
75+
76+
import argparse
77+
import json
78+
import logging
79+
import os
80+
import sqlite3
81+
import sys
82+
import time
83+
from pathlib import Path
84+
from typing import Iterable
85+
86+
import httpx
87+
88+
logger = logging.getLogger("backfill_memu_embeddings")
89+
90+
# Per-table backfill spec: (table name, text source column, log label)
91+
TABLES = [
92+
("memu_memory_items", "summary", "memory items"),
93+
("memu_resources", "caption", "resources"),
94+
]
95+
96+
DEFAULT_BATCH_SIZE = 32
97+
DEFAULT_DB = Path("~/.nerve/memu.sqlite").expanduser()
98+
DEFAULT_BASE_URL = "http://embeddings:11434/v1"
99+
DEFAULT_MODEL = "nomic-embed-text"
100+
DEFAULT_API_KEY = "placeholder"
101+
DEFAULT_TIMEOUT = 60.0 # seconds; nomic-embed on CPU is plenty fast within this
102+
103+
104+
def _resolve_endpoint() -> tuple[str, str, str]:
105+
base_url = os.environ.get("MEMU_EMBEDDING_BASE_URL", DEFAULT_BASE_URL).rstrip("/")
106+
api_key = os.environ.get("MEMU_EMBEDDING_API_KEY", DEFAULT_API_KEY) or DEFAULT_API_KEY
107+
model = os.environ.get("MEMU_EMBED_MODEL", DEFAULT_MODEL) or DEFAULT_MODEL
108+
return base_url, api_key, model
109+
110+
111+
def _fetch_pending(
112+
cur: sqlite3.Cursor,
113+
table: str,
114+
text_col: str,
115+
limit: int | None,
116+
) -> list[tuple[str, str]]:
117+
"""Return rows that still need embeddings, as ``(id, text)`` tuples.
118+
119+
Filters out rows where the text source is NULL or empty since
120+
there's nothing to embed for those, and the embedding endpoint
121+
rejects empty strings.
122+
"""
123+
sql = (
124+
f"SELECT id, {text_col} FROM {table} "
125+
f"WHERE (embedding_json IS NULL OR embedding_json = '') "
126+
f" AND {text_col} IS NOT NULL "
127+
f" AND {text_col} != '' "
128+
)
129+
if limit is not None:
130+
sql += f"LIMIT {int(limit)}"
131+
return list(cur.execute(sql))
132+
133+
134+
def _embed_batch(
135+
client: httpx.Client,
136+
base_url: str,
137+
api_key: str,
138+
model: str,
139+
texts: list[str],
140+
) -> list[list[float]]:
141+
"""POST a batch to ``/embeddings`` and return a list of vectors.
142+
143+
Raises on non-2xx HTTP status. The caller controls retry policy.
144+
"""
145+
response = client.post(
146+
f"{base_url}/embeddings",
147+
headers={"Authorization": f"Bearer {api_key}"},
148+
json={"model": model, "input": texts},
149+
)
150+
response.raise_for_status()
151+
payload = response.json()
152+
data = payload.get("data") or []
153+
if len(data) != len(texts):
154+
raise RuntimeError(
155+
f"Embedding endpoint returned {len(data)} vectors for "
156+
f"{len(texts)} inputs (model={model})"
157+
)
158+
# Order is documented to match input order; sort by index defensively.
159+
by_index = sorted(data, key=lambda d: d.get("index", 0))
160+
return [d["embedding"] for d in by_index]
161+
162+
163+
def _backfill_table(
164+
conn: sqlite3.Connection,
165+
client: httpx.Client,
166+
base_url: str,
167+
api_key: str,
168+
model: str,
169+
table: str,
170+
text_col: str,
171+
label: str,
172+
batch_size: int,
173+
limit: int | None,
174+
dry_run: bool,
175+
) -> int:
176+
"""Backfill one table. Returns the number of rows written."""
177+
cur = conn.cursor()
178+
pending = _fetch_pending(cur, table, text_col, limit)
179+
total = len(pending)
180+
if total == 0:
181+
logger.info("%s: nothing to backfill", label)
182+
return 0
183+
184+
# Also report the truly-empty-text count so dry-run is honest.
185+
dropped = cur.execute(
186+
f"SELECT COUNT(*) FROM {table} "
187+
f"WHERE (embedding_json IS NULL OR embedding_json = '') "
188+
f" AND ({text_col} IS NULL OR {text_col} = '')"
189+
).fetchone()[0]
190+
if dropped:
191+
logger.info(
192+
"%s: skipping %d rows with NULL/empty %s (nothing to embed)",
193+
label, dropped, text_col,
194+
)
195+
196+
logger.info("%s: %d rows pending (batch=%d)", label, total, batch_size)
197+
if dry_run:
198+
return 0
199+
200+
written = 0
201+
start = time.monotonic()
202+
for offset in range(0, total, batch_size):
203+
chunk = pending[offset : offset + batch_size]
204+
ids = [row[0] for row in chunk]
205+
texts = [row[1] for row in chunk]
206+
try:
207+
vectors = _embed_batch(client, base_url, api_key, model, texts)
208+
except (httpx.HTTPError, RuntimeError) as exc:
209+
logger.error(
210+
"%s: batch %d-%d failed (%s); skipping and continuing",
211+
label, offset, offset + len(chunk), exc,
212+
)
213+
continue
214+
215+
# Single transaction per batch so an interrupt loses at most
216+
# one batch of work.
217+
with conn:
218+
for row_id, vector in zip(ids, vectors):
219+
conn.execute(
220+
f"UPDATE {table} SET embedding_json = ? WHERE id = ?",
221+
(json.dumps(vector), row_id),
222+
)
223+
written += len(chunk)
224+
225+
if written % 100 < batch_size:
226+
elapsed = time.monotonic() - start
227+
rate = written / elapsed if elapsed > 0 else 0
228+
logger.info(
229+
"%s: %d/%d (%.1f rows/s, ~%.0fs remaining)",
230+
label, written, total, rate,
231+
(total - written) / rate if rate > 0 else 0,
232+
)
233+
234+
elapsed = time.monotonic() - start
235+
logger.info(
236+
"%s: wrote %d embeddings in %.1fs",
237+
label, written, elapsed,
238+
)
239+
return written
240+
241+
242+
def main(argv: list[str] | None = None) -> int:
243+
parser = argparse.ArgumentParser(description=__doc__.split("\n")[0])
244+
parser.add_argument(
245+
"--db",
246+
type=Path,
247+
default=DEFAULT_DB,
248+
help=f"Path to memu.sqlite (default: {DEFAULT_DB})",
249+
)
250+
parser.add_argument(
251+
"--batch-size",
252+
type=int,
253+
default=DEFAULT_BATCH_SIZE,
254+
help=f"Rows per embedding request (default: {DEFAULT_BATCH_SIZE})",
255+
)
256+
parser.add_argument(
257+
"--limit",
258+
type=int,
259+
default=None,
260+
help="Stop after this many rows per table (default: no limit)",
261+
)
262+
parser.add_argument(
263+
"--table",
264+
choices=[t[0] for t in TABLES],
265+
default=None,
266+
help="Only backfill this table (default: all)",
267+
)
268+
parser.add_argument(
269+
"--dry-run",
270+
action="store_true",
271+
help="Count pending rows; do not embed or write",
272+
)
273+
parser.add_argument(
274+
"--verbose",
275+
"-v",
276+
action="store_true",
277+
help="Enable DEBUG logging",
278+
)
279+
args = parser.parse_args(argv)
280+
281+
logging.basicConfig(
282+
level=logging.DEBUG if args.verbose else logging.INFO,
283+
format="%(asctime)s %(levelname)s %(message)s",
284+
)
285+
286+
base_url, api_key, model = _resolve_endpoint()
287+
logger.info(
288+
"endpoint: %s, model: %s%s",
289+
base_url, model,
290+
" (DRY RUN)" if args.dry_run else "",
291+
)
292+
293+
db_path = args.db.expanduser()
294+
if not db_path.exists():
295+
logger.error("memu.sqlite not found at %s", db_path)
296+
return 2
297+
298+
targets: Iterable[tuple[str, str, str]] = (
299+
[t for t in TABLES if t[0] == args.table] if args.table else TABLES
300+
)
301+
302+
conn = sqlite3.connect(str(db_path))
303+
try:
304+
with httpx.Client(timeout=DEFAULT_TIMEOUT) as client:
305+
grand_total = 0
306+
for table, text_col, label in targets:
307+
grand_total += _backfill_table(
308+
conn,
309+
client,
310+
base_url,
311+
api_key,
312+
model,
313+
table,
314+
text_col,
315+
label,
316+
args.batch_size,
317+
args.limit,
318+
args.dry_run,
319+
)
320+
logger.info("%s%d rows", "WOULD WRITE " if args.dry_run else "wrote ", grand_total)
321+
finally:
322+
conn.close()
323+
324+
return 0
325+
326+
327+
if __name__ == "__main__":
328+
sys.exit(main())

0 commit comments

Comments
 (0)