local-semantic-chunking-design.md 13 KB

本地语义分块与向量化实现文档

本文档记录项目中文档数据的本地语义分块、embedding 向量化、Milvus 存储的完整实现方式与完成状态。

文档版本:v1.0 创建日期:2026-06-27 依赖状态:已实现本地 bge-m3 dense + Milvus 2.5+ BM25 sparse,替代原智谱 embedding-2 方案。


一、总体架构

┌─────────────────────────────────────────────────────────────────┐
│                         Spring Boot 后端                         │
│  ┌─────────────────┐    ┌─────────────────┐    ┌──────────────┐ │
│  │ DocumentService │───▶│ Embedding Bridge│───▶│   Milvus     │ │
│  │ (KbDocument/    │    │ (Python/FastAPI)│    │ (dense+sparse)│ │
│  │  KbChunk in H2) │    │  port 18732     │    │              │ │
│  └─────────────────┘    └─────────────────┘    └──────────────┘ │
│         │                                              ▲        │
│         │                                              │        │
│         ▼                                              │        │
│  ┌─────────────────────────────────────────────────────┐        │
│  │              HierarchicalTextSplitter                │        │
│  │  L1 (≈2400 chars, 规则) → L2 (≈1600, 语义) → L3 (≈800, 语义)│        │
│  │  仅 L3 写入 Milvus;L1/L2 保留在 H2                  │        │
│  └─────────────────────────────────────────────────────┘        │
└─────────────────────────────────────────────────────────────────┘

二、分块实现

2.1 文件位置

文件 作用
backend/embedding-bridge/backend/indexing/text_splitter.py 三级层次化分块器 HierarchicalTextSplitter + SemanticTextSplitter
backend/embedding-bridge/backend/indexing/semantic_chunker.py 基于本地 embedding 的语义边界检测 SemanticChunker

2.2 三级分块策略

层级 大小 语义分块 重叠 说明
L1 max(2000, chunk_size * 3) 0 对原文做粗粒度规则切分,保证所有 L1 拼接等于原文
L2 max(1000, chunk_size * 2) chunk_overlap 在 L1 内部按语义边界粗分,允许边界句子重叠
L3 max(600, chunk_size) chunk_overlap 在 L2 内部按语义边界细分,允许边界句子重叠;仅 L3 写入 Milvus
  • L1 必须保证严格拼接等于原文,因此 L1 之间不允许 overlap。
  • L2/L3 允许在语义边界处共享“承上启下”的句子,相邻 chunk 之间会出现内容重叠。
  • 重叠发生在完整语义单元(句子/子句)边界,而不是字符级滑动窗口。

2.3 规则分块(SemanticTextSplitter

  1. 文本净化sanitize_text):NFC 规范化、剔除零宽字符/C0/C1 控制符/BOM/PUA 区乱码、UTF-16 代理项。
  2. 原子切分:按分隔符优先级递归切分,保留分隔符本身作为独立原子,保证拼接还原。
    • 优先级:\n\n > > > > > \n > > > 空格 > 字符
  3. 合并原子:贪心合并到接近 chunk_size;超过大小时回退到最近语义分隔符。
  4. 重叠区(overlap > 0 时):从当前 chunk 末尾回退,截取不超过 overlap 且以语义边界结尾的重叠内容。

2.4 语义分块(SemanticChunker

在规则分块之前增加一道语义粗分:

  1. 切分语义单元:按句子/段落分隔符切分,每个单元保留末尾标点。
  2. 合并短单元:小于 min_unit_length(默认 12)的单元向前合并,减少 embedding 噪声。
  3. 生成向量:调用本地 embedding_service.get_embeddings() 批量获取单元向量。
  4. 计算相邻相似度:余弦相似度。
  5. 建立边界
    • 强制边界:当前段落长度超过 max_chunk_size 必须切开。
    • 语义边界:相邻单元相似度低于有效阈值且出现显著下降时切开。

2.5 自适应阈值策略

中文语料相邻句子相似度普遍偏高(常见 0.65~0.85),固定阈值 0.6 几乎不触发切分。因此实现自适应阈值:

  • 默认启用 use_adaptive_threshold=True
  • 有效阈值 effective_threshold = max(similarity_threshold, percentile(similarities, 0.25))
    • similarity_threshold 默认 0.55,作为地板值
    • 取所有相邻相似度的 25 分位数作为动态阈值
  • 显著下降条件:prev_sim - sim > significant_drop(默认 0.08
    • 避免在整体偏低但平稳的语料内部产生碎块

三、向量化实现

3.1 本地 dense embedding

项目 内容
文件 backend/embedding-bridge/backend/indexing/embedding.py
模型 BAAI/bge-m3
维度 1024
设备 CPU(默认)
池化方式 mean pooling + L2 normalize
最大长度 512 tokens

代码核心:

tokenizer = AutoTokenizer.from_pretrained(model_name, local_files_only=local_only)
model = AutoModel.from_pretrained(model_name, local_files_only=local_only)
outputs = model(**inputs)
embeddings = _mean_pooling(outputs.last_hidden_state, inputs["attention_mask"])
embeddings = F.normalize(embeddings, p=2, dim=1)

3.2 稀疏向量 / 全文检索

不再手动维护 BM25 字典,而是使用 Milvus 2.5+ 原生 BM25

  • Collection schema 中为 text 字段启用 FunctionType.BM25
  • Milvus 在插入时自动对 text 字段做中文分词并生成 sparse vector
  • 检索时同样使用 FunctionType.BM25 将查询文本转换为 sparse vector,与存储的 sparse vector 做匹配

这种方式避免了 Python 端维护词汇表与 IDF 的复杂性。

3.3 Embedding Bridge 服务

项目 内容
文件 backend/embedding-bridge/server.py
端口 18732
启动方式 Spring Boot 自动检测并启动子进程
主要接口 POST /embed 批量获取 dense embedding;POST /chunk 分块
退出机制 父进程 watcher:监控 Java 父进程 PID,父进程被杀后 Python 子进程自动退出

父进程 watcher 实现:

def _start_parent_watcher():
    import psutil, os, time
    parent = psutil.Process(os.getppid())
    def _watch():
        while True:
            time.sleep(2)
            if not parent.is_running() or parent.status() == psutil.STATUS_ZOMBIE:
                os._exit(0)
    threading.Thread(target=_watch, daemon=True).start()

四、Milvus 存储

4.1 Collection 设计

字段 类型 说明
id VARCHAR 主键,chunk_id,如 docId::l3::index
document_id Int64 所属文档 ID
text VARCHAR / TEXT 块文本内容,用于 BM25
dense_vector FloatVector(1024) bge-m3 dense 向量
sparse_vector SparseFloatVector Milvus BM25 自动生成
chunk_level Int8 固定 3(仅 L3)
chunk_idx Int64 全局顺序索引
file_type VARCHAR 源文件类型
filename VARCHAR 源文件名
file_path VARCHAR 源文件路径
page_number Int32 页码(PDF 等)

4.2 索引

  • dense_vector:IVF_FLAT / COSINE,用于语义检索
  • sparse_vector:SPARSE_INVERTED_INDEX,用于全文检索

4.3 检索方式

支持 hybrid search:

  • dense 召回:基于 bge-m3 向量相似度
  • sparse 召回:基于 BM25 全文匹配
  • 可配置融合权重,综合排序返回 topK

五、完成内容

5.1 已实现的文件

文件 变更
backend/embedding-bridge/backend/indexing/semantic_chunker.py 新增:基于 bge-m3 的语义分块器
backend/embedding-bridge/backend/indexing/text_splitter.py 修改:集成语义分块到三级分块器
backend/embedding-bridge/backend/indexing/embedding.py 修改/新增:本地 bge-m3 dense embedding
backend/embedding-bridge/server.py 修改:增加父进程 watcher,Java 被杀后自动退出
backend/hermes-bridge/hermes_bridge.py 修改:增加父进程 watcher(同机制)
backend/src/main/java/com/agent/management/service/Neo4jExecutorService.java 修改:增加 @PreDestroy destroy() 关闭 Neo4j drivers
backend/src/main/java/com/agent/management/config/MilvusConfig.java 修改:Milvus client destroyMethod="close"

5.2 已验证的测试

  1. 层级一致性:短文本(674 字符)和长文本(811 字符)均验证 L1/L2/L3 各自拼接等于原文。
  2. 语义边界检测
    • 990 字符五段跨领域文本(量子计算 / 太阳系 / 印象派 / 热带雨林 / 古罗马法)在 L3 切分为 5 块,每块对应一个主题,验证了 bge-m3 能够有效识别强语义边界。
    • 909 字符四段主题文本(AI / 烹饪 / 旅行 / 运动)中,由于中文相邻句子在 bge-m3 下相似度普遍偏高(0.70~0.85),自适应阈值(25 分位数)被抬高到约 0.74,仅在差异最显著的边界(烹饪→旅行,相似度从 0.81 骤降到 0.64)处切开,说明当前参数对高相似度中文语料偏保守,强主题边界才能稳定触发分块。
  3. 重叠策略验证:990 字符五段跨领域文本在 chunk_overlap=0 时 L1/L2/L3 拼接均严格等于原文;在 chunk_overlap=100 时 L3 在语义边界处产生预期重叠(每个 chunk 开头重复前一段末尾的完整句子),L1 仍严格无重叠。
  4. 进程清理:Java 主进程被杀后,Hermes 与 Embedding Bridge 子进程在数秒内自动退出。

六、关键设计决策

6.1 为什么用本地 bge-m3 替代智谱 embedding-2?

  • 避免外部 API 依赖与网络延迟
  • 本地模型一次加载,无限次推理
  • 保护数据隐私,文档内容不出本机
  • 1024 维 dense 向量质量足够支撑 RAG 检索

6.2 为什么 L3 才写入 Milvus?

  • L3 是最小检索粒度,适合 RAG 召回
  • L1/L2 仅用于层级展示与上下文聚合
  • 减少 Milvus 数据量,降低存储与检索开销

6.3 为什么语义分块只在 L2/L3 启用?

  • L1 是顶层切分,目标是控制在内存可处理的大块,不需要语义边界
  • L2/L3 是内容召回粒度,语义边界能显著提升检索质量
  • L1 必须保证严格拼接等于原文,因此 L1 之间不允许 overlap
  • L2/L3 允许在语义边界处保留 chunk_overlap,使承上启下的句子同时出现在相邻块中,提升检索召回率;overlap=0 时 L2/L3 拼接仍严格等于父级文本

6.4 为什么需要自适应阈值?

  • 中文句子在 bge-m3 下的余弦相似度普遍偏高
  • 固定阈值 0.6 在实测中几乎不触发切分
  • 取相似度分布的 25 分位数 + 显著下降条件,能在不同语料上保持稳定切分

七、配置说明

7.1 application.yml 相关配置

app:
  kb:
    enabled: true
    upload-dir: ./uploads/kb
    max-file-size: 52428800
    chunk-size: 1000               # L3 目标大小
    chunk-overlap: 200             # L2/L3 语义边界处允许的重叠量;L1 始终为 0

  milvus:
    enabled: true
    host: localhost
    port: 19530
    collection: kb_documents
    vector-dimension: 1024

  embedding-bridge:
    enabled: true
    host: 127.0.0.1
    port: 18732
    chunk-size: 800                # L3 目标大小
    chunk-overlap: 100             # L2/L3 语义边界处允许的重叠量;L1 始终为 0
    embedding-model: BAAI/bge-m3
    embedding-device: cpu

7.2 Python 环境

Embedding Bridge 依赖:

torch
transformers
fastapi
uvicorn
pymilvus>=2.5.0
psutil

八、待办 / 后续扩展

  • 端到端文档上传验证(Tika 解析 → 分块 → 向量化 → Milvus upsert)
  • 中文语义分块阈值调优(当前 adaptive p25 + drop=0.08 对高相似度中文语料偏保守,强主题边界才能稳定触发分块)
  • hybrid search 权重调优与前端检索测试
  • GPU 设备支持(当前仅 CPU)
  • 多语言/英文语料自适应阈值验证
  • RAG 工作流节点 kbRetrieval 接入

九、风险与注意事项

风险 说明
模型首次加载慢 bge-m3 模型首次加载可能需要数十秒到数分钟,取决于磁盘与 CPU
长文本截断 MAX_LENGTH=512,超长文本会被截断;当前按句子分块后单元通常小于 512 tokens
Windows 子进程残留 已通过父进程 watcher 解决;若 psutil 未安装则降级不监控
中文分词质量 依赖 Milvus 2.5+ 内置中文分词,需确保 Milvus 版本 >= 2.5

文档维护:当 text_splitter.pysemantic_chunker.pyembedding.pyserver.py 发生变更时,应同步更新本文档。