Skip to content

Commit 9fa4706

Browse files
committed
docs: add run-grouped embeddings design spec
1 parent a6daeea commit 9fa4706

1 file changed

Lines changed: 209 additions & 0 deletions

File tree

Lines changed: 209 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,209 @@
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

Comments
 (0)