feat(search): provenance 출처 필터 — [[workspace.sources]] 멀티소스 + --source/--source-type

혼합 출처 KB(위키+jira 등)에서 색인은 전부 하되 질의 시 출처로 좁히는 provenance
레버. 전역 trust 곱셈가중(weighted-RRF)은 A/B 에서 반증(θ=0.85 만으로 incident MRR
0.918→0.340 절벽, 점수 압축) — 필터가 see-saw 없는 올바른 레버.

- config [[workspace.sources]] (각 id/root/exclude/trust_level/source_type);
  단일 root 는 implicit `default` source 로 정규화. validate: id 유일·비어있지 않음.
- config schema v3→v4 (step_3_to_4, root→[[workspace.sources]] id=default 미러, 멱등)
- V014 documents.source_id 컬럼+인덱스 (additive, DEFAULT 'default', 재색인 0)
- Metadata.source_id + BodyHints trust precedence(frontmatter > source 기본값 > Primary)
- ingest: --root 미지정 시 resolved_sources() 순회 + doc 마다 source_id/trust stamp
- 검색 SearchFilters.source_type/source_id → lexical + vector 두 site (IN, OR)
- CLI kebab search --source <id> / --source-type <type> (repeatable/comma-sep)

도그푸딩(620 doc, jira400+wiki220): --source wiki 로 개념 질의 MRR 0.780→0.810,
--source jira 로 incident 0.918→0.975. trust precedence 실측(jira=secondary 기본값).

version bump 0.28.0 → 0.29.0 (신규 CLI flag + config 키 + V014 migration → minor).
follow-up: MCP search 필터 미노출 · kebab list source_id 미표시 · RAG provenance 라벨.

자세한 내용: tasks/HOTFIXES.md (2026-06-21), docs/release-notes/v0.29.0-draft.md.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012Mc6W1fgsrbFKTsqA6P8La
This commit is contained in:
2026-06-21 08:35:19 +00:00
parent 403e162ac0
commit 58ac62d53a
101 changed files with 1201 additions and 111 deletions

View File

@@ -9,7 +9,7 @@ use toml_edit::{DocumentMut, Item};
/// 현재 바이너리가 이해하는 config 스키마 버전. 마이그레이션 완료 시
/// 사용자 파일의 `schema_version` 을 이 값으로 stamp 한다.
pub const CURRENT_SCHEMA_VERSION: u32 = 3;
pub const CURRENT_SCHEMA_VERSION: u32 = 4;
/// 한 번의 마이그레이션에서 발생한 개별 변경.
#[derive(Clone, Debug, PartialEq, serde::Serialize)]
@@ -68,6 +68,7 @@ const HEADER: &str = "\
fn section_comment(path: &str) -> Option<&'static str> {
Some(match path {
"workspace" => "# 색인 대상 워크스페이스.",
"workspace.sources" => "# named multi-source (각 source 의 id 가 documents.source_id 로 stamp).",
"storage" => "# XDG 저장 경로(데이터/sqlite/벡터/에셋/모델).",
"indexing" => "# 병렬도 + 파일시스템 watch.",
"chunking" => "# 청크 크기·오버랩·heading 존중.",
@@ -376,6 +377,39 @@ pub fn step_2_to_3(doc: &mut DocumentMut, changes: &mut Vec<MigrationChange>) {
copy_image_paddle_to_pdf(doc);
}
/// v3 → v4: 단일 `workspace.root` 를 `[[workspace.sources]]` 의 implicit
/// `default` source 로 미러링한다(`id = "default"`, `root = <기존 root>`).
/// 기존 `workspace.root` 키는 그대로 둔다 — `resolved_sources()` 가 sources
/// 가 있으면 그쪽을 우선하므로 무해하고, defaults reconcile 이 root 를 다시
/// 추가하려 하지 않게 한다. 멱등: `[[workspace.sources]]` 가 이미 있으면 no-op.
pub fn step_3_to_4(doc: &mut DocumentMut, changes: &mut Vec<MigrationChange>) {
let Some(ws) = doc.get_mut("workspace").and_then(Item::as_table_mut) else {
return;
};
// 이미 sources 가 선언돼 있으면(array-of-tables 든 inline 이든) 손대지 않음.
if ws.contains_key("sources") {
return;
}
// root 가 없으면 만들 게 없음(defaults 에는 항상 있지만 방어).
let Some(root_val) = ws.get("root").and_then(Item::as_str).map(str::to_string) else {
return;
};
let mut entry = toml_edit::Table::new();
entry.insert("id", toml_edit::value("default"));
entry.insert("root", toml_edit::value(root_val));
let mut aot = toml_edit::ArrayOfTables::new();
aot.push(entry);
ws.insert("sources", Item::ArrayOfTables(aot));
changes.push(MigrationChange {
kind: ChangeKind::AddedSection,
path: "workspace.sources".to_string(),
detail: "workspace.root → [[workspace.sources]] id=default".to_string(),
});
}
/// 파일의 schema_version(없으면 1) 부터 CURRENT 까지 step 적용.
fn run_steps(doc: &mut DocumentMut, from: u32, changes: &mut Vec<MigrationChange>) {
if from < 2 {
@@ -384,6 +418,9 @@ fn run_steps(doc: &mut DocumentMut, from: u32, changes: &mut Vec<MigrationChange
if from < 3 {
step_2_to_3(doc, changes);
}
if from < 4 {
step_3_to_4(doc, changes);
}
}
/// 사용자 config.toml 텍스트를 받아 step 체인 + reconciliation + version
@@ -648,6 +685,76 @@ engine = \"paddle-onnx\"
assert!(again.is_empty(), "not idempotent: {again:?}");
}
#[test]
fn step_3_to_4_mirrors_root_into_default_source() {
let v3 = "\
schema_version = 3
[workspace]
root = \"/my/notes\"
exclude = [\".git/**\"]
";
let mut doc: DocumentMut = v3.parse().unwrap();
let mut changes = Vec::new();
step_3_to_4(&mut doc, &mut changes);
let out = doc.to_string();
// 새 array-of-tables 가 id=default 로 추가.
assert!(out.contains("[[workspace.sources]]"), "{out}");
assert!(out.contains("id = \"default\""), "{out}");
// 기존 root 는 보존(reconcile 이 다시 추가하지 않게).
assert!(out.contains("root = \"/my/notes\""), "{out}");
// 재파싱 후 sources.default 가 root 를 미러.
let reparsed: DocumentMut = out.parse().unwrap();
let src0 = reparsed["workspace"]["sources"][0].as_table().unwrap();
assert_eq!(src0["id"].as_str(), Some("default"));
assert_eq!(src0["root"].as_str(), Some("/my/notes"));
// 멱등.
let mut changes2 = Vec::new();
step_3_to_4(&mut doc, &mut changes2);
assert!(changes2.is_empty(), "step_3_to_4 not idempotent");
}
#[test]
fn step_3_to_4_noop_when_sources_already_present() {
let v4 = "\
schema_version = 4
[workspace]
root = \"/my/notes\"
exclude = []
[[workspace.sources]]
id = \"notes\"
root = \"/my/notes\"
";
let mut doc: DocumentMut = v4.parse().unwrap();
let mut changes = Vec::new();
step_3_to_4(&mut doc, &mut changes);
assert!(changes.is_empty(), "must not touch existing sources");
// 기존 source 만 존재(default 가 추가되지 않음).
assert!(!doc.to_string().contains("id = \"default\""));
}
#[test]
fn migrate_document_v3_to_v4_adds_sources_and_is_idempotent() {
let v3 = "\
schema_version = 3
[workspace]
root = \"/n\"
exclude = []
";
let outcome = migrate_document(v3);
assert_eq!(outcome.from_schema_version, 3);
assert_eq!(outcome.to_schema_version, 4);
assert!(outcome.changed());
assert!(outcome.new_text.contains("[[workspace.sources]]"));
assert_eq!(read_schema_version(&outcome.new_text), 4);
let again = migrate_document(&outcome.new_text);
assert!(!again.changed(), "not idempotent: {:?}", again.changes);
assert_eq!(again.new_text, outcome.new_text);
}
#[test]
fn migrate_document_missing_schema_version_treated_as_v1() {
let old = "[workspace]\nroot = \"/n\"\n";