|
| 1 | +# Run-Grouped Embeddings Design |
| 2 | + |
| 3 | +Status: approved design, not yet implemented. Supersedes the per-message |
| 4 | +assistant embedding scheme currently on this branch (nothing has shipped; the |
| 5 | +index is rebuildable). |
| 6 | + |
| 7 | +## Problem |
| 8 | + |
| 9 | +The current vector index embeds every embeddable message individually. Corpus |
| 10 | +analysis of the live archive (non-automated sessions, 2026-07-05) shows why that |
| 11 | +dilutes retrieval quality: |
| 12 | + |
| 13 | +- ~49.7k user messages vs ~1.095M assistant messages. |
| 14 | +- 909k of those assistant messages carry tool use with p50 120 chars — short, |
| 15 | + procedural narration ("Let me check the file") that is context-poor as a |
| 16 | + standalone semantic unit. |
| 17 | +- Grouped between human turns, assistant messages form ~44k runs (avg 24.7 |
| 18 | + messages/run, p50 10, p99 184, max 14,659). Per-message embedding spends |
| 19 | + most vectors on fragments of long work stretches. |
| 20 | +- 31% of assistant volume is subordinate material: sidechain messages (21.8%) |
| 21 | + and subagent sessions (5.0%) are embedded today with no marker, blended into |
| 22 | + ranking alongside top-level human-driven work. |
| 23 | + |
| 24 | +Goal: embed coherent semantic units so "reconstruct this design decision" |
| 25 | +queries match narrative, not fragments — while keeping subordinate evidence |
| 26 | +searchable but ranked below top-level intent. |
| 27 | + |
| 28 | +## Unit model |
| 29 | + |
| 30 | +**User documents are unchanged**: one document per embeddable user row, with |
| 31 | +today's `u:`/`o:` doc keys, occurrence suffixes, and escaping. |
| 32 | + |
| 33 | +**Assistant content becomes run documents.** A run is a maximal sequence of |
| 34 | +embeddable assistant messages within one session, bounded by: |
| 35 | + |
| 36 | +- embeddable user rows (`role = 'user' AND is_system = 0 AND` not |
| 37 | + system-prefixed per `SystemPrefixSQL`) — the same predicate that defines |
| 38 | + user documents, so boundaries need no new classification; |
| 39 | +- session start/end; |
| 40 | +- transitions of `is_sidechain`: contiguous sidechain blocks form their own runs |
| 41 | + and never mix with non-sidechain messages. |
| 42 | + |
| 43 | +The boundary term is deliberately "embeddable user row", not "human turn": rows |
| 44 | +excluded by the system-prefix filter (interruptions, task notifications, command |
| 45 | +wrappers, continuation banners) do not split runs. |
| 46 | + |
| 47 | +A run of one message degenerates to today's behavior: p50 exchanges look the |
| 48 | +same as the current scheme. |
| 49 | + |
| 50 | +### Run identity |
| 51 | + |
| 52 | +`doc_key = r:<session_id>:<identity of the run's first message>` where the |
| 53 | +first-message identity reuses the existing machinery: percent-escaped |
| 54 | +`source_uuid` with `#<n>` occurrence suffix, ordinal fallback (`ro:` prefix) for |
| 55 | +legacy data without UUIDs. Properties: |
| 56 | + |
| 57 | +- A run that grows a trailing message keeps its doc_key; `content_hash` changes |
| 58 | + and the run re-embeds (the active tail re-embeds per build, which is the |
| 59 | + intended cost). |
| 60 | +- A new user turn landing mid-run after a resync splits the run: the second half |
| 61 | + becomes a new document, the old one shrinks; kit's reconciliation and |
| 62 | + two-phase eviction handle both, unchanged. |
| 63 | + |
| 64 | +### Run content |
| 65 | + |
| 66 | +Message texts joined in ordinal order with a single blank line (`\n\n`). No |
| 67 | +structural markers, role labels, or metadata are injected into the embedded text |
| 68 | +— lineage, ordinals, and sidechain/subagent status live in mirror columns and |
| 69 | +hit metadata only. |
| 70 | + |
| 71 | +## Mirror schema v2 |
| 72 | + |
| 73 | +`vector_messages` becomes a unit mirror (name kept for continuity): |
| 74 | + |
| 75 | +```sql |
| 76 | +CREATE TABLE IF NOT EXISTS vector_messages ( |
| 77 | + doc_key TEXT PRIMARY KEY, |
| 78 | + session_id TEXT NOT NULL, |
| 79 | + source_uuid TEXT NOT NULL DEFAULT '', -- first message of the unit |
| 80 | + ordinal INTEGER NOT NULL, -- ordinal_start (index compat) |
| 81 | + ordinal_end INTEGER NOT NULL, -- == ordinal for user docs |
| 82 | + subordinate INTEGER NOT NULL DEFAULT 0, |
| 83 | + offsets TEXT NOT NULL DEFAULT '', -- JSON, see below |
| 84 | + content TEXT NOT NULL, |
| 85 | + content_hash TEXT NOT NULL, |
| 86 | + embed_gen TEXT |
| 87 | +); |
| 88 | +``` |
| 89 | + |
| 90 | +`offsets` is a JSON array, one entry per member message, in ordinal order: |
| 91 | +`[{"o": ordinal, "r": rune_start, "b": byte_start}, ...]` (ends implied by the |
| 92 | +next entry / content length). Rune offsets map kit chunk windows to messages; |
| 93 | +byte offsets slice snippets without re-decoding. Empty for user docs. |
| 94 | + |
| 95 | +### Subordinate classification |
| 96 | + |
| 97 | +A unit is subordinate when any of: |
| 98 | + |
| 99 | +- its messages have `is_sidechain = 1` (sidechain runs); |
| 100 | +- the session's `relationship_type` is `subagent` or `fork`; |
| 101 | +- the session is parent-linked (`parent_session_id <> ''`) with any relationship |
| 102 | + type other than `continuation` (defensive: covers empty or unknown types). |
| 103 | + |
| 104 | +Continuations are deliberately top-level, deviating from |
| 105 | +`canonicalChildRelationships` (which includes `continuation` to dedupe session |
| 106 | +identity in lists): embedding cares about content provenance, a continuation is |
| 107 | +the same human-driven conversation, its replayed banner is already excluded as |
| 108 | +system-prefixed, and its new content is unique. Forks follow the existing child |
| 109 | +convention because their prefix replays parent content (dedup by downranking; |
| 110 | +fork volume is 0.56% of assistant messages, so index bloat is negligible). |
| 111 | + |
| 112 | +## Versioning and migration |
| 113 | + |
| 114 | +Two independent mechanisms, both required: |
| 115 | + |
| 116 | +1. **Mirror schema version.** New `vector_meta` key `mirror_schema_version` |
| 117 | + (this scheme writes `2`). The write-path `Open` compares it against the |
| 118 | + binary's version: on mismatch — including the key being absent while any |
| 119 | + mirror state exists — it drops and recreates all mirror-owned tables and |
| 120 | + clears the refresh watermark and scope keys, so the next build takes the |
| 121 | + existing first-ever full path. `vectors.db` is disposable by design; |
| 122 | + `sessions.db` is never touched. The read path treats a version mismatch as |
| 123 | + `ErrStale`-equivalent (semantic search reports the index must be rebuilt) |
| 124 | + rather than misreading v1 rows. |
| 125 | +1. **Generation fingerprint.** `vectorGeneration` Params gain |
| 126 | + `doc_unit_scheme = "run_v1"` and `chunk_overlap_chars = <n>` alongside |
| 127 | + `max_input_chars`. The scheme change therefore cuts a new generation |
| 128 | + through the existing building → active → retired lifecycle even if the |
| 129 | + mirror were somehow current, and future overlap tuning is a fingerprint |
| 130 | + change, not a silent drift. |
| 131 | + |
| 132 | +## Chunking |
| 133 | + |
| 134 | +Runs are chunked by the existing `kitvec.Split`. Overlap changes from the |
| 135 | +implicit `maxInputChars / 30` to an explicit `maxInputChars * 15 / 100` (375 |
| 136 | +chars at the default 2500), recorded in the fingerprint as |
| 137 | +`chunk_overlap_chars`. No kit changes required. |
| 138 | + |
| 139 | +**Anchor policy:** a hit's anchor is the message whose rune span contains the |
| 140 | +matched chunk's center rune (`chunk_start + max_runes/2`, clamped to the chunk's |
| 141 | +actual span); if the center falls on a boundary, the earlier message wins. kit's |
| 142 | +`Hit.ChunkIndex` plus `SplitOptions` reproduce the chunk window |
| 143 | +deterministically from the mirrored content. |
| 144 | + |
| 145 | +## Search, ranking, citation |
| 146 | + |
| 147 | +- **Hit shape.** A semantic hit resolves to session + `ordinal_start`.. |
| 148 | + `ordinal_end` + anchor ordinal + a snippet sliced from the matched chunk |
| 149 | + (byte offsets). Hits carry lineage: `subordinate`, `relationship_type`, |
| 150 | + `parent_session_id`, and whether the unit is a sidechain run. User-doc hits |
| 151 | + keep today's single-ordinal shape (range collapses to one ordinal). |
| 152 | +- **`--around` and the context cursor flow anchor on the anchor ordinal** — no |
| 153 | + changes to the message-window APIs on any backend. |
| 154 | +- **Scope filter.** `--scope top|all|subordinate` on semantic/hybrid search |
| 155 | + (API: `scope` param). Default `all`: subordinate content stays discoverable |
| 156 | + but penalized. |
| 157 | +- **Subordinate penalty.** Applied at the merge step as a rank-based adjustment |
| 158 | + (subordinate hits' RRF contributions use `rank + P`, with `P` a small |
| 159 | + constant, initial value 5), not a hard tier and not a score multiplier — RRF |
| 160 | + ranks are the only scale that is comparable across legs. |
| 161 | +- **Hybrid fusion.** The FTS leg stays message-granularity (exact strings, |
| 162 | + commands, filenames). Before RRF, each FTS message hit maps to its |
| 163 | + containing unit (its user doc, or the run whose ordinal range contains it); |
| 164 | + fusion happens at unit granularity. When the FTS side contributes, the exact |
| 165 | + matched message becomes the hit's anchor regardless of chunk center. |
| 166 | +- **FTS-only search is untouched** at message granularity. |
| 167 | + |
| 168 | +## Surface changes |
| 169 | + |
| 170 | +- Search API/CLI hit fields: `ordinal_range`, `anchor`, `subordinate`, |
| 171 | + `relationship` (client regenerated). |
| 172 | +- `embeddings status`/build summaries count units, not messages; docs updated |
| 173 | + (semantic-search.md, semantic-search-internals.md — corpus, doc_key scheme, |
| 174 | + migration, anchor policy sections). |
| 175 | +- finding-history skill template: cite session + ordinal range, use |
| 176 | + `--scope top` when reconstructing decisions, treat subordinate hits as |
| 177 | + supporting evidence requiring parent corroboration. |
| 178 | + |
| 179 | +## Cost estimate |
| 180 | + |
| 181 | +~47k user docs + ~42k run docs ≈ 90k documents, ~250k chunks (15% overlap |
| 182 | +included) vs ~1.14M single-message docs today: roughly 5x fewer encode units for |
| 183 | +the initial build, with long work stretches contributing a handful of narrative |
| 184 | +chunks instead of hundreds of near-duplicate procedural vectors. |
| 185 | + |
| 186 | +## Testing |
| 187 | + |
| 188 | +- Reducer: boundary cases (system-prefixed user rows do not split; sidechain |
| 189 | + transitions do; session-edge runs; single-message runs; empty-content |
| 190 | + messages), offset correctness (rune and byte, multi-byte content). |
| 191 | +- Identity: stable doc_key across appends; split/merge on mid-run user-turn |
| 192 | + insertion; resync ordinal renumbering with UUID keys. |
| 193 | +- Migration: v1 mirror with data → version bump drops/recreates and next build |
| 194 | + is full; read path on mismatched version reports rebuild-required; |
| 195 | + fingerprint change cuts a new generation. |
| 196 | +- Anchoring: chunk-center mapping across message boundaries; FTS-anchored hybrid |
| 197 | + hits. |
| 198 | +- Ranking: subordinate penalty ordering; `--scope` filters; continuation |
| 199 | + classified top-level, fork/subagent/sidechain subordinate; parent-linked |
| 200 | + empty-type subordinate. |
| 201 | +- Backend parity: message-window and FTS behavior unchanged on SQLite/PG/DuckDB |
| 202 | + (vector search itself remains SQLite-local by design). |
| 203 | + |
| 204 | +## Out of scope |
| 205 | + |
| 206 | +- Contextual prefixing (prepending the triggering user turn to run text) — a |
| 207 | + content-synthesis change adoptable later; changes only content_hash. |
| 208 | +- Cross-session dedup of fork-replayed content beyond downranking. |
| 209 | +- Mirror Refresh write-amplification work (tracked separately). |
0 commit comments