DocTool · Retitle 方案决策矩阵

附注表归一化:从字符串匹配到向量召回

本地 bge-large-zh-v1.5 + 复用 SectionAttributionService + KNN 强约束
项目 DocTool / 财务报表 tie-out
状态 待路径 3 数据验证
日期 2026-06-18

01问题定位

主表归一化(8 张)用现有 match_keywords + match_patterns 规则匹配稳定可用;499 个附注表在长尾样本(别名、修饰、OCR 飘移、兄弟节覆盖)上召回不准。TitleNormalizerService 不是根因,不动。

关键事实
extracted_tables 没有 title 字段——标题在 extracted_paragraphs 里。SectionAttributionService.AttributeAsync 已经把每个 table 归属到 L1NodeId / L2NodeId / Source,结果落到 attribution_section 字符串字段——算了但 InferTitle 没消费

02方案选型

核心改动

为什么是这套

采纳

  • attribution 已经有,直接复用——不重复造轮子
  • 兄弟节下兄弟 note 在 KNN 阶段就被排除
  • 本地 ONNX 一次推理 ~100ms,200 表/doc 串行 ~20s,可接受
  • 主表判定完全不动,8 张主表的 fast path 保留

放弃

  • 关键词 + 向量双路径并行打分(单路径更易调试)
  • target 侧加"节级"节点做粗筛(节级本来就在 audit_template_nodes)
  • 本地 LLM 仲裁(本地 LLM 体积大,LLM 仲裁罕见样本,仍走 DashScope)
  • 独立 embedding sidecar(进程内足够,先把方案跑通)

03流程图

# 入口: RetitleService.RetitleByDocAsync(docId) Phase 1 SectionAttributionService.AttributeAsync() → attributionResult.Attributions[i] {L1NodeId, L2NodeId, Source} → 写 extracted_tables.attribution_section # 已存在, 不动 主循环 for each table: │ ├─ 步骤 1 主表判定(原样不动) │ hit = candidates.FirstOrDefault(IsMainStatementTitle) │ 命中 → match_method='main_keyword' ✓ 主表 OK, 不进向量 │ └─ 步骤 2 附注判定 — 单向量路径 │ ├─ ① 读 attribution (Phase 1 算的) │ l1_node_id, l2_node_id, source │ ├─ ② 拼 embedding_text_query │ if L2NodeId: │ "[所在节: {L1.short_name}] [子节: {L2.short_name}] {段落窗口}" │ elif L1NodeId: │ "[所在节: {L1.short_name}] {段落窗口}" │ else (OutOfOutline): │ "{段落窗口}" # 无归属信号 │ ├─ ③ 一次 encode (ONNX Runtime, ~100ms) │ AuditTemplateNodeEmbeddingService.Encode(text) │ → float[1024] L2-normalized │ ├─ ④ 层次化 KNN (强约束) │ if L2NodeId: │ WHERE parent_id = L2NodeId AND kind='note' AND level>=4 │ elif L1NodeId: │ WHERE parent_id = L1NodeId AND kind='note' │ else: │ WHERE kind='note' # 全 499 fallback │ LIMIT 20 │ └─ ⑤ rerank + 置信度分支 score = 0.7 × cosine + 0.3 × kw_score → top-5 → cosine 全 < 0.4 → no_match → top-1 显著 (差>0.15) → 命中 → 否则 → LLM 仲裁 (Qwen API) 步骤 3 / 4 续表 / 噪音判定(原样不动) 全失败 title = "Document table {N}", match_method='azure_default'

04match_method 字段值

字段值触发条件说明
main_keyword步骤 1 命中8 张主表 fast path,稳定
note_vector_rerank附注 KNN 命中,attribution 强约束理想路径,attribution 起作用
note_vector_global附注 KNN 命中,OutOfOutline fallbackattribution 算不出,弱命中
note_llm_arbitrateKNN cosine 区分不开,LLM 仲裁罕见,vector 模糊样本
note_vector_no_matchKNN cosine 全 < 0.4字段/模型问题,需要排查
continuation步骤 3 命中续表标识,原样
noise步骤 4 命中噪音表,原样
azure_default全失败azure 占位

05目标侧 embedding(离线,启动时一次性入库)

-- 源表: audit_template_nodes WHERE kind='note' AND is_active=true  →  499 行
-- 模型: bge-large-zh-v1.5 (1024 维, CLS pooling, 中文 SOTA)
-- 推理: Microsoft.ML.OnnxRuntime 进程内, ~100ms/次, 启动 +1-3s

embedding_text_target(node) =
  "{parent_chain.number_label join ' '}
   {node.number_label}
   {node.short_name}
   {aliases join ' ' (前 5 个)}"

# 示例 (审计附注 - 货币资金及应收款项 - 应收账款):
"七、合并财务报表项目注释 1.货币资金及应收款项 (1)应收账款
 应收账款 应收账款 应收款 应收账款余额 应收账款净额"

-- 存储:
CREATE TABLE audit_template_node_embeddings (
  id            bigserial PRIMARY KEY,
  node_id       varchar(64) NOT NULL REFERENCES audit_template_nodes(id),
  embedding     vector(1024),
  model_version varchar(64),  -- 'bge-large-zh-v1.5@v1'
  computed_at   timestamptz DEFAULT now()
);

CREATE INDEX idx_note_embedding_hnsw ON audit_template_node_embeddings
  USING hnsw (embedding vector_cosine_ops)
  WITH (m = 16, ef_construction = 64);

06关键决策路径

当前没有 retitle 跑通的数据,方案价值由路径 3 的数据决定。下面是路径 3 跑通/跑不通后的具体动作。

路径 3 状态 attribution 覆盖率 下一步动作
跑通 ≥ 70% 直接进 worktree 实施向量方案。 bge-large-zh-v1.5 + ONNX Runtime + 复用 attribution + KNN 强约束。 实施完跑离线评估,验收标准:
  • vector-augmented recall ≥ 90%
  • vector-only 增量召回 ≥ 15%
  • keyword-only baseline ≥ 70%
跑通 30% - 70% A/B 两种策略:
  • 路径 1:复用 attribution 强约束
  • 路径 2:全 499 KNN + 强 rerank (kw_score 权重提到 0.5)
同一份 doc 同时跑,对比命中率,数字决定。
跑通 < 30% KNN 强约束方案在源头失败。 attribution 算不出 → 频繁 fallback 到全 499,强约束没意义。
回到全 499 KNN + 段落级方案,放弃 attribution 复用。 风险:兄弟节下兄弟 note 召回问题暴露,得用其他方法解决(拼 number_label、调 rerank 权重)。
跑不通 N/A 问题不在 retitle 方案层,在数据/启动层
1. SectionAttributionService.AttributeAsync 抛异常 → 看 inner exception,定位缺失依赖(IAuditOutlineMatcher 注入、Role='sectionHeading' 没填、seed 没跑)
2. 全 OutOfOutline → 抽样 extracted_paragraphs.Role 分布,Azure 解析 role 分类问题
3. 启动失败 → 解 make devali / .env / SqlSugar CodeFirst 迁移
不要直接跳到 retitle 主流程,故障域先收敛。

07路径 3 命令设计

# SP.Shield.Tools 增加 command
dotnet run --project SP.Shield.Tools -- retitle-sample --doc-id <guid>

# 行为:
1. SELECT * FROM extracted_pages    WHERE doc_id = ?
2. SELECT * FROM extracted_paragraphs WHERE doc_id = ? ORDER BY page_number, top
3. SELECT * FROM extracted_tables    WHERE doc_id = ? ORDER BY page_number
4. new SectionAttributionService(...).AttributeAsync(doc_id, ...)
5. 打印:
   - attribution_source 分布 (L1Match / L2Match / FallbackByL1 /
                              MainStatement / OutOfOutline)
   - 每张 table 的 L1NodeId / L2NodeId / Source / 段落窗口前 3 段预览
   - OutOfOutline 的 table 列表 (重点看)
6. 写 JSON: ~/.claude/jobs/<job>/tmp/retitle-sample-{doc_id}.json

# 不带任何 retitle 逻辑, 纯粹验 SectionAttribution 这一层
# 不需要 ONNX, 不需要 retitle 跑通

08前置条件

开工前必须确认
  1. make devali 后端能起(ASPNETCORE_URLS=http://127.0.0.1:8000)
  2. PostgreSQL 有至少一份 已解析 的 doc(extracted_pages / extracted_paragraphs / extracted_tables 都有数据)
  3. pgvector 扩展已装(后续实施需要,路径 3 不需要)
  4. audit_template_nodes 表已 seed(8 主表 + 499 附注)