# 本地语义分块与向量化实现文档 > 本文档记录项目中文档数据的本地语义分块、embedding 向量化、Milvus 存储的完整实现方式与完成状态。 **文档版本**:v1.0 **创建日期**:2026-06-27 **依赖状态**:已实现本地 bge-m3 dense + Milvus 2.5+ BM25 sparse,替代原智谱 embedding-2 方案。 --- ## 一、总体架构 ```text ┌─────────────────────────────────────────────────────────────────┐ │ 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 | 代码核心: ```python 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 实现: ```python 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` 相关配置 ```yaml 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 依赖: ```text 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.py`、`semantic_chunker.py`、`embedding.py` 或 `server.py` 发生变更时,应同步更新本文档。