联系我们
首页 › 技术博客 › F-Pro 四阶段跨域污染防御:从 0.60 → 0.80 的完整复盘

F-Pro 四阶段跨域污染防御:从 0.60 → 0.80 的完整复盘

✍️ Attune 检索团队 📅 ⏱️ 18 分钟阅读 📊 高级 工程

问题:违约金 vs Java penalty

用户的 Attune vault 同时装着:

  • 几百篇中文法律判例(rust-lang/attune-enterprise/data/crawler_backup/cases/)
  • 数千个英文代码片段(rust-lang/book + CyC2018/CS-Notes Java/算法)
  • 个人笔记(中英混合)

场景:用户问 “合同纠纷案件中违约金的法定上限是多少?”。期望返回判例段落。

实测返回的 top-3 结果是:

  1. ❌ Java HashMap 实现里的 // penalty for collision 注释
  2. ❌ 算法笔记 // retry penalty for back-off
  3. ✅ 一段判例(但只到第 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:

PenaltyScen A 法律 MRRScen B Rust MRR误伤率(合法跨域被压)
0.2 (狠)0.830.9114%
0.40.801.003%
0.50.740.952%
0.6 (软)0.680.891%
0.8 (几乎不惩罚)0.620.870%
1.0 (basleline 无惩罚)0.600.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 本地)~250ms96%$0 (本地 GPU)
LLM (gpt-4o-mini API)~400ms98%$1.20
关键词匹配(4 类 × 12-30 词)<10ms89%$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.600.80+33%
Scen B Rust(rust-lang/book)0.871.00+15% (side benefit)
Scen C 中文八股(CyC2018/CS-Notes)1.001.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

相关阅读

· 14 分钟

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 被排除。

阅读全文 →
· 12 分钟

Attune 设计哲学:为什么我们不做又一个云端 AI SaaS

过去三年我们看过 20+ 款 "AI 知识库" 产品,全部走相同范式:上传文档到云端 → 服务端 chunk + embed → 云端 LLM。Attune 选择反向:本地 vault + AES-256-GCM 字段加密 + 显式出网 + 12 类 PII 自动脱敏。代价是工程复杂度 3 倍,回报是"原文不出网"成为可证明的事实而非空头承诺。

阅读全文 →