本文档记录项目中文档数据的本地语义分块、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 │ │
│ └─────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────┘
| 文件 | 作用 |
|---|---|
backend/embedding-bridge/backend/indexing/text_splitter.py |
三级层次化分块器 HierarchicalTextSplitter + SemanticTextSplitter |
backend/embedding-bridge/backend/indexing/semantic_chunker.py |
基于本地 embedding 的语义边界检测 SemanticChunker |
| 层级 | 大小 | 语义分块 | 重叠 | 说明 |
|---|---|---|---|---|
| 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 之间会出现内容重叠。
- 重叠发生在完整语义单元(句子/子句)边界,而不是字符级滑动窗口。
SemanticTextSplitter)sanitize_text):NFC 规范化、剔除零宽字符/C0/C1 控制符/BOM/PUA 区乱码、UTF-16 代理项。\n\n > 。 > ! > ? > ; > \n > , > 、 > 空格 > 字符chunk_size;超过大小时回退到最近语义分隔符。SemanticChunker)在规则分块之前增加一道语义粗分:
min_unit_length(默认 12)的单元向前合并,减少 embedding 噪声。embedding_service.get_embeddings() 批量获取单元向量。max_chunk_size 必须切开。中文语料相邻句子相似度普遍偏高(常见 0.65~0.85),固定阈值 0.6 几乎不触发切分。因此实现自适应阈值:
use_adaptive_threshold=Trueeffective_threshold = max(similarity_threshold, percentile(similarities, 0.25))
similarity_threshold 默认 0.55,作为地板值prev_sim - sim > significant_drop(默认 0.08)
| 项目 | 内容 |
|---|---|
| 文件 | 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)
不再手动维护 BM25 字典,而是使用 Milvus 2.5+ 原生 BM25:
text 字段启用 FunctionType.BM25text 字段做中文分词并生成 sparse vectorFunctionType.BM25 将查询文本转换为 sparse vector,与存储的 sparse vector 做匹配这种方式避免了 Python 端维护词汇表与 IDF 的复杂性。
| 项目 | 内容 |
|---|---|
| 文件 | 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()
| 字段 | 类型 | 说明 |
|---|---|---|
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 等) |
dense_vector:IVF_FLAT / COSINE,用于语义检索sparse_vector:SPARSE_INVERTED_INDEX,用于全文检索支持 hybrid search:
| 文件 | 变更 |
|---|---|
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" |
chunk_overlap=0 时 L1/L2/L3 拼接均严格等于原文;在 chunk_overlap=100 时 L3 在语义边界处产生预期重叠(每个 chunk 开头重复前一段末尾的完整句子),L1 仍严格无重叠。0.6 在实测中几乎不触发切分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
Embedding Bridge 依赖:
torch
transformers
fastapi
uvicorn
pymilvus>=2.5.0
psutil
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 发生变更时,应同步更新本文档。