这是系列文章的第三篇,介绍 Canonical 冻结后的结构感知 Chunk、Qwen3 Embedding、服务器传输、PostgreSQL、HNSW、BM25、RRF 和 Reranker。

公开说明:所有私人网络地址、账号、Token、密钥、内部仓库和来源哈希均已删除或替换。

系列目录

  1. 解析架构与质量基线
  2. 块级融合与人工质量验收
  3. Chunk、Embedding 与混合检索
  4. 服务部署、增量发布与安全运维

1. M6 阶段的执行顺序

Canonical 通过人工验收后,才解除下游禁令。索引阶段严格按以下顺序执行:

M6-01  Chunk QC
M6-02  Embedding QC
M6-03  Canonical 传输验证
M6-04  PostgreSQL 部署
M6-05  数据库导入与正式索引
M6-06  REST/MCP 检索验证

任何阶段失败,后续阶段全部阻断,同时保留已完成输出用于诊断。M6 不重新运行 MinerU,也不修改已验收的 Canonical。

2. 为什么 Chunk 也需要 Schema

简单按字符数或固定 token 窗口切分,会破坏:

  • 标准条款结构;
  • 表格行列关系;
  • 代码块;
  • 图片 caption;
  • 跨页内容;
  • heading path;
  • 原始页码和 bbox 溯源。

因此 Chunk 不是一段字符串,而是带完整来源信息的正式数据对象。

3. 结构感知 Chunk 策略

本次采用:

普通正文目标:约 600 tokens
普通正文上限:900 tokens
标准条款:尽量保持完整
表格:按逻辑表处理
代码块:保持完整
标题路径:保留
权威文字:优先 search_text
拒绝状态:UNRESOLVED

中文 token 估算不能简单使用英文单词计数。为了在不依赖在线 tokenizer 的情况下保持确定性,可以为 CJK、拉丁单词、数字和标点建立固定估算规则,并将规则版本写入 Manifest。

4. Chunk Schema

核心字段示例:

{
  "chunk_id": "content-derived-stable-id",
  "schema_version": "knowledge-chunk-v1",
  "document_id": "stable-document-id",
  "chunk_type": "text",
  "search_text": "用于检索的权威正文",
  "heading_path": ["第 5 章", "5.2 条款"],
  "original_pages": [117, 118],
  "block_ids": ["block-001", "block-002"],
  "logical_table_ids": [],
  "image_ids": [],
  "asset_paths": [],
  "bbox_refs": [],
  "source_sha256": "<source-pdf-sha256>",
  "chunk_sha256": "<chunk-sha256>",
  "requires_review": false
}

chunk_id 应由稳定输入派生,而不是使用随机 UUID。这样相同 Canonical 输入可以得到相同 Chunk ID,增量发布时能准确判断复用和重建。

5. 不同内容类型分别处理

最终 15,801 个 Chunk 包含:

类型数量
普通文本11,863
表格1,803
代码1,566
图片564
图表5

5.1 普通文本

优先沿标题、段落和条款边界合并。只有超过上限时才进一步切分。

5.2 表格

以 logical_table 为主要检索单位,同时保留 fragment、页码和表格 HTML。超大表格可以分块,但必须保留表头和逻辑表 ID。

5.3 代码

避免从 IF/ELSE/ENDIF、花括号或 ASN.1 结构中间截断。跨页代码块需要结合原始页码保留完整上下文。

5.4 图片和图表

Chunk 保存图题、辅助描述、原始页码和真实资产引用。Mermaid 不能成为原图的替代品。

6. Chunk 门禁

至少验证:

  • search_text 非空;
  • 所有来源 block 已对齐;
  • UNRESOLVED 为 0;
  • 每个 Chunk 有文档和来源哈希;
  • 原始页码在文档页数范围内;
  • 表格 Chunk 可定位 logical table;
  • 图片 Chunk 可定位真实资产;
  • chunk_sha256 可复算;
  • 所有可搜索页面均被覆盖;
  • 没有意外遗漏逻辑表;
  • review flag 没有丢失。

本次 2,999 个源页面中,2,943 页属于预期可搜索页面,56 页是无可搜索正文的页面;最终没有遗漏任何预期可搜索页。

7. Embedding 模型冻结

Embedding 使用:

模型系列:Qwen3-Embedding-4B GGUF
量化:Q8_0
维度:2560
Pooling:last
归一化:L2
相似度:cosine
运行时:llama-cpp-python + Vulkan GPU

下载前先固定:

  • 模型仓库;
  • 精确 revision;
  • 精确文件名;
  • 文件 SHA-256;
  • 文件大小;
  • 推理运行时版本;
  • pooling;
  • 上下文长度;
  • batch 参数;
  • 并发数。

不要只记录“Qwen3-Embedding-4B”。同一仓库的不同 revision、量化文件或 pooling 设置,可能得到不可比较的向量。

8. 先小批量验证,再生成全量向量

Embedding Pilot 的顺序:

  1. 校验模型文件哈希;
  2. 选取少量不同类型 Chunk;
  3. 生成 2,560 维向量;
  4. 检查维度;
  5. 检查无 NaN/Infinity;
  6. 检查 L2 范数接近 1;
  7. 执行余弦相似度检索;
  8. 模拟中断并验证增量恢复;
  9. Pilot 通过后启动全量。

L2 检查示例:

import numpy as np

vector = np.asarray(embedding, dtype=np.float32)
assert vector.shape == (2560,)
assert np.isfinite(vector).all()
assert abs(float(np.linalg.norm(vector)) - 1.0) < 1e-5

9. Embedding 分片与增量恢复

向量不写入一个超大文件,而是分片保存:

04-embeddings/release/
├── embedding-manifest.json
├── embedding-records.jsonl
└── shards/
    ├── shard-00000/
    │   ├── vectors.npy
    │   └── records.jsonl
    ├── shard-00001/
    └── ...

每个分片记录:

  • 输入 Chunk 指纹;
  • Chunk 数量;
  • 首尾 Chunk ID;
  • 向量维度和 dtype;
  • vectors 文件 SHA-256;
  • records 文件 SHA-256;
  • L2 最大偏差;
  • 运行耗时。

如果进程中断,已完成且输入指纹未变化的分片直接复用。修改一个文档时,也只重建受影响的 Chunk 和 Embedding。

全量 float32 向量的最大 L2 范数偏差约为 6e-8,15,801 个向量全部为有限值。

10. 服务器只接收必要资产

服务器传输包分为:

sources.tar
canonical-core.tar
release.tar

10.1 sources.tar

包含:

  • 原始 PDF;
  • source manifest;
  • SHA256SUMS。

10.2 canonical-core.tar

每个文档包含:

  • blocks.jsonl
  • pages.json
  • logical-tables.json
  • page-table-fragments.json
  • images.json
  • 必要的结构化裁剪图
  • server document manifest

10.3 release.tar

包含:

  • chunks.jsonl
  • Chunk manifest
  • Embedding shards
  • Embedding manifest
  • Release manifest
  • SHA256SUMS

本地页面 PNG、解析中间 JSON、人工校正包、布局可视化和重复图片树不上传服务器。

11. 传输校验

上传流程不是简单 scp:

  1. 本地构建归档;
  2. 本地校验每个成员;
  3. 上传到 .incoming/<release-id>;
  4. 远端复算归档 SHA-256;
  5. 解包后复算成员 SHA-256;
  6. 复核所有原始 PDF 哈希;
  7. 检查文档、页面、图片、Chunk、Embedding 和分片数量;
  8. 通过后再准备 current 指针切换。

只有在数据库导入和服务检索也通过后,staging 才允许清理。

12. PostgreSQL 运行架构

服务器采用:

Ubuntu Server 24.04
PostgreSQL 17
pgvector
halfvec(2560)
HNSW cosine
BM25 扩展
应用侧中文分词

PostgreSQL 只加入内部 Docker 网络,不向宿主机发布数据库端口。应用容器通过内部服务名访问数据库。

数据库表至少包括:

  • releases;
  • documents;
  • pages;
  • blocks;
  • logical tables;
  • page table fragments;
  • images;
  • chunks;
  • chunk embeddings。

数据库密码通过服务器密钥文件注入,文件权限为 0600,不写入 Compose 和 Git。

13. 数据库导入流程

正式导入采用 staging:

  1. 创建或清空当前 Release 的 staging;
  2. 严格读取 Release Manifest;
  3. 验证输入哈希、维度和数量;
  4. 导入 documents/pages;
  5. 导入 tables/fragments/images;
  6. 导入 chunks;
  7. 导入 halfvec embeddings;
  8. 验证外键和双向关系;
  9. 创建正式 BM25/HNSW 索引;
  10. 执行检索 smoke test;
  11. 事务性提升 Release 状态。

导入后的核心计数:

documents = 41
pages = 2999
chunks = 15801
embeddings = 15801

float32 向量转换为 halfvec(2560) 后,最大 L2 偏差约为 3.6e-5,仍满足检索门禁。

14. 正式索引

14.1 HNSW 向量索引

向量列使用 halfvec(2560),距离度量使用 cosine。HNSW 提供低延迟近似最近邻检索。

14.2 BM25 索引

技术标准中的以下内容特别依赖关键词召回:

  • 标准号;
  • 条款号;
  • 英文缩写;
  • 逻辑节点;
  • ASN.1 标识符;
  • 图号和表号。

中文使用应用侧确定性分词,并将分词版本写入 Release/服务状态。这样同一输入在不同部署中得到一致检索词。

15. 为什么采用混合检索

只使用向量检索:语义召回较好,但可能漏掉精确标准号和代码标识。

只使用 BM25:精确词召回较好,但用户换一种表达后容易漏检。

最终流程:

用户问题
Query Embedding
中文与技术标识分词
HNSW Top-K
BM25 Top-K
RRF
Qwen3 Reranker
最终结果

16. RRF 排名融合

Reciprocal Rank Fusion 不要求两路分数处于同一量纲:

RRF_score(d) = sum(1 / (k + rank_i(d)))

本次使用 k=60。向量和 BM25 各自返回候选后,按 rank 融合,再选出一批候选交给 Reranker。

RRF 需要做确定性测试:相同输入、相同候选顺序和相同配置必须产生相同结果。

17. Reranker 精排

精排使用 Qwen3-Reranker-0.6B,固定模型 revision 和文件哈希,服务器端并发为 1。

Reranker 的作用不是替代召回,而是在 RRF 候选中重新判断问题与 Chunk 的相关性。服务应记录:

  • 向量检索得分;
  • BM25 得分;
  • RRF 得分;
  • Reranker 得分;
  • 总耗时和分阶段耗时。

当 Reranker 不可用时,系统可以明确降级到 RRF 结果,但必须在响应或健康状态中暴露降级,而不能静默伪装为已精排。

18. 检索结果的来源信息

每条结果不仅返回正文,还应返回:

  • chunk_id;
  • document_id;
  • 原始页码;
  • heading path;
  • block IDs;
  • logical table/image 关联;
  • source SHA-256;
  • review flag;
  • 各阶段排名或分数。

这样上层 Agent 可以引用来源页,人工也可以从回答回到原始 PDF。

19. REST 与 MCP

REST 搜索示例:

curl --request POST 'https://kb.example.com/v1/search' \
  --header "Authorization: Bearer <client-token>" \
  --header 'Content-Type: application/json' \
  --data '{
    "query": "需要检索的问题",
    "limit": 5
  }'

MCP 暴露三个最小工具:

search_knowledge
list_knowledge_documents
get_knowledge_chunk

通用 MCP 配置:

{
  "mcpServers": {
    "private-knowledge": {
      "url": "https://kb.example.com/mcp",
      "headers": {
        "Authorization": "Bearer ${KNOWLEDGE_API_TOKEN}"
      }
    }
  }
}

KNOWLEDGE_API_TOKEN 由客户端环境变量或系统密钥存储提供,不能写进公开配置。

20. 本阶段验收

  • Chunk 覆盖全部预期可搜索页面
  • 表格、代码和图片结构未被破坏
  • Chunk 来源和哈希可复算
  • Embedding 模型 revision 与哈希固定
  • 向量维度、有限值和 L2 门禁通过
  • 增量恢复通过
  • 传输归档和成员哈希一致
  • PostgreSQL 重启后数据仍存在
  • HNSW 和 BM25 索引通过 smoke test
  • RRF 结果确定性通过
  • Reranker 在线验证通过
  • REST 和 MCP 返回正确页码与来源

下一篇将完成整个系列:如何为不同客户端签发独立 Token,如何限制 Agent 权限,以及怎样实现 M7 增量发布、原子切换、自动回滚和可恢复备份。