PoC: 별칭 순수 벡터가 영어 설명형 rank 7~30 (concat 본문 희석으로 미회복) → 별도 벡터 명분. 차단요인 3건: embedding_records FK(787, V011 재생성), CASCADE 대체(명시 DELETE), filter_chunks sentinel strip. plan Task 4.5/4.6. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
22 KiB
별칭 dense 별도 벡터 Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans. Steps use checkbox (
- [ ]).
Goal: chunk.aliases를 별도 dense 벡터(sentinel chunk_id {orig}#alias)로 색인해, dense(e5)가 별칭 순수 신호로 설명형 패러프레이즈를 잡게 한다. 본문 벡터 불변(회귀 안전).
Architecture: ingest.expansion.embed_aliases(default off) on 이면 별칭을 e5 passage 임베딩 → sentinel chunk_id VectorRecord upsert. VectorRetriever 가 sentinel hit 을 원본 chunk_id 로 strip + dedup(2채널 유지, wire 무변경). purge 가 sentinel 벡터도 정리.
Tech Stack: Rust 2024, fastembed e5, LanceVectorStore(MergeInsert keyed on chunk_id), kebab-core/config/app/search.
빌드 규약: CARGO_TARGET_DIR=/build/out/cargo-target/target, -j 4. 결과 redirect + echo "EXIT=$?" 후 커밋. cargo|grep 금지. 브랜치 feat/doc-side-expansion(같은 PR).
참조 spec: docs/superpowers/specs/2026-05-30-dense-alias-vectors-design.md
File Structure
| 파일 | 역할 | Task |
|---|---|---|
crates/kebab-core/src/ids.rs |
ALIAS_SUFFIX 상수 + strip_alias_suffix 헬퍼 |
1 |
crates/kebab-config/src/lib.rs |
IngestExpansionCfg.embed_aliases + env |
2 |
crates/kebab-app/src/lib.rs |
ingest 별칭 임베딩 + sentinel VectorRecord + purge sentinel | 3 |
crates/kebab-search/src/vector.rs |
VectorRetriever sentinel strip + dedup + overfetch↑ | 4 |
docs/, dogfood |
측정 + 문서 | 5 |
Task 1: ALIAS_SUFFIX + strip_alias_suffix (kebab-core)
Files: Modify crates/kebab-core/src/ids.rs (+ lib.rs re-export)
- Step 1: 실패 테스트 —
ids.rs#[cfg(test)] mod tests에:
#[test]
fn strip_alias_suffix_roundtrip() {
assert_eq!(strip_alias_suffix("abc123#alias"), "abc123");
assert_eq!(strip_alias_suffix("abc123"), "abc123"); // 접미 없으면 그대로
assert_eq!(ALIAS_SUFFIX, "#alias");
}
-
Step 2: 실패 확인 —
CARGO_TARGET_DIR=/build/out/cargo-target/target cargo test -p kebab-core strip_alias_suffix -j 4 > /tmp/dv-t1.log 2>&1; echo "EXIT=$?"→ 컴파일 실패. -
Step 3: 구현 —
ids.rs상단(pub 영역)에:
/// 별칭 dense 벡터의 sentinel chunk_id 접미. 본문 벡터(원본 chunk_id)와
/// 별칭 벡터(`{orig}#alias`)를 LanceDB(chunk_id 키)에서 공존시킨다. ChunkId 는
/// blake3 hex(영숫자)라 `#` 미포함 → 충돌 없음. 설계 spec dense-alias-vectors §3.2.
pub const ALIAS_SUFFIX: &str = "#alias";
/// sentinel 별칭 chunk_id 에서 원본 chunk_id 를 복원. 접미 없으면 그대로.
pub fn strip_alias_suffix(id: &str) -> &str {
id.strip_suffix(ALIAS_SUFFIX).unwrap_or(id)
}
crates/kebab-core/src/lib.rs 의 ids re-export 에 ALIAS_SUFFIX, strip_alias_suffix 추가
(pub use ids::{... , ALIAS_SUFFIX, strip_alias_suffix}; — 기존 pub use ids::{...} 목록에 삽입).
-
Step 4: 통과 —
cargo test -p kebab-core strip_alias_suffix -j 4EXIT=0. -
Step 5: 커밋 —
git add crates/kebab-core && git commit -m "feat(core): ALIAS_SUFFIX + strip_alias_suffix (dense alias vectors)"
Task 2: config embed_aliases
Files: Modify crates/kebab-config/src/lib.rs
- Step 1: 실패 테스트 —
#[cfg(test)] mod tests에:
#[test]
fn embed_aliases_defaults_off() {
assert!(!Config::defaults().ingest.expansion.embed_aliases);
}
#[test]
fn embed_aliases_env_override() {
let mut cfg = Config::defaults();
let env: std::collections::HashMap<String, String> =
[("KEBAB_INGEST_EXPANSION_EMBED_ALIASES".to_string(), "true".to_string())]
.into_iter().collect();
cfg.apply_env(&env);
assert!(cfg.ingest.expansion.embed_aliases);
}
-
Step 2: 실패 확인 —
cargo test -p kebab-config embed_aliases -j 4 > /tmp/dv-t2.log 2>&1; echo "EXIT=$?"→ 컴파일 실패. -
Step 3: 구현 —
IngestExpansionCfgstruct 에 필드(기존prompt_version다음):
/// 별칭을 dense 벡터로도 색인(별도 sentinel chunk_id). default off.
/// `enabled`(별칭 생성)와 별개 축 — 둘 다 on 이어야 dense 별칭. 설계 spec
/// dense-alias-vectors §3.3.
pub embed_aliases: bool,
impl Default for IngestExpansionCfg 에 embed_aliases: false, 추가. apply_env 에:
"KEBAB_INGEST_EXPANSION_EMBED_ALIASES" => {
self.ingest.expansion.embed_aliases = parse_bool(v)
}
-
Step 4: 통과 —
cargo test -p kebab-config -j 4EXIT=0 (신규 2 + 기존). -
Step 5: 커밋 —
git add crates/kebab-config && git commit -m "feat(config): ingest.expansion.embed_aliases flag (default off)"
Task 3: ingest 별칭 임베딩 + sentinel VectorRecord + purge
Files: Modify crates/kebab-app/src/lib.rs (embed 블록 ~1309, purge 함수)
- Step 1: 구현 (embed 블록) —
if !chunks.is_empty()블록(현재 body inputs/records 생성)을 확장. body records 생성 후 별칭 records 를 추가로 만들어 같은upsert에 합친다:
기존 body 임베딩(let inputs = chunks.iter().map(|c| EmbeddingInput{text: c.text.as_str(), ...}) → vectors → records)은 그대로. vec_store.upsert(&records) 직전에 추가:
// dense 별칭(별도 벡터, sentinel chunk_id). embed_aliases on +
// 별칭 있는 청크만. 본문 records 는 위에서 이미 생성됨(불변).
let mut all_records = records;
if app.config.ingest.expansion.embed_aliases {
let alias_chunks: Vec<&kebab_core::Chunk> = chunks
.iter()
.filter(|c| c.aliases.as_deref().is_some_and(|a| !a.is_empty()))
.collect();
if !alias_chunks.is_empty() {
let alias_inputs: Vec<EmbeddingInput<'_>> = alias_chunks
.iter()
.map(|c| EmbeddingInput {
text: c.aliases.as_deref().unwrap(),
kind: EmbeddingKind::Document,
})
.collect();
let alias_vectors = emb
.embed(&alias_inputs)
.context("Embedder::embed (alias vectors)")?;
for (c, v) in alias_chunks.iter().zip(alias_vectors) {
let alias_chunk_id = kebab_core::ChunkId(format!(
"{}{}",
c.chunk_id.0,
kebab_core::ALIAS_SUFFIX
));
all_records.push(VectorRecord {
embedding_id: kebab_core::id_for_embedding(
&alias_chunk_id, &model_id, &model_version, dimensions,
),
chunk_id: alias_chunk_id,
vector: v,
doc_id: canonical.doc_id.clone(),
text: c.aliases.clone().unwrap_or_default(),
heading_path: c.heading_path.clone(),
model_id: model_id.clone(),
model_version: model_version.clone(),
dimensions,
});
}
}
}
vec_store.upsert(&all_records).context("VectorStore::upsert")?;
(기존 vec_store.upsert(&records) 줄은 위 upsert(&all_records) 로 대체 — 중복 upsert 금지.)
- Step 2: 구현 (purge sentinel) —
purge_vector_orphans_for_workspace_path의delete_by_chunk_ids(&stale)를, stale + sentinel 을 함께 지우도록:
let mut to_delete = stale.clone();
to_delete.extend(stale.iter().map(|id| format!("{}{}", id, kebab_core::ALIAS_SUFFIX)));
vec_store
.delete_by_chunk_ids(&to_delete)
.context("VectorStore::delete_by_chunk_ids (orphan vector cleanup)")?;
그리고 sweep_deleted_files 의 purge_deleted_workspace_path 후 vec.delete_by_chunk_ids(&chunk_ids)(있는 곳)도 동일하게 {id}#alias 를 포함하도록 확장(해당 위치 grep -n "delete_by_chunk_ids" crates/kebab-app/src/lib.rs 로 모두 찾아 sentinel 추가).
-
Step 3: 빌드 + 회귀 —
CARGO_TARGET_DIR=/build/out/cargo-target/target cargo build -p kebab-app -j 4 > /tmp/dv-t3.log 2>&1; echo "EXIT=$?"EXIT=0.cargo test -p kebab-app -j 4EXIT=0(embed_aliases off 라 기존 무영향). -
Step 4: 커밋 —
git add crates/kebab-app/src/lib.rs && git commit -m "feat(app): 별칭 dense 별도 벡터 색인 + purge (sentinel)"
Task 4: VectorRetriever sentinel strip + dedup
Files: Modify crates/kebab-search/src/vector.rs
- Step 1: 실패 테스트 —
crates/kebab-search/tests/의 기존 vector 테스트 패턴 확인(ls crates/kebab-search/tests/ && grep -rln "VectorRetriever" crates/kebab-search/tests/). store 에 body +{orig}#alias벡터를 넣고, 별칭 벡터에 가까운 쿼리로 검색 시 결과가 원본 chunk_id 1개(중복 없음)인지 검증:
#[test]
fn alias_vector_hit_strips_to_original_and_dedupes() {
// store 에 chunk "c1" body 벡터 + "c1#alias" 별칭 벡터. 쿼리가 둘 다 매칭.
// 결과: 원본 "c1" 1개 (sentinel strip + dedup).
// (기존 vector 테스트 헬퍼로 store fixture 구성 — 벡터/임베딩 mock 패턴 따름.)
let hits = retr.search(&q).unwrap();
let c1 = hits.iter().filter(|h| h.chunk_id.0 == "c1").count();
assert_eq!(c1, 1, "body+alias 둘 다 매칭해도 원본 chunk_id 1개로 dedup");
assert!(!hits.iter().any(|h| h.chunk_id.0.ends_with("#alias")),
"sentinel chunk_id 가 결과에 노출되면 안 된다");
}
정확한 store fixture(벡터 upsert + embed mock)는 기존
tests/의 VectorRetriever 테스트 패턴을 따른다.
-
Step 2: 실패 확인 —
cargo test -p kebab-search alias_vector_hit -j 4 > /tmp/dv-t4.log 2>&1; echo "EXIT=$?"→ 실패(현재 sentinel 노출 + 중복). -
Step 3: 구현 —
vector.rssearch(): (a)VECTOR_OVERFETCH_MULTIPLIER를2→3(별칭 벡터로 dedup 후 k 미달 방지). (b) raw_hits 순회 루프에서 strip + dedup. 기존:let candidate_ids: Vec<&str> = raw_hits.iter().map(|h| h.chunk_id.0.as_str()).collect(); let hydration = hydrate_chunks(&self.sqlite, &candidate_ids)...; ... for hit in raw_hits { let Some(meta) = hydration.get(hit.chunk_id.0.as_str()) else { continue; }; rank = rank.saturating_add(1); hits.push(build_hit(hit, meta, rank, ...)?); if hits.len() >= k { break; } }를 다음으로(원본 id 로 hydrate + seen dedup, build_hit 에 strip 된 chunk_id 반영):
// sentinel 별칭 hit 을 원본 chunk_id 로 strip 해 hydrate. let candidate_ids: Vec<&str> = raw_hits .iter() .map(|h| kebab_core::strip_alias_suffix(h.chunk_id.0.as_str())) .collect(); let hydration = hydrate_chunks(&self.sqlite, &candidate_ids) .context("kb-search vector: hydrate chunk metadata")?; ... let model_id = self.embed.model_id(); let mut hits: Vec<SearchHit> = Vec::with_capacity(k.min(raw_hits.len())); let mut seen: std::collections::HashSet<String> = std::collections::HashSet::new(); let mut rank: u32 = 0; for mut hit in raw_hits { let orig = kebab_core::strip_alias_suffix(hit.chunk_id.0.as_str()).to_string(); if !seen.insert(orig.clone()) { continue; // 같은 원본이 body+alias 둘 다 → 첫(높은 score) 유지 } let Some(meta) = hydration.get(orig.as_str()) else { continue; }; // build_hit 이 원본 chunk_id 를 쓰도록 hit 의 chunk_id 를 strip 본으로 교체. hit.chunk_id = kebab_core::ChunkId(orig); rank = rank.saturating_add(1); hits.push(build_hit(hit, meta, rank, &self.index_version, &model_id, self.snippet_chars)?); if hits.len() >= k { break; } }(
raw_hits가Vec<VectorHit>라for mut hit가능.VectorHit.chunk_id가pub인지 확인 —crates/kebab-core/src/vector.rs:24. pub 아니면 build_hit 시그니처에 override chunk_id 인자 추가.) -
Step 4: 통과 + 회귀 —
cargo test -p kebab-search -j 4 > /tmp/dv-t4.log 2>&1; echo "EXIT=$?"EXIT=0 (신규 + 기존 vector/hybrid). -
Step 5: 커밋 —
git add crates/kebab-search/src/vector.rs crates/kebab-search/tests && git commit -m "feat(search): VectorRetriever sentinel 별칭 strip + dedup"
Task 4.5: V0XX — embedding_records FK 제거 (breaking) + CASCADE 대체
배경 (spec §3.5): sentinel chunk_id 는 chunks 에 없어 embedding_records.chunk_id REFERENCES chunks(chunk_id) ON DELETE CASCADE(V001:100) FK 를 위반(SQLite 787) → ingest 에러. SQLite 는 ALTER
로 FK 못 지워 테이블 재생성. CASCADE 사라지면 orphan 정리를 명시 DELETE 로 대체.
Files: Create migrations/V010__drop_embedding_records_fk.sql (또는 현재 최신 번호+1 확인:
ls migrations/ → 최신이 V010__chunk_aliases.sql 이면 V011), Modify crates/kebab-store-sqlite/src/documents.rs(put_chunks), crates/kebab-store-sqlite/src/store.rs(purge 경로)
-
Step 1: 최신 migration 번호 확인 —
ls migrations/. doc-side expansion 이 V010__chunk_aliases.sql 을 추가했으므로 신규는 V011. 파일명V011__drop_embedding_records_fk.sql. -
Step 2: migration 작성 —
embedding_records를 FK 없이 재생성(V003 의 status/vector_committed 컬럼 + 모든 인덱스 보존). FK 외 스키마는 동일:
-- V011__drop_embedding_records_fk.sql — embedding_records.chunk_id FK 제거.
-- sentinel chunk_id({orig}#alias, chunks 에 없는 id) 벡터를 허용하기 위함
-- (설계 spec 2026-05-30-dense-alias-vectors-design.md §3.5-1). SQLite 는 ALTER
-- 로 FK 제거 불가 → 테이블 재생성. status/vector_committed(V003) + 인덱스 보존.
-- CASCADE 제거분은 put_chunks/purge 의 명시 DELETE 로 대체(§3.5-2).
PRAGMA foreign_keys=OFF;
CREATE TABLE embedding_records_new (
embedding_id TEXT PRIMARY KEY,
chunk_id TEXT NOT NULL, -- FK 제거 (was REFERENCES chunks ON DELETE CASCADE)
model_id TEXT NOT NULL,
model_version TEXT NOT NULL,
dimensions INTEGER NOT NULL,
lance_table TEXT NOT NULL,
created_at TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'pending',
vector_committed INTEGER NOT NULL DEFAULT 0,
UNIQUE(chunk_id, model_id, model_version, dimensions)
);
INSERT INTO embedding_records_new
SELECT embedding_id, chunk_id, model_id, model_version, dimensions,
lance_table, created_at, status, vector_committed
FROM embedding_records;
DROP TABLE embedding_records;
ALTER TABLE embedding_records_new RENAME TO embedding_records;
CREATE INDEX idx_embed_chunk ON embedding_records(chunk_id);
CREATE INDEX idx_embed_model ON embedding_records(model_id, model_version, dimensions);
CREATE INDEX idx_embed_status ON embedding_records(status);
PRAGMA foreign_keys=ON;
UPDATE kv SET value = CAST(CAST(value AS INTEGER) + 1 AS TEXT) WHERE key = 'corpus_revision';
⚠️
chunks_bd_tombstone_embeddingstrigger(V003)는 그대로 유지. FK 제거 후 tombstone 이 실제 보존됨 (CASCADE 가 즉시 안 지움) — 명시 DELETE(Step 3)가 정리를 담당.
- Step 3: CASCADE 대체 — 명시 DELETE — chunk 삭제 경로에서 embedding_records 를 명시 정리.
crates/kebab-store-sqlite/src/documents.rsput_chunks(DELETE-then-INSERT, 라인 101DELETE FROM chunks WHERE doc_id=?직전/직후): 해당 doc 의 chunk_id +{id}#aliasembedding_records 삭제:
// CASCADE 제거(V011) 대체: 이 doc 의 chunk 임베딩 레코드를 명시 정리.
// 원본 + sentinel({id}#alias) 둘 다. (별칭 벡터는 chunks FK 가 없어 자동 정리 안 됨.)
tx.execute(
"DELETE FROM embedding_records WHERE chunk_id IN \
(SELECT chunk_id FROM chunks WHERE doc_id=?1 \
UNION SELECT chunk_id||'#alias' FROM chunks WHERE doc_id=?1)",
params![doc.0],
).map_err(StoreError::from)?;
(이 DELETE 는 DELETE FROM chunks 전에 실행 — chunks 가 지워지면 서브쿼리가 빈 결과.)
crates/kebab-store-sqlite/src/store.rs 의 purge_orphan_at_workspace_path(라인 ~631 DELETE FROM documents)·purge_deleted_workspace_path 도 동일하게, chunks 삭제 전 수집한 chunk_id + sentinel 을
DELETE FROM embedding_records WHERE chunk_id IN (...) 로 정리. (grep -n "DELETE FROM documents\|DELETE FROM chunks" crates/kebab-store-sqlite/src/store.rs 로 경로 확인.)
-
Step 4: 테스트 —
crates/kebab-store-sqlite/tests/에:- sentinel chunk_id embedding_records INSERT 가 FK 위반 없이 성공(V011 후).
- put_chunks 재호출 시 기존 embedding_records(원본+sentinel) 정리 → orphan 0.
Run:
CARGO_TARGET_DIR=/build/out/cargo-target/target cargo test -p kebab-store-sqlite -j 4 > /tmp/dv-t45.log 2>&1; echo "EXIT=$?"EXIT=0 + 기존 corpus_revision baseline(V011 bump 로 +1) 갱신 필요 시 갱신.
-
Step 5: 커밋 —
git add migrations/V011__drop_embedding_records_fk.sql crates/kebab-store-sqlite && git commit -m "feat(store): V011 embedding_records FK 제거 + CASCADE 대체 명시 DELETE (sentinel 별칭 벡터)"
Task 4.6: filter_chunks sentinel strip
배경 (spec §3.5-3): filter_chunks(filters.rs:81)가 embedding_records er JOIN chunks c ON c.chunk_id=er.chunk_id WHERE er.status='committed' 로 LanceDB 후보를 필터. sentinel chunk_id 는 chunks
JOIN 에서 버려져 VectorRetriever strip 이전에 탈락. sentinel candidate 를 원본으로 strip 해 JOIN 통과시킴.
Files: Modify crates/kebab-store-sqlite/src/filters.rs (filter_chunks)
-
Step 1: 실패 테스트 — committed 원본 chunk 의 sentinel candidate(
{orig}#alias)가 filter_chunks 결과에 (원본 또는 sentinel 로) 통과하는지. (기존 filters 테스트 패턴 따라.) -
Step 2: 구현 —
filter_chunks(chunk_ids, filters)가 candidatechunk_ids중 sentinel (#alias접미)을 원본으로 strip 해 IN-list/JOIN 에 넣되, 반환은 입력 candidate 형태(sentinel 유지) 로 — VectorRetriever 가 그 sentinel 을 받아 strip+dedup(Task 4)하기 때문. 즉:- IN-list 바인딩: 각 candidate 를
strip_alias_suffix한 원본 chunk_id 로 JOIN(committed 판정은 원본 chunk 기준). 원본이 committed 면 그 candidate(원본 or sentinel) 통과. - 반환: 통과한 원본 candidate 문자열 그대로(sentinel 포함) — store.search 가 그대로 VectorRetriever 로.
- 구현 주의: 현재
er.chunk_id IN (?)가 candidate 직접 매칭. sentinel 은 embedding_records 에는 있으나(V011 후) chunks JOIN 실패. 두 방법 중 택1 — (a) JOIN 을c.chunk_id = strip(er.chunk_id)로 (SQL 에서#alias제거:replace(er.chunk_id,'#alias','')또는rtrim), 또는 (b) Rust 에서 candidate 를 원본으로 strip 해 IN-list 구성 후, 결과를 원본 candidate 와 매핑해 반환. (b) 권장 (SQL replace 보다 명확).kebab_core::strip_alias_suffix사용.
- IN-list 바인딩: 각 candidate 를
-
Step 3: 테스트 통과 + 회귀 —
cargo test -p kebab-store-sqlite -p kebab-search -j 4 > /tmp/dv-t46.log 2>&1; echo "EXIT=$?"EXIT=0. -
Step 4: 커밋 —
git add crates/kebab-store-sqlite/src/filters.rs && git commit -m "feat(store): filter_chunks sentinel 별칭 candidate strip (committed 통과)"
Task 5: 측정 + 문서
-
Step 1: clippy —
cargo clippy --workspace --all-targets -j 4 -- -D warnings > /tmp/dv-clippy.log 2>&1; echo "EXIT=$?"EXIT=0. -
Step 2: 측정 —
.kebabignore(topics 만) 재작성 → release 빌드 →KEBAB_INGEST_EXPANSION_ENABLED=true KEBAB_INGEST_EXPANSION_EMBED_ALIASES=true kebab ingest --force-reingest(topics 재임베딩, 별칭 벡터 생성, ~32분) →KEBAB_EVAL_GOLDEN=... kebab eval run --mode hybrid --k 50→eval variants. Read 로 값 확인(추측 금지).- 효과: 영어 설명형(mvcc/raft)
recall@500→양수 회복? concat PoC(6/0/2/0.25) 대비 개선? - 회귀: body 벡터 불변이라 명사형/단일쿼리 회귀 0 확인. 측정 후
.kebabignore삭제.
- 효과: 영어 설명형(mvcc/raft)
-
Step 3: 문서 —
tasks/HOTFIXES.mddated entry(lexical 별칭 + dense 별칭 측정 표), README Configuration(embed_aliasesoff 기본), ARCHITECTURE(별칭 dense sentinel 벡터), HANDOFF. -
Step 4: 커밋 —
git add tasks/HOTFIXES.md README.md docs/ARCHITECTURE.md HANDOFF.md && git commit -m "docs: dense 별칭 측정 결과 + 문서 동기화"
Self-Review
- Spec 커버리지: §3.2 sentinel→Task1. §3.3 config→Task2, ingest embed→Task3, retriever dedup→Task4, purge→Task3. §5 측정→Task5. §7 테스트→각 Task. ✅
- Placeholder: Task4 Step1 store fixture 는 "기존 패턴 따름"으로 위임(단언 핵심 명시). VectorHit.chunk_id pub 여부는 "확인 후 분기" 지시. 나머지 완성 코드. ✅
- 타입 일관성:
ALIAS_SUFFIX/strip_alias_suffix(Task1, kebab_core) ↔ ingest(Task3)·retriever(Task4) 사용.embed_aliases(Task2 config) ↔ ingest(Task3). VectorRecord 필드(Task3) = 기존 body records 와 동일 구조. ✅
Execution Handoff
OMC teammate(sequential single-team). Task1·2=sonnet(작은), Task3·4=opus(임베딩/retriever 핵심). Task3/4 후 code-reviewer(opus, sentinel dedup·purge 정확성·회귀). Task5 측정은 main 세션 직접.