4.0 KiB
M2.4 — pgvector repository
| Field | Value |
|---|---|
| Phase | M2 — Projections |
| Size | M — 1–3 days |
| Status | ⬜ Not started |
| Flags | — |
| Spec | inlined below |
| Blocks | M2.3, M2.1 |
Goal
Write the projection into Postgres idempotently, so rebuild is safe to run at any time and produces the same rows.
Facts (inlined — no spec read needed)
async fn upsert_node(&self, node: &MemoryNode, embedding: &[f32]) -> Result<()>;
async fn insert_edges(&self, child: &Sha256Hash, parents: &[Sha256Hash]) -> Result<()>;
async fn search(&self, q: &[f32], levels: &[Level], project: &ProjectId, k: usize)
-> Result<Vec<ScoredNode>>;
async fn parents_of(&self, sha: &Sha256Hash) -> Result<Vec<MemoryNode>>;
async fn clear_project(&self, project: &ProjectId) -> Result<()>;
upsert_node is ON CONFLICT (sha256) DO NOTHING. Content identity means an
identical node is the same node; re-running rebuild must not duplicate or churn
rows. Same for edges on the composite key.
search orders by embedding <=> $1 — cosine distance, matching the
vector_cosine_ops index. Any other operator silently drops to a seq scan.
Edges are inserted after both endpoints exist, or the foreign key rejects them. Rebuild therefore has two passes: all nodes, then all edges. This is not an optimisation; a single-pass insert fails on the first forward reference.
Embeddings are generated in batches of ≤32 (M2.1) and are the expensive part of rebuild — batch across nodes, not per node.
Steps
PgRepo::connect(url)with a pool; run migrations on connect.- Implement the five methods above.
upsert_many(nodes)batching embedding calls at 32 and inserting with a multi-row statement.- Two-pass write: nodes, then edges.
clear_projectdeletes nodes for one project; edges cascade.- Return
ScoredNode { node, distance }— keep the raw distance, do not convert to a similarity score here. The reranker (M3.2) wants the ordering, and a lossy conversion hides ties.
Acceptance
- Upserting the same node twice leaves one row.
- Edges referencing not-yet-inserted parents fail; two-pass write succeeds.
searchreturns nearest-first and respects the level filter.clear_projectremoves only that project.
Verify
Harness: disposable Postgres with the production image; a deterministic fake embedder (hash → fixed vector) so vector assertions are exact.
Integration test — tests/it_pg_repo.rs:
a1_upsert_idempotent— upsert twice, assertcount(*) == 1.a2_two_pass_required— single-pass insert with a forward edge reference fails; two-pass succeeds. Proves the ordering constraint is real.a3_search_orders_by_distance— insert three known vectors, assert returned order matches hand-computed cosine distance.a4_level_filter— L0/L1/L2 present; search withlevels=[L1]returns only L1.a5_project_isolation— two projects with identical text; search one, assert no cross-project results.a6_clear_project_scoped— clear one, assert the other is intact and no orphan edges remain.a7_batching— upsert 100 nodes, assert the embedder saw exactly 4 calls.a8_parents_of— walk a two-level graph, assert the returned parents match.
Command: cargo test -p mem-store pg_repo
False pass:
- Using a random embedder. Assertion 3 becomes untestable and is usually deleted, which removes the only check that the distance operator matches the index.
- Testing
searchwith one project in the database. Assertion 5 is the only one that catches a missingWHERE project = $1, and that bug leaks another project's memory into every answer.
Traps
- Converting distance to similarity in the repo. It loses precision, and the reranker downstream wants candidates in order rather than scores.
- Per-node embedding calls. 412 chunks becomes 412 HTTP round trips where 13 would do, and rebuild goes from seconds to minutes.
Background: DESIGN.md — pgvector, retrieval