F-Pro 四阶段跨域污染防御:从 0.60 → 0.80 的完整复盘
问题:违约金 vs Java penalty
用户的 Attune vault 同时装着:
- 几百篇中文法律判例(rust-lang/attune-enterprise/data/crawler_backup/cases/)
- 数千个英文代码片段(rust-lang/book + CyC2018/CS-Notes Java/算法)
- 个人笔记(中英混合)
场景:用户问 “合同纠纷案件中违约金的法定上限是多少?”。期望返回判例段落。
实测返回的 top-3 结果是:
- ❌ Java HashMap 实现里的
// penalty for collision注释 - ❌ 算法笔记
// retry penalty for back-off - ✅ 一段判例(但只到第 3 位)
原因:BM25 + 向量检索都没有”领域感”。
- BM25: 中文法律分词后的 “违约金 / 上限” 与英文
penalty / limit在 token 层面看起来无关,但中文 OCR 输入错乱、jieba 分词噪声会让”违约金”偶尔切成”违约|金”,匹配到 Java 注释里的”金” - 向量: bge-m3 是双语模型,”违约金” 和 “penalty” 在 embedding 空间相邻(cosine ~0.65)
最直接的方案是 per-vault domain(拆 3 个 vault),但用户拒绝 — vault 切换太烦。需要在共享 vault 上做逻辑域隔离。
F-Pro 4 阶段架构
ingest path:
item → detect_domain → store.items.corpus_domain = "legal"
chunk → "[领域: legal] " + chunk_text → embed → vector_store
chunk → "[领域: legal] " + chunk_text → tokenize → bm25_index
retrieval path:
query → detect_query_domain (keyword 4 类 × 30 词) → "legal"
BM25 + Vector → fusion (RRF k=60)
for each result:
if result.domain != query.domain:
score *= CROSS_DOMAIN_PENALTY // 0.4
rerank (BAAI bge-reranker-base ONNX) → top-K
Stage 1: items.corpus_domain 字段
给 items 表加一列:
// migrations/20260420_add_corpus_domain.sql
ALTER TABLE items
ADD COLUMN corpus_domain TEXT DEFAULT NULL;
CREATE INDEX idx_items_corpus_domain ON items(corpus_domain);
入库时根据文件路径 / MIME / 内容前 1KB 自动判定:
// attune-core/src/scanner.rs
pub fn detect_domain(path: &Path, content_head: &str) -> Domain {
// 路径优先 (确定性最高)
if path.to_string_lossy().contains("/legal/")
|| path.extension() == Some(OsStr::new("pdf")) {
if RE_LEGAL_KEYWORDS.is_match(content_head) {
return Domain::Legal;
}
}
if path.extension() == Some(OsStr::new("rs"))
|| path.extension() == Some(OsStr::new("py"))
|| path.extension() == Some(OsStr::new("ts")) {
return Domain::Programming;
}
// 内容兜底
if RE_LEGAL_KEYWORDS.is_match(content_head) {
Domain::Legal
} else if content_head.is_ascii() {
Domain::EnglishGeneral
} else {
Domain::ChineseGeneral
}
}
用户也可以手动 override(Web UI 设置 → 文件标签)。
Stage 2: chunk 前缀注入
embedding 前给每个 chunk 拼上 domain 标记:
// attune-core/src/embed_worker.rs
let prefix = format!("[领域: {}] ", domain.as_zh());
let chunk_with_prefix = format!("{}{}", prefix, chunk_text);
let embedding = embedder.embed(&chunk_with_prefix).await?;
这一步把”领域”信息融进了 embedding 向量,相同领域的 chunk 在向量空间更近。BM25 也受益(前缀里 “领域: legal” 这几个 token 命中跨域 query 的概率低)。
Stage 3: 跨域惩罚(penalty 系数 sweep)
// attune-core/src/search.rs
const CROSS_DOMAIN_PENALTY: f32 = 0.4;
pub fn apply_cross_domain_penalty(
results: &mut Vec<SearchResult>,
query_domain: Domain,
) {
for r in results {
if r.corpus_domain != query_domain {
r.score *= CROSS_DOMAIN_PENALTY;
}
}
results.sort_by(|a, b| b.score.partial_cmp(&a.score).unwrap_or(Equal));
}
为什么是 0.4 不是 0.2 / 0.6 / 0.8?我们跑了 5 轮 sweep:
| Penalty | Scen A 法律 MRR | Scen B Rust MRR | 误伤率(合法跨域被压) |
|---|---|---|---|
| 0.2 (狠) | 0.83 | 0.91 | 14% |
| 0.4 | 0.80 | 1.00 | 3% |
| 0.5 | 0.74 | 0.95 | 2% |
| 0.6 (软) | 0.68 | 0.89 | 1% |
| 0.8 (几乎不惩罚) | 0.62 | 0.87 | 0% |
| 1.0 (basleline 无惩罚) | 0.60 | 0.87 | — |
“误伤率”是指:query “Rust 中如何处理内存安全” 这种合法跨域(程序员 + 中文)原本应该返回 Rust 笔记,被 penalty 压到第 5 位以后。
0.2 把误伤率推到 14%,太狠。0.4 在”提升 Scen A”和”误伤合法跨域”之间是最佳点。
Stage 4: 关键词意图识别(零 LLM 调用)
query 的领域怎么判?我们试过 3 种方案:
| 方案 | Latency p95 | 准确率 | 月成本(10K query) |
|---|---|---|---|
| LLM (qwen2.5-1.5b 本地) | ~250ms | 96% | $0 (本地 GPU) |
| LLM (gpt-4o-mini API) | ~400ms | 98% | $1.20 |
| 关键词匹配(4 类 × 12-30 词) | <10ms | 89% | $0 |
89% 准确率看起来低,但关键词漏判 ≠ 检索失败 — 漏判时 penalty 不生效,等于退化到 baseline。所以”假阴性”成本是 0;只有”假阳性”会害人(错把法律 query 判成 programming,导致法律结果被 penalty 压住),实测假阳性 < 1%。
// attune-core/src/search.rs
const LEGAL_KEYWORDS: &[&str] = &[
"案件", "案号", "判决", "诉讼", "原告", "被告", "合同纠纷",
"违约金", "赔偿", "管辖", "上诉", "二审", "再审", "执行",
/* ... 30 词 */
];
const PROGRAMMING_KEYWORDS: &[&str] = &[
"Rust", "Python", "TypeScript", "borrow", "lifetime",
"async", "await", "trait", "generic", /* ... */
];
pub fn detect_query_domain(query: &str) -> Domain {
let scores = [
(Domain::Legal, count_keywords(query, LEGAL_KEYWORDS)),
(Domain::Programming, count_keywords(query, PROGRAMMING_KEYWORDS)),
(Domain::ChineseGeneral, /* ... */),
(Domain::EnglishGeneral, /* ... */),
];
scores.into_iter()
.max_by_key(|(_, s)| *s)
.filter(|(_, s)| *s > 0)
.map(|(d, _)| d)
.unwrap_or(Domain::ChineseGeneral)
}
评测结果
在 attune 三赛道 benchmark 上:
| 赛道 | Baseline (无 F-Pro) | F-Pro 全开 | 增益 |
|---|---|---|---|
| Scen A 法律(10677 篇 attune-enterprise 判例) | 0.60 | 0.80 | +33% |
| Scen B Rust(rust-lang/book) | 0.87 | 1.00 | +15% (side benefit) |
| Scen C 中文八股(CyC2018/CS-Notes) | 1.00 | 1.00 | — |
Scen B 的”意外提升”是 F-Pro 设计阶段没预料到的:Rust query “borrow checker” 以前会被中文笔记里的”借款”污染(中文 vault 不少法律相关),加 penalty 后这种污染被压住了。好的工程总是有 side effects — 当 side effect 是正向的时候,这通常意味着设计触及了问题的本质。
踩坑记录
坑 1: chunk 前缀让 chunk 长度超限
bge-m3 的 max_seq_len = 8192,加 “[领域: legal] ” 后多 7 个 token。我们的 chunker 默认目标 chunk size = 512,加前缀后 chunker 没及时调整 → 偶尔 truncate。修复:chunker 设 chunk_size_target -= len(prefix)。
坑 2: penalty 与 reranker 顺序
第一版 F-Pro 是 penalty → rerank,结果 Scen A MRR 反而降到 0.55。原因:reranker 看到的是”已被 penalty 拉低的列表”,跨域结果都掉到 top-100 之后,reranker 学到”低分 = 不相关” → 反向放大错误。改成 rerank → penalty,Scen A 立即回到 0.78+。
坑 3: Programming domain 误识别
query “Python 律师事务所自动化” → 关键词同时命中 LEGAL(”律师事务所”)和 PROGRAMMING(”Python”)。早期版本简单 max(scores) 让 PROGRAMMING 险胜,结果法律内容被压。修复:加 tie-breaker(中文字符比例 > 50% → 偏向中文域)。
设计哲学
把”领域”当成 first-class 信号,写进数据本身(chunk 前缀 + items 字段),让向量空间和 BM25 都能感知到。这样所有上层逻辑(RRF fusion / reranker / chat citation)自动受益,不需要为每条单独修复。
这是”一处优化,处处获益”的工程设计,与”在 chat prompt 里多加一句”领域是 X””这种 patch 工程相反。Patch 看起来便宜(改一行 prompt),但每加一个 vertical 就要改一遍 prompt,长期债务高。
后续工作
- 跨语言扩展:目前只判 zh / en,未来加日韩
- 领域自动学习:用 unsupervised clustering 发现新领域(用户 vault 装了”医学”内容时,自动识别为新 domain)
- 细粒度 penalty:合同 vs 判例 vs 法律评论是法律的 3 个子域,可能需要 sub-domain penalty
相关阅读
bge-m3 双语 RAG 底座选型实录:4 个候选 / 3 赛道 / 5 轮取舍
在 attune 三赛道 benchmark(法律/Rust/中文八股)上对比 bge-m3 / m3e-base / multilingual-e5-large / openai-text-embedding-3。bge-m3 综合 MRR 0.93 拿下,特别是法律赛道领先 m3e-base 23% / mE5-large 45%。本文给出 5 个真实拒绝理由,包括为什么 openai-text-3-large 没测、为什么 cohere v3 被排除。
阅读全文 →Attune 设计哲学:为什么我们不做又一个云端 AI SaaS
过去三年我们看过 20+ 款 "AI 知识库" 产品,全部走相同范式:上传文档到云端 → 服务端 chunk + embed → 云端 LLM。Attune 选择反向:本地 vault + AES-256-GCM 字段加密 + 显式出网 + 12 类 PII 自动脱敏。代价是工程复杂度 3 倍,回报是"原文不出网"成为可证明的事实而非空头承诺。
阅读全文 →