Ver código fonte

1. 实现了本地 Embedding Bridge 服务,Spring Boot 自动管理 Python 子进程生命周期,提供分块与向量接口;
2. 实现了本地 bge-m3 稠密向量生成,替代原智谱 embedding-2 外部 API 依赖;
3. 实现了基于 Milvus 2.5+ 原生 BM25 的稀疏向量存储,由 Milvus 自动维护中文分词与全文索引;
4. 实现了三级层次化文本分块(L1/L2/L3),L2/L3 启用基于 bge-m3 的语义边界检测;
5. 新增了 SemanticChunker 语义分块器,通过自适应阈值与显著下降条件识别话题边界;
6. 实现了 Leaf-only 向量化策略,仅 L3 写入 Milvus,L1/L2 保留在业务库用于层级展示;
7. 新增了 EmbeddingBridgeClient 与 EmbeddingBridgeProcessManager,支持健康检查与自动重启;
8. 修复了后端进程被杀后 Python 子进程残留问题,Embedding Bridge 与 Hermes Bridge 均增加父进程守护;
9. 修复了 Spring Boot 关闭时 Neo4j 驱动未释放的问题,新增 @PreDestroy 统一关闭缓存的 Driver;
10. 更新了 application.yml.example,新增 embedding-bridge 与 milvus 相关配置项;
11. 更新了知识库文档管理前端页面,适配本地 embedding 与 Milvus 的写入流程;
12. 更新了 prompt.md 需求记录与 .gitignore,补充 embedding-bridge 目录忽略规则;
13. 新增了本地语义分块与向量化实现文档,记录架构、配置与验证结果。

weisijie 1 mês atrás
pai
commit
3511de4315
29 arquivos alterados com 3276 adições e 195 exclusões
  1. 18 3
      .gitignore
  2. 72 0
      backend/embedding-bridge/backend/indexing/embedding.py
  3. 362 0
      backend/embedding-bridge/backend/indexing/milvus_client.py
  4. 68 0
      backend/embedding-bridge/backend/indexing/milvus_writer.py
  5. 264 0
      backend/embedding-bridge/backend/indexing/semantic_chunker.py
  6. 347 0
      backend/embedding-bridge/backend/indexing/text_splitter.py
  7. 10 0
      backend/embedding-bridge/requirements.txt
  8. 305 0
      backend/embedding-bridge/server.py
  9. 33 0
      backend/hermes-bridge/hermes_bridge.py
  10. 84 0
      backend/src/main/java/com/agent/management/config/EmbeddingBridgeProperties.java
  11. 19 7
      backend/src/main/java/com/agent/management/config/MilvusConfig.java
  12. 9 2
      backend/src/main/java/com/agent/management/config/MilvusProperties.java
  13. 13 3
      backend/src/main/java/com/agent/management/controller/KbDocumentController.java
  14. 24 0
      backend/src/main/java/com/agent/management/model/entity/KbChunk.java
  15. 6 0
      backend/src/main/java/com/agent/management/repository/KbChunkRepository.java
  16. 11 1
      backend/src/main/java/com/agent/management/service/DocumentService.java
  17. 6 0
      backend/src/main/java/com/agent/management/service/Neo4jExecutorService.java
  18. 52 63
      backend/src/main/java/com/agent/management/service/impl/DocumentPipelineImpl.java
  19. 82 24
      backend/src/main/java/com/agent/management/service/impl/DocumentServiceImpl.java
  20. 143 0
      backend/src/main/java/com/agent/management/service/impl/EmbeddingBridgeClient.java
  21. 191 0
      backend/src/main/java/com/agent/management/service/impl/EmbeddingBridgeProcessManager.java
  22. 12 68
      backend/src/main/java/com/agent/management/service/impl/VectorStoreServiceImpl.java
  23. 29 4
      backend/src/main/resources/application.yml.example
  24. 302 0
      docs/local-semantic-chunking-and-embedding.md
  25. 314 0
      docs/super-mew-readme.md
  26. 8 0
      frontend/src/api/knowledge.js
  27. 300 3
      frontend/src/views/knowledge/DocumentManagement.vue
  28. 49 16
      frontend/src/views/workflow/WorkflowEditor.vue
  29. 143 1
      prompt.md

+ 18 - 3
.gitignore

@@ -48,6 +48,7 @@ temp/
 uploads/
 hermes-agent/
 __pycache__/
+list_agent_procs.ps1
 
 # ===== 调试截图 =====
 condition-elif-test.png
@@ -56,15 +57,28 @@ edge-bezier-test.png
 edge-selected-highlight.png
 edge-test-created.png
 edge-test.png
+frontend-chunks.png
+frontend-documents.png
+frontend-history.png
+frontend-management.png
+kb_chunk_full.png
+kb_chunks.png
+kb_documents.png
+model-dropdown-open.png
+model-management.png
+model-selected.png
 output-node-test.png
 output-node-test2.png
 page-check.png
 page-ws-check.png
 sse-after-refresh.png
 sse-first-load.png
-model-dropdown-open.png
-model-management.png
-model-selected.png
+test_doc.txt
+test_overlap.txt
+test_split_diff.txt
+test_split_diff2.txt
+test_split_result.txt
+test_semantic_long.py
 workflow-editor-after-drag.png
 workflow-editor-node-selected.png
 workflow-editor.png
@@ -76,6 +90,7 @@ workflow-list.png
 # ===== 第三方参考项目 =====
 skills-manage/
 beekeeper-studio/
+SuperMew/
 
 # ===== 需求迭代记录 =====
 # prompt.md

+ 72 - 0
backend/embedding-bridge/backend/indexing/embedding.py

@@ -0,0 +1,72 @@
+"""文本向量化服务 - 只支持密集向量(由 Milvus 2.5+ 原生支持中文分词与 BM25 全文检索)"""
+import os
+import threading
+
+import torch
+import torch.nn.functional as F
+from transformers import AutoModel, AutoTokenizer
+
+
+def _mean_pooling(last_hidden_state: torch.Tensor, attention_mask: torch.Tensor) -> torch.Tensor:
+    """对 last_hidden_state 按 attention_mask 做 mean pooling。"""
+    mask = attention_mask.unsqueeze(-1).expand(last_hidden_state.size()).float()
+    sum_embeddings = torch.sum(last_hidden_state * mask, dim=1)
+    sum_mask = torch.clamp(mask.sum(dim=1), min=1e-9)
+    return sum_embeddings / sum_mask
+
+
+def _create_dense_embedder() -> tuple[AutoTokenizer, AutoModel, str]:
+    model_name = os.getenv("EMBEDDING_MODEL", "BAAI/bge-m3")
+    device = os.getenv("EMBEDDING_DEVICE", "cpu")
+    local_only = os.getenv("EMBEDDING_LOCAL_FILES_ONLY", "true").lower() in ("1", "true", "yes")
+
+    tokenizer = AutoTokenizer.from_pretrained(model_name, local_files_only=local_only)
+    model = AutoModel.from_pretrained(model_name, local_files_only=local_only)
+    model.to(device)
+    model.eval()
+    return tokenizer, model, device
+
+
+class EmbeddingService:
+    """文本向量化服务 - 密集向量本地模型(基于 transformers,避免 sentence_transformers 拉入 pyarrow)"""
+
+    # 与 BAAI/bge-m3 推荐保持一致;800 中文字符约 300~400 tokens,512 足够
+    MAX_LENGTH = 512
+
+    def __init__(self, state_path=None):
+        self._tokenizer = None
+        self._model = None
+        self._device = None
+        self._lock = threading.Lock()
+
+    def _get_embedder(self):
+        if self._model is None:
+            with self._lock:
+                if self._model is None:
+                    self._tokenizer, self._model, self._device = _create_dense_embedder()
+        return self._tokenizer, self._model, self._device
+
+    def get_embeddings(self, texts: list[str]) -> list[list[float]]:
+        if not texts:
+            return []
+        try:
+            tokenizer, model, device = self._get_embedder()
+            with torch.no_grad():
+                inputs = tokenizer(
+                    texts,
+                    padding=True,
+                    truncation=True,
+                    max_length=self.MAX_LENGTH,
+                    return_tensors="pt",
+                )
+                inputs = {k: v.to(device) for k, v in inputs.items()}
+                outputs = model(**inputs)
+                embeddings = _mean_pooling(outputs.last_hidden_state, inputs["attention_mask"])
+                embeddings = F.normalize(embeddings, p=2, dim=1)
+            return embeddings.cpu().numpy().tolist()
+        except Exception as e:
+            raise Exception(f"本地密集嵌入模型调用失败: {str(e)}") from e
+
+
+# 全进程唯一实例(延迟加载模型,避免启动时立即加载)
+embedding_service = EmbeddingService()

+ 362 - 0
backend/embedding-bridge/backend/indexing/milvus_client.py

@@ -0,0 +1,362 @@
+"""Milvus 访问层:无状态 Store + 短生命周期 gRPC 连接(避免长期持有失效 channel)。"""
+from __future__ import annotations
+
+import os
+from contextlib import contextmanager
+from dataclasses import dataclass
+from typing import Callable, Iterator, TypeVar
+
+from pymilvus import AnnSearchRequest, DataType, MilvusClient, RRFRanker, Function, FunctionType
+
+QUERY_MAX_LIMIT = 16384
+T = TypeVar("T")
+
+
+@dataclass(frozen=True)
+class MilvusSettings:
+    host: str
+    port: str
+    collection_name: str
+    uri: str
+    timeout: float
+
+    @classmethod
+    def from_env(cls) -> "MilvusSettings":
+        host = os.getenv("MILVUS_HOST", "localhost")
+        port = os.getenv("MILVUS_PORT", "19530")
+        collection = os.getenv("MILVUS_COLLECTION", "kb_documents")
+        timeout = float(os.getenv("MILVUS_TIMEOUT", "30"))
+        return cls(
+            host=host,
+            port=port,
+            collection_name=collection,
+            uri=f"http://{host}:{port}",
+            timeout=timeout,
+        )
+
+
+@contextmanager
+def milvus_client_session(settings: MilvusSettings | None = None) -> Iterator[MilvusClient]:
+    """一次 RPC 会话:创建连接,用完后关闭,不缓存 gRPC channel。"""
+    cfg = settings or MilvusSettings.from_env()
+    client = MilvusClient(uri=cfg.uri, timeout=cfg.timeout)
+    try:
+        yield client
+    finally:
+        client.close()
+
+
+def _normalize_filter(filter_expr: str) -> str:
+    return filter_expr.strip() if filter_expr.strip() else "id >= 0"
+
+
+class MilvusStore:
+    """Milvus 集合读写;本身不持有连接,所有 IO 经 milvus_client_session。"""
+
+    def __init__(self, settings: MilvusSettings | None = None):
+        self._settings = settings or MilvusSettings.from_env()
+
+    @property
+    def collection_name(self) -> str:
+        return self._settings.collection_name
+
+    def _run(self, operation: Callable[[MilvusClient], T]) -> T:
+        with milvus_client_session(self._settings) as client:
+            return operation(client)
+
+    @contextmanager
+    def session(self) -> Iterator[MilvusClient]:
+        """同一业务流(如整次上传)内复用一条连接,用毕即关。"""
+        with milvus_client_session(self._settings) as client:
+            yield client
+
+    @staticmethod
+    def ensure_collection(client: MilvusClient, collection_name: str, dense_dim: int) -> None:
+        if client.has_collection(collection_name):
+            return
+
+        schema = client.create_schema(auto_id=True, enable_dynamic_field=True)
+        schema.add_field("id", DataType.INT64, is_primary=True, auto_id=True)
+        schema.add_field("dense_embedding", DataType.FLOAT_VECTOR, dim=dense_dim)
+        schema.add_field("sparse_embedding", DataType.SPARSE_FLOAT_VECTOR)
+        schema.add_field(
+            "text",
+            DataType.VARCHAR,
+            max_length=65535,
+            enable_analyzer=True,
+            analyzer_params={"type": "standard"},
+            enable_match=True,
+        )
+        schema.add_field("document_id", DataType.INT64)
+        schema.add_field("filename", DataType.VARCHAR, max_length=255)
+        schema.add_field("file_type", DataType.VARCHAR, max_length=50)
+        schema.add_field("file_path", DataType.VARCHAR, max_length=1024)
+        schema.add_field("page_number", DataType.INT64)
+        schema.add_field("chunk_idx", DataType.INT64)
+        schema.add_field("chunk_id", DataType.VARCHAR, max_length=512)
+        schema.add_field("parent_chunk_id", DataType.VARCHAR, max_length=512)
+        schema.add_field("root_chunk_id", DataType.VARCHAR, max_length=512)
+        schema.add_field("chunk_level", DataType.INT64)
+
+        bm25_function = Function(
+            name="text_bm25_emb",
+            function_type=FunctionType.BM25,
+            input_field_names=["text"],
+            output_field_names=["sparse_embedding"],
+        )
+        schema.add_function(bm25_function)
+
+        index_params = client.prepare_index_params()
+        index_params.add_index(
+            field_name="dense_embedding",
+            index_type="HNSW",
+            metric_type="IP",
+            params={"M": 16, "efConstruction": 256},
+        )
+        index_params.add_index(
+            field_name="sparse_embedding",
+            index_type="SPARSE_INVERTED_INDEX",
+            metric_type="BM25",
+            params={"drop_ratio_build": 0.2},
+        )
+        try:
+            client.create_collection(
+                collection_name=collection_name,
+                schema=schema,
+                index_params=index_params,
+            )
+        except Exception as e:
+            # Milvus Lite on Windows 在 create_index 后重命名 manifest.json 时偶发
+            # WinError 183,但集合与索引实际已创建成功,因此若集合已存在则忽略。
+            if client.has_collection(collection_name):
+                return
+            raise
+
+    def init_collection(self, dense_dim: int | None = None) -> None:
+        if dense_dim is None:
+            dense_dim = int(os.getenv("DENSE_EMBEDDING_DIM", "1024"))
+
+        def _init(client: MilvusClient) -> None:
+            self.ensure_collection(client, self.collection_name, dense_dim)
+
+        self._run(_init)
+
+    def insert(self, data: list[dict]):
+        return self._run(lambda client: client.insert(self.collection_name, data))
+
+    def delete_by_document(self, document_id: int) -> int:
+        def _delete(client: MilvusClient) -> int:
+            result = client.delete(
+                collection_name=self.collection_name,
+                filter=f"document_id == {document_id}",
+            )
+            return getattr(result, "delete_count", len(result)) if result else 0
+
+        return self._run(_delete)
+
+    def delete_by_ids(self, ids: list[int | str]) -> int:
+        if not ids:
+            return 0
+
+        def _delete(client: MilvusClient) -> int:
+            result = client.delete(collection_name=self.collection_name, ids=ids)
+            return getattr(result, "delete_count", len(result)) if result else 0
+
+        return self._run(_delete)
+
+    def query(
+        self,
+        filter_expr: str = "",
+        output_fields: list[str] | None = None,
+        limit: int = 10000,
+        offset: int = 0,
+    ):
+        expr = _normalize_filter(filter_expr)
+        fields = output_fields or ["filename", "file_type"]
+
+        def _query(client: MilvusClient):
+            return client.query(
+                collection_name=self.collection_name,
+                filter=expr,
+                output_fields=fields,
+                limit=min(limit, QUERY_MAX_LIMIT),
+                offset=offset,
+            )
+
+        return self._run(_query)
+
+    def query_all(self, filter_expr: str = "", output_fields: list[str] | None = None) -> list:
+        """分页拉取;单次 session 内完成,避免每页新建连接。"""
+        fields = output_fields or ["filename", "file_type"]
+        expr = _normalize_filter(filter_expr)
+
+        def _query_all(client: MilvusClient) -> list:
+            out: list = []
+            offset = 0
+            while True:
+                batch = client.query(
+                    collection_name=self.collection_name,
+                    filter=expr,
+                    output_fields=fields,
+                    limit=QUERY_MAX_LIMIT,
+                    offset=offset,
+                )
+                if not batch:
+                    break
+                out.extend(batch)
+                if len(batch) < QUERY_MAX_LIMIT:
+                    break
+                offset += len(batch)
+            return out
+
+        return self._run(_query_all)
+
+    def get_chunks_by_ids(self, chunk_ids: list[str]) -> list[dict]:
+        ids = [item for item in chunk_ids if item]
+        if not ids:
+            return []
+        quoted_ids = ", ".join(f'"{item}"' for item in ids)
+        return self.query(
+            filter_expr=f"chunk_id in [{quoted_ids}]",
+            output_fields=[
+                "text",
+                "filename",
+                "file_type",
+                "page_number",
+                "chunk_id",
+                "parent_chunk_id",
+                "root_chunk_id",
+                "chunk_level",
+                "chunk_idx",
+            ],
+            limit=len(ids),
+        )
+
+    def hybrid_retrieve(
+        self,
+        dense_embedding: list[float],
+        query: str,
+        top_k: int = 5,
+        rrf_k: int = 60,
+        filter_expr: str = "",
+    ) -> list[dict]:
+        output_fields = [
+            "text",
+            "filename",
+            "file_type",
+            "page_number",
+            "chunk_id",
+            "parent_chunk_id",
+            "root_chunk_id",
+            "chunk_level",
+            "chunk_idx",
+        ]
+        dense_search = AnnSearchRequest(
+            data=[dense_embedding],
+            anns_field="dense_embedding",
+            param={"metric_type": "IP", "params": {"ef": 64}},
+            limit=top_k * 2,
+            expr=filter_expr,
+        )
+        sparse_search = AnnSearchRequest(
+            data=[query],
+            anns_field="sparse_embedding",
+            param={"metric_type": "BM25", "params": {"drop_ratio_search": 0.2}},
+            limit=top_k * 2,
+            expr=filter_expr,
+        )
+        reranker = RRFRanker(k=rrf_k)
+
+        def _search(client: MilvusClient):
+            return client.hybrid_search(
+                collection_name=self.collection_name,
+                reqs=[dense_search, sparse_search],
+                ranker=reranker,
+                limit=top_k,
+                output_fields=output_fields,
+            )
+
+        results = self._run(_search)
+        formatted_results = []
+        for hits in results:
+            for hit in hits:
+                formatted_results.append({
+                    "id": hit.get("id"),
+                    "text": hit.get("text", ""),
+                    "filename": hit.get("filename", ""),
+                    "file_type": hit.get("file_type", ""),
+                    "page_number": hit.get("page_number", 0),
+                    "chunk_id": hit.get("chunk_id", ""),
+                    "parent_chunk_id": hit.get("parent_chunk_id", ""),
+                    "root_chunk_id": hit.get("root_chunk_id", ""),
+                    "chunk_level": hit.get("chunk_level", 0),
+                    "chunk_idx": hit.get("chunk_idx", 0),
+                    "score": hit.get("distance", 0.0),
+                })
+        return formatted_results
+
+    def dense_retrieve(
+        self,
+        dense_embedding: list[float],
+        top_k: int = 5,
+        filter_expr: str = "",
+    ) -> list[dict]:
+        def _search(client: MilvusClient):
+            return client.search(
+                collection_name=self.collection_name,
+                data=[dense_embedding],
+                anns_field="dense_embedding",
+                search_params={"metric_type": "IP", "params": {"ef": 64}},
+                limit=top_k,
+                output_fields=[
+                    "text",
+                    "filename",
+                    "file_type",
+                    "page_number",
+                    "chunk_id",
+                    "parent_chunk_id",
+                    "root_chunk_id",
+                    "chunk_level",
+                    "chunk_idx",
+                ],
+                filter=filter_expr,
+            )
+
+        results = self._run(_search)
+        formatted_results = []
+        for hits in results:
+            for hit in hits:
+                formatted_results.append({
+                    "id": hit.get("id"),
+                    "text": hit.get("entity", {}).get("text", ""),
+                    "filename": hit.get("entity", {}).get("filename", ""),
+                    "file_type": hit.get("entity", {}).get("file_type", ""),
+                    "page_number": hit.get("entity", {}).get("page_number", 0),
+                    "chunk_id": hit.get("entity", {}).get("chunk_id", ""),
+                    "parent_chunk_id": hit.get("entity", {}).get("parent_chunk_id", ""),
+                    "root_chunk_id": hit.get("entity", {}).get("root_chunk_id", ""),
+                    "chunk_level": hit.get("entity", {}).get("chunk_level", 0),
+                    "chunk_idx": hit.get("entity", {}).get("chunk_idx", 0),
+                    "score": hit.get("distance", 0.0),
+                })
+        return formatted_results
+
+    def drop_collection(self) -> None:
+        def _drop(client: MilvusClient) -> None:
+            if client.has_collection(self.collection_name):
+                client.drop_collection(self.collection_name)
+
+        self._run(_drop)
+
+
+# 兼容旧名;全项目共用同一无状态 Store 实例即可(不缓存连接)
+MilvusManager = MilvusStore
+
+_store: MilvusStore | None = None
+
+
+def get_milvus_store() -> MilvusStore:
+    global _store
+    if _store is None:
+        _store = MilvusStore()
+    return _store

+ 68 - 0
backend/embedding-bridge/backend/indexing/milvus_writer.py

@@ -0,0 +1,68 @@
+"""文档向量化并写入 Milvus - 支持密集+稀疏向量"""
+import os
+
+from backend.indexing.embedding import EmbeddingService, embedding_service as _default_embedding_service
+from backend.indexing.milvus_client import MilvusStore, get_milvus_store
+
+
+class MilvusWriter:
+    """文档向量化并写入 Milvus 服务 - 支持混合检索"""
+
+    def __init__(self, embedding_service: EmbeddingService = None, milvus_manager: MilvusStore = None):
+        self.embedding_service = embedding_service or _default_embedding_service
+        self.milvus_manager = milvus_manager or get_milvus_store()
+
+    def write_chunks(self, chunks: list[dict], batch_size: int = 50, progress_callback=None):
+        """将 L3 叶子分块写入 Milvus(稠密向量 + 服务端 BM25 稀疏向量)。"""
+        if not chunks:
+            return []
+
+        leaf_chunks = [c for c in chunks if c.get("chunk_level") == 3]
+        if not leaf_chunks:
+            return []
+
+        dense_dim = int(os.getenv("DENSE_EMBEDDING_DIM", "1024"))
+        total = len(leaf_chunks)
+        vector_ids = []
+
+        with self.milvus_manager.session() as client:
+            MilvusStore.ensure_collection(client, self.milvus_manager.collection_name, dense_dim)
+
+            for i in range(0, total, batch_size):
+                batch = leaf_chunks[i : i + batch_size]
+                texts = [doc["text"] for doc in batch]
+                dense_embeddings = self.embedding_service.get_embeddings(texts)
+
+                insert_data = [
+                    {
+                        "dense_embedding": dense_emb,
+                        "text": doc["text"],
+                        "document_id": doc.get("document_id"),
+                        "filename": doc["filename"],
+                        "file_type": doc["file_type"],
+                        "file_path": doc.get("file_path", ""),
+                        "page_number": doc.get("page_number", 0),
+                        "chunk_idx": doc.get("chunk_idx", 0),
+                        "chunk_id": doc.get("chunk_id", ""),
+                        "parent_chunk_id": doc.get("parent_chunk_id", ""),
+                        "root_chunk_id": doc.get("root_chunk_id", ""),
+                        "chunk_level": doc.get("chunk_level", 0),
+                    }
+                    for doc, dense_emb in zip(batch, dense_embeddings)
+                ]
+
+                result = client.insert(self.milvus_manager.collection_name, insert_data)
+                ids = result.get("ids", []) if isinstance(result, dict) else getattr(result, "primary_keys", [])
+                vector_ids.extend(ids)
+
+                if progress_callback:
+                    processed = min(i + batch_size, total)
+                    progress_callback(processed, total)
+
+        # 返回所有 leaf_chunks 对应的 vector_id 列表
+        id_map = {leaf_chunks[i]["chunk_id"]: vector_ids[i] for i in range(len(vector_ids))}
+        return [id_map.get(c["chunk_id"]) for c in leaf_chunks]
+
+    def write_documents(self, documents: list[dict], batch_size: int = 50, progress_callback=None):
+        """兼容旧接口:传入 list[dict] 直接写入(要求 dict 已包含 document_id 等字段)。"""
+        return self.write_chunks(documents, batch_size, progress_callback)

+ 264 - 0
backend/embedding-bridge/backend/indexing/semantic_chunker.py

@@ -0,0 +1,264 @@
+"""基于本地 embedding 模型的语义分块器。
+
+把文本按句子/子句切分成语义单元后,计算相邻单元向量相似度,
+在相似度显著下降处建立分块边界,从而实现按话题主题分块。
+"""
+import logging
+import math
+import os
+import re
+from typing import List, Tuple
+
+logger = logging.getLogger("semantic-chunker")
+
+# 语义单元边界:段落/句子级别。与 text_splitter 分隔符保持一致,但粒度更粗。
+_SEMANTIC_UNIT_SEPARATORS = ["\n\n", "。", "!", "?", ";", "\n"]
+_UNIT_SPLIT_PATTERN = re.compile(
+    "(" + "|".join(re.escape(s) for s in _SEMANTIC_UNIT_SEPARATORS) + ")"
+)
+
+
+def _split_to_units(text: str) -> List[str]:
+    """
+    把文本切分为语义单元,每个单元包含末尾分隔符。
+    例如:['第一句话。', '第二句话!', '第三句话;']
+
+    注意:regex split 在相邻分隔符之间会产生空字符串(如 "。
+" 之间),
+    且孤立的换行符不能独立成单元。这里把分隔符附加到当前单元,直到
+    单元包含非空白内容后再结束,从而保证拼接结果等于原文。
+    """
+    parts = _UNIT_SPLIT_PATTERN.split(text)
+    units: List[str] = []
+    current = ""
+    for part in parts:
+        if part in _SEMANTIC_UNIT_SEPARATORS:
+            current += part
+            # 当前单元已包含非空白内容时结束该单元;否则继续等待后续文本
+            if current.strip():
+                units.append(current)
+                current = ""
+        else:
+            current += part
+    if current:
+        units.append(current)
+    return units
+
+
+def _merge_tiny_units(units: List[str], min_length: int = 12) -> List[str]:
+    """
+    把过短的单元(如标题、单个语气词)向前合并,减少 embedding 噪声。
+    合并后若仍过短则继续合并,直到末尾。
+    """
+    if not units:
+        return []
+    merged: List[str] = [units[0]]
+    for unit in units[1:]:
+        if len(merged[-1].strip()) < min_length:
+            merged[-1] = merged[-1] + unit
+        elif len(unit.strip()) < min_length:
+            merged[-1] = merged[-1] + unit
+        else:
+            merged.append(unit)
+    return merged
+
+
+class SemanticChunker:
+    """
+    基于向量相似度的语义分块器。
+
+    分块策略:
+    1. 先把文本切分为语义单元(句子/段落)。
+    2. 用本地 embedding 模型为每个单元生成向量。
+    3. 计算相邻单元余弦相似度。
+    4. 当相似度低于阈值,或当前段落长度超过 max_chunk_size 时,建立新边界。
+
+    阈值策略:
+    - 默认启用自适应阈值:取相邻单元相似度分布的 25 分位数,且不低于
+      similarity_threshold 地板值。对中文等相似度整体偏高的语料更稳健。
+    - 仅低于阈值还不够,必须同时满足“显著下降”条件:当前相似度比前一对
+      单元下降超过 significant_drop,从而避免在整体都低但平稳的语料内部过切。
+    - 关闭自适应阈值时,使用固定 similarity_threshold。
+
+    重叠策略:
+    - 当 chunk_overlap > 0 时,语义边界处的“承上启下”单元会被追加到下一个
+      chunk 的开头,实现相邻 chunk 在句子/子句边界的重叠,而不是字符级滑动。
+    - 重叠量受 chunk_overlap 上限限制,避免无限重复。
+    """
+
+    def __init__(
+        self,
+        similarity_threshold: float = 0.55,
+        max_chunk_size: int = 800,
+        min_unit_length: int = 12,
+        batch_size: int = 32,
+        use_adaptive_threshold: bool = True,
+        significant_drop: float = 0.08,
+        chunk_overlap: int = 0,
+    ):
+        self.similarity_threshold = similarity_threshold
+        self.max_chunk_size = max_chunk_size
+        self.min_unit_length = min_unit_length
+        self.batch_size = batch_size
+        self.use_adaptive_threshold = use_adaptive_threshold
+        self.significant_drop = significant_drop
+        self.chunk_overlap = chunk_overlap
+
+    def split_text(self, text: str) -> List[str]:
+        """
+        对 text 执行语义分块。
+
+        返回的段落列表按顺序拼接包含原文全部内容;若启用重叠,相邻段落之间会
+        共享边界处的语义单元(承上启下句子),因此拼接结果会大于原文。
+        """
+        if not text:
+            return []
+        if len(text) <= self.max_chunk_size:
+            return [text]
+
+        units = _split_to_units(text)
+        units = _merge_tiny_units(units, self.min_unit_length)
+        if not units:
+            return [text]
+        if len(units) == 1:
+            return [text]
+
+        embeddings = self._embed_units(units)
+        return self._build_chunks(units, embeddings)
+
+    def _embed_units(self, units: List[str]) -> List[List[float]]:
+        """批量获取语义单元向量。"""
+        # 延迟导入,避免在 embedding-bridge 启动时立即加载模型
+        try:
+            from backend.indexing.embedding import embedding_service
+        except ImportError:
+            logger.warning("embedding_service 不可用,回退到规则分块")
+            return []
+
+        try:
+            texts = [u.strip() for u in units]
+            all_embeddings: List[List[float]] = []
+            for i in range(0, len(texts), self.batch_size):
+                batch = texts[i : i + self.batch_size]
+                emb = embedding_service.get_embeddings(batch)
+                all_embeddings.extend(emb)
+            return all_embeddings
+        except Exception as e:
+            logger.warning("语义向量计算失败,回退到规则分块: %s", e)
+            return []
+
+    def _build_chunks(
+        self, units: List[str], embeddings: List[List[float]]
+    ) -> List[str]:
+        """根据相似度构建语义段落,支持边界单元重叠。"""
+        chunks: List[str] = []
+        current_chunk = units[0]
+        current_len = len(units[0])
+
+        # 预计算所有相邻单元相似度
+        similarities: List[float] = []
+        if embeddings and len(embeddings) == len(units):
+            for i in range(1, len(units)):
+                similarities.append(_cosine_similarity(embeddings[i - 1], embeddings[i]))
+
+        effective_threshold = self.similarity_threshold
+        if self.use_adaptive_threshold and similarities:
+            effective_threshold = max(
+                self.similarity_threshold,
+                _percentile(similarities, 0.25),
+            )
+            logger.debug("自适应语义阈值: %.3f (地板值: %.3f)", effective_threshold, self.similarity_threshold)
+
+        for i in range(1, len(units)):
+            unit = units[i]
+            unit_len = len(unit)
+
+            # 强制边界:超过最大长度必须切开
+            if current_len + unit_len > self.max_chunk_size:
+                chunks.append(current_chunk)
+                current_chunk = unit
+                current_len = unit_len
+                continue
+
+            # 语义边界:相似度低于有效阈值且出现显著下降时切开
+            should_split = False
+            if similarities and i - 1 < len(similarities):
+                sim = similarities[i - 1]
+                if sim < effective_threshold:
+                    prev_sim = similarities[i - 2] if i >= 2 else 1.0
+                    if prev_sim - sim > self.significant_drop:
+                        should_split = True
+                        logger.debug(
+                            "语义边界: units[%s]~[%s] similarity=%.3f < %.3f, drop=%.3f",
+                            i - 1,
+                            i,
+                            sim,
+                            effective_threshold,
+                            prev_sim - sim,
+                        )
+
+            if should_split:
+                chunks.append(current_chunk)
+                # 边界处的重叠单元:把当前 chunk 末尾若干完整语义单元共享给下一个 chunk
+                overlap_units = self._extract_overlap_units(current_chunk)
+                current_chunk = "".join(overlap_units) + unit
+                current_len = sum(len(u) for u in overlap_units) + unit_len
+            else:
+                current_chunk += unit
+                current_len += unit_len
+
+        if current_chunk:
+            chunks.append(current_chunk)
+
+        return chunks
+
+    def _extract_overlap_units(self, current_chunk: str) -> List[str]:
+        """从当前 chunk 末尾提取不超过 chunk_overlap 的完整语义单元。"""
+        if self.chunk_overlap <= 0 or not current_chunk:
+            return []
+
+        current_units = _split_to_units(current_chunk)
+        if not current_units:
+            return []
+
+        overlap: List[str] = []
+        total_len = 0
+        for u in reversed(current_units):
+            candidate_len = total_len + len(u)
+            if overlap and candidate_len > self.chunk_overlap:
+                break
+            overlap.insert(0, u)
+            total_len = candidate_len
+            if total_len >= self.chunk_overlap:
+                break
+
+        return overlap
+
+
+def _percentile(values: List[float], q: float) -> float:
+    """计算分位数(q 在 [0, 1] 之间),不依赖 numpy。"""
+    if not values:
+        return 0.0
+    sorted_values = sorted(values)
+    n = len(sorted_values)
+    if n == 1:
+        return sorted_values[0]
+    index = q * (n - 1)
+    lower = int(index)
+    upper = lower + 1
+    if upper >= n:
+        return sorted_values[-1]
+    weight = index - lower
+    return sorted_values[lower] * (1 - weight) + sorted_values[upper] * weight
+
+
+def _cosine_similarity(a: List[float], b: List[float]) -> float:
+    """计算两个向量的余弦相似度。"""
+    if not a or not b or len(a) != len(b):
+        return 0.0
+    dot = sum(x * y for x, y in zip(a, b))
+    norm_a = math.sqrt(sum(x * x for x in a))
+    norm_b = math.sqrt(sum(x * x for x in b))
+    if norm_a == 0 or norm_b == 0:
+        return 0.0
+    return dot / (norm_a * norm_b)

+ 347 - 0
backend/embedding-bridge/backend/indexing/text_splitter.py

@@ -0,0 +1,347 @@
+"""文档三级分块服务(接收已解析文本,不直接读取文件)"""
+import re
+import unicodedata
+from typing import Dict, List
+
+from backend.indexing.semantic_chunker import SemanticChunker
+
+# 编译非打印 C0/C1 控制字符的正则(保留常规排版字:\t, \n, \r)
+_CONTROL_CHAR_RE = re.compile(r"[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]")
+# 编译零宽字符和不可见格式化控制字符(零宽空白、BOM 标记、左右强排标志等)
+_INVISIBLE_CHAR_RE = re.compile(r"[\u200b-\u200d\ufeff\u200f\u202a-\u202e]")
+
+
+def sanitize_text(text: str) -> str:
+    """
+    企业级标准文本净化器 (Text Sanitizer)。
+    1. 规范化 (Normalization):统一转换为标准 NFC 格式。
+    2. 剔除/替换不合法及不可见字节:过滤 NUL、零宽字符、BOM 标签等。
+    3. 清洗非打印字符及乱码:剔除 C0/C1 控制符号,剥离 Unicode PUA 私有使用区乱码符号。
+    4. 编码收敛防爆:利用 utf-8 ignore 安全剥离孤立的 UTF-16 代理项。
+    """
+    if not text:
+        return ""
+
+    text = unicodedata.normalize("NFC", text)
+    text = _INVISIBLE_CHAR_RE.sub("", text)
+    text = _CONTROL_CHAR_RE.sub("", text)
+    text = re.sub(r"[\ue000-\uf8ff]", "", text)
+
+    try:
+        cleaned = text.encode("utf-8", "ignore").decode("utf-8", "ignore")
+    except Exception:
+        chars = []
+        for char in text:
+            if 0xD800 <= ord(char) <= 0xDFFF:
+                continue
+            chars.append(char)
+        cleaned = "".join(chars)
+
+    return cleaned
+
+
+# 分隔符按语义粒度分组,越靠前越优先作为分块边界。
+# 切分时保留分隔符作为独立原子,保证拼接后原文不变。
+_SENTENCE_SEPARATORS = ["\n\n", "。", "!", "?", ";", "\n"]
+_CLAUSE_SEPARATORS = [",", "、"]
+_WORD_SEPARATORS = [" ", ""]
+
+DEFAULT_SEPARATORS = _SENTENCE_SEPARATORS + _CLAUSE_SEPARATORS + _WORD_SEPARATORS
+
+
+class SemanticTextSplitter:
+    """
+    语义优先的文本分块器。
+
+    核心保证:
+    1. 分块后所有 chunk 按顺序拼接,等于原始输入。
+    2. 每个 chunk 的末尾尽量落在语义边界(段落 > 句子 > 子句 > 词)。
+    3. 重叠区只在边界处截取,不会从句子中间截断。
+    """
+
+    def __init__(
+        self,
+        chunk_size: int = 800,
+        chunk_overlap: int = 100,
+        separators: List[str] | None = None,
+        use_semantic_chunking: bool = False,
+        semantic_similarity_threshold: float = 0.6,
+    ):
+        self.chunk_size = chunk_size
+        self.chunk_overlap = chunk_overlap
+        self.separators = separators or DEFAULT_SEPARATORS
+        self.use_semantic_chunking = use_semantic_chunking
+        self.semantic_chunker = None
+        if use_semantic_chunking:
+            # overlap 在语义段落边界处由 SemanticChunker 处理,保证承上启下句子完整
+            self.semantic_chunker = SemanticChunker(
+                similarity_threshold=semantic_similarity_threshold,
+                max_chunk_size=chunk_size,
+                chunk_overlap=chunk_overlap,
+            )
+
+    def split_text(self, text: str) -> List[str]:
+        """对单段文本执行分块。"""
+        text = sanitize_text(text)
+        if not text:
+            return []
+        if len(text) <= self.chunk_size:
+            return [text]
+
+        # 若启用语义分块,先按话题边界粗分,再在每个语义段落内做规则细分。
+        # 保证所有段落拼接仍等于原文。
+        if self.use_semantic_chunking and self.semantic_chunker is not None:
+            semantic_chunks = self.semantic_chunker.split_text(text)
+            result: List[str] = []
+            for chunk in semantic_chunks:
+                if len(chunk) <= self.chunk_size:
+                    result.append(chunk)
+                else:
+                    atoms = self._split_into_atoms(chunk)
+                    result.extend(self._merge_atoms(atoms))
+            return result
+
+        # 未启用语义分块:按标点规则切分。
+        atoms = self._split_into_atoms(text)
+        return self._merge_atoms(atoms)
+
+    def _split_into_atoms(self, text: str) -> List[str]:
+        """
+        递归地在最粗可用的分隔符处切分文本,保留分隔符作为独立原子。
+        例如 "。" 切分后,标点本身会作为一个原子项,保证后续拼接不丢标点。
+        """
+        return self._split_recursive(text, self.separators.copy())
+
+    def _split_recursive(self, text: str, separators: List[str]) -> List[str]:
+        if len(text) <= self.chunk_size or not separators:
+            return [text] if text else []
+
+        separator = separators.pop(0)
+        if separator == "":
+            # 兜底:按字符切分
+            return list(text)
+
+        parts = text.split(separator)
+        if len(parts) <= 1:
+            return self._split_recursive(text, separators)
+
+        result: List[str] = []
+        for i, part in enumerate(parts):
+            if i > 0:
+                # 分隔符本身作为独立原子保留
+                result.append(separator)
+            if not part:
+                continue
+            if len(part) > self.chunk_size:
+                result.extend(self._split_recursive(part, separators.copy()))
+            else:
+                result.append(part)
+
+        return result
+
+    def _merge_atoms(self, atoms: List[str]) -> List[str]:
+        """
+        把原子片段合并成接近 chunk_size 的块。
+
+        合并规则:
+        1. 原子不可拆分;
+        2. 中间 chunk 的末尾尽量落在语义分隔符上;
+        3. 因回退而移出当前 chunk 的非边界原子,会保留到下一个 chunk 中,保证原文不丢失;
+        4. 若启用 overlap,下一个 chunk 以当前 chunk 末尾的边界感知重叠区开头。
+        """
+        if not atoms:
+            return []
+
+        chunks: List[str] = []
+        current: List[str] = []
+        current_len = 0
+
+        def last_sep_index(seq: List[str]) -> int:
+            for idx in range(len(seq) - 1, -1, -1):
+                if seq[idx] in self.separators:
+                    return idx
+            return -1
+
+        for atom in atoms:
+            atom_len = len(atom)
+
+            # 加入当前原子会超过 chunk_size,则先结束当前 chunk
+            if current and current_len + atom_len > self.chunk_size:
+                sep_idx = last_sep_index(current)
+                if sep_idx >= 0:
+                    chunk_atoms = current[: sep_idx + 1]
+                    pending = current[sep_idx + 1 :] + [atom]
+                else:
+                    # 当前窗口内没有可用分隔符,直接结束整个窗口
+                    chunk_atoms = current
+                    pending = [atom]
+
+                chunks.append("".join(chunk_atoms))
+
+                if self.chunk_overlap > 0 and chunk_atoms:
+                    overlap_atoms = self._extract_overlap_atoms(chunk_atoms)
+                    current = overlap_atoms + pending
+                else:
+                    current = pending
+                current_len = sum(len(a) for a in current)
+                continue
+
+            current.append(atom)
+            current_len += atom_len
+
+        if current:
+            chunks.append("".join(current))
+
+        return chunks
+
+    def _extract_overlap_atoms(self, atoms: List[str]) -> List[str]:
+        """
+        从一组原子末尾回退,截取不超过 chunk_overlap 且以语义边界结尾的重叠区。
+        优先让整个重叠区以句子或子句分隔符结尾。
+        """
+        if self.chunk_overlap <= 0 or not atoms:
+            return []
+
+        overlap: List[str] = []
+        length = 0
+        for atom in reversed(atoms):
+            overlap.insert(0, atom)
+            length += len(atom)
+            if length >= self.chunk_overlap:
+                break
+
+        # 让 overlap 以语义边界结束
+        while len(overlap) > 1:
+            if overlap[-1] in self.separators:
+                break
+            removed_len = len(overlap[-1])
+            if length - removed_len >= self.chunk_overlap // 3:
+                length -= removed_len
+                overlap.pop()
+            else:
+                break
+
+        return overlap
+
+    @staticmethod
+    def _atoms_length(atoms: List[str]) -> int:
+        return sum(len(a) for a in atoms)
+
+
+class HierarchicalTextSplitter:
+    """三级层次化分块(L1 / L2 / L3)"""
+
+    def __init__(self, chunk_size: int = 800, chunk_overlap: int = 100):
+        # L1 是对原文的粗粒度切分,L1 之间不允许 overlap,保证所有 L1 拼接等于原文。
+        # L2/L3 在父级内部进行切分,启用基于 embedding 相似度的语义边界;
+        # 允许 L2/L3 在语义边界处保留 overlap,使承上启下的句子同时出现在前后块中。
+        level_1_size = max(2000, chunk_size * 3)
+        level_2_size = max(1000, chunk_size * 2)
+        level_3_size = max(600, chunk_size)
+
+        self._splitter_level_1 = SemanticTextSplitter(
+            chunk_size=level_1_size,
+            chunk_overlap=0,
+            separators=DEFAULT_SEPARATORS,
+            use_semantic_chunking=False,
+        )
+        self._splitter_level_2 = SemanticTextSplitter(
+            chunk_size=level_2_size,
+            chunk_overlap=chunk_overlap,
+            separators=DEFAULT_SEPARATORS,
+            use_semantic_chunking=True,
+        )
+        self._splitter_level_3 = SemanticTextSplitter(
+            chunk_size=level_3_size,
+            chunk_overlap=chunk_overlap,
+            separators=DEFAULT_SEPARATORS,
+            use_semantic_chunking=True,
+        )
+
+    @staticmethod
+    def _build_chunk_id(document_id: int, level: int, index: int) -> str:
+        return f"{document_id}::l{level}::{index}"
+
+    def split_text(
+        self,
+        text: str,
+        document_id: int,
+        filename: str = "",
+        file_type: str = "",
+        file_path: str = "",
+        page_number: int = 0,
+    ) -> List[Dict]:
+        """对单段文本执行三级分块,返回 L1/L2/L3 全部分块。"""
+        text = sanitize_text(text)
+        if not text:
+            return []
+
+        base_doc = {
+            "filename": sanitize_text(filename),
+            "file_path": sanitize_text(file_path),
+            "file_type": sanitize_text(file_type),
+            "page_number": int(page_number),
+        }
+
+        root_chunks: List[Dict] = []
+        level_1_counter = 0
+        level_2_counter = 0
+        level_3_counter = 0
+        chunk_idx = 0
+
+        level_1_texts = self._splitter_level_1.split_text(text)
+        for level_1_text in level_1_texts:
+            if not level_1_text:
+                continue
+            level_1_id = self._build_chunk_id(document_id, 1, level_1_counter)
+            level_1_counter += 1
+
+            level_1_chunk = {
+                **base_doc,
+                "text": level_1_text,
+                "chunk_id": level_1_id,
+                "parent_chunk_id": "",
+                "root_chunk_id": level_1_id,
+                "chunk_level": 1,
+                "chunk_idx": chunk_idx,
+            }
+            chunk_idx += 1
+            root_chunks.append(level_1_chunk)
+
+            level_2_texts = self._splitter_level_2.split_text(level_1_text)
+            for level_2_text in level_2_texts:
+                if not level_2_text:
+                    continue
+                level_2_id = self._build_chunk_id(document_id, 2, level_2_counter)
+                level_2_counter += 1
+
+                level_2_chunk = {
+                    **base_doc,
+                    "text": level_2_text,
+                    "chunk_id": level_2_id,
+                    "parent_chunk_id": level_1_id,
+                    "root_chunk_id": level_1_id,
+                    "chunk_level": 2,
+                    "chunk_idx": chunk_idx,
+                }
+                chunk_idx += 1
+                root_chunks.append(level_2_chunk)
+
+                level_3_texts = self._splitter_level_3.split_text(level_2_text)
+                for level_3_text in level_3_texts:
+                    if not level_3_text:
+                        continue
+                    level_3_id = self._build_chunk_id(document_id, 3, level_3_counter)
+                    level_3_counter += 1
+                    root_chunks.append({
+                        **base_doc,
+                        "text": level_3_text,
+                        "chunk_id": level_3_id,
+                        "parent_chunk_id": level_2_id,
+                        "root_chunk_id": level_1_id,
+                        "chunk_level": 3,
+                        "chunk_idx": chunk_idx,
+                    })
+                    chunk_idx += 1
+
+        return root_chunks

+ 10 - 0
backend/embedding-bridge/requirements.txt

@@ -0,0 +1,10 @@
+fastapi>=0.115.0
+uvicorn[standard]>=0.30.0
+pymilvus==2.5.5
+transformers==4.41.2
+torch>=2.0.0
+numpy<2
+jieba>=0.42.0
+python-multipart>=0.0.9
+pydantic>=2.8.0
+

+ 305 - 0
backend/embedding-bridge/server.py

@@ -0,0 +1,305 @@
+#!/usr/bin/env python3
+"""
+Embedding Bridge - 本地 Embedding 与 Milvus 写入服务
+
+供 Java Spring Boot 调用,完成:
+- 三级分块
+- 本地 HuggingFace 稠密向量
+- Milvus 2.5+ 原生 BM25 稀疏向量
+- Leaf-only 向量化存储
+
+启动方式:
+    python server.py --port 18732
+"""
+
+import argparse
+import faulthandler
+import logging
+import os
+import sys
+import threading
+import time
+from pathlib import Path
+from typing import List, Optional
+
+# 限制 PyTorch / OpenMP 线程数,避免在 Windows + uvicorn 多线程环境下出现 segfault
+os.environ.setdefault("OMP_NUM_THREADS", "1")
+os.environ.setdefault("MKL_NUM_THREADS", "1")
+os.environ.setdefault("OPENBLAS_NUM_THREADS", "1")
+os.environ.setdefault("NUMEXPR_NUM_THREADS", "1")
+os.environ.setdefault("KMP_DUPLICATE_LIB_OK", "TRUE")
+
+faulthandler.enable()
+
+
+# ---------------------------------------------------------------------------
+# 父进程守护:Java 后端被 kill 后,子进程自动退出,释放端口与数据库连接
+# ---------------------------------------------------------------------------
+def _start_parent_watcher():
+    """启动守护线程,当父进程退出时自杀。"""
+    try:
+        import psutil
+    except ImportError:
+        logging.warning("未安装 psutil,无法监听父进程状态;Java 退出后子进程可能残留")
+        return
+
+    try:
+        parent = psutil.Process(os.getppid())
+    except Exception:
+        return
+
+    def _watch():
+        while True:
+            time.sleep(2)
+            try:
+                if not parent.is_running() or parent.status() == psutil.STATUS_ZOMBIE:
+                    logging.info("父进程已退出,Embedding Bridge 自动终止")
+                    os._exit(0)
+            except Exception:
+                # 获取不到父进程信息时也退出,避免成为孤儿进程
+                logging.info("父进程状态不可获取,Embedding Bridge 自动终止")
+                os._exit(0)
+
+    watcher = threading.Thread(target=_watch, daemon=True, name="parent-watcher")
+    watcher.start()
+
+
+_start_parent_watcher()
+
+# ---------------------------------------------------------------------------
+# 强制 stdout/stderr 使用 UTF-8
+# ---------------------------------------------------------------------------
+for _stream in (sys.stdout, sys.stderr):
+    try:
+        _stream.reconfigure(encoding="utf-8", errors="replace")
+    except Exception:
+        pass
+
+# ---------------------------------------------------------------------------
+# 项目路径注入
+# ---------------------------------------------------------------------------
+BRIDGE_DIR = Path(__file__).resolve().parent
+if str(BRIDGE_DIR) not in sys.path:
+    sys.path.insert(0, str(BRIDGE_DIR))
+
+# ---------------------------------------------------------------------------
+# 日志
+# ---------------------------------------------------------------------------
+logging.basicConfig(
+    level=logging.INFO,
+    format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
+    stream=sys.stderr,
+)
+logger = logging.getLogger("embedding-bridge")
+
+# ---------------------------------------------------------------------------
+# FastAPI app
+# ---------------------------------------------------------------------------
+try:
+    from fastapi import FastAPI, HTTPException
+    from pydantic import BaseModel, Field
+except ImportError:
+    logger.error("请先安装依赖: pip install -r requirements.txt")
+    sys.exit(1)
+
+app = FastAPI(title="Embedding Bridge", version="1.0.0")
+
+# ---------------------------------------------------------------------------
+# 共享密钥(X-Bridge-Token 头校验)
+# ---------------------------------------------------------------------------
+_BRIDGE_AUTH_TOKEN = os.environ.get("EMBEDDING_BRIDGE_AUTH_TOKEN", "").strip()
+
+
+@app.middleware("http")
+async def _verify_token(request, call_next):
+    """校验 X-Bridge-Token 头(/health 不校验)"""
+    if _BRIDGE_AUTH_TOKEN and request.url.path != "/health":
+        token = request.headers.get("X-Bridge-Token", "")
+        if token != _BRIDGE_AUTH_TOKEN:
+            from fastapi.responses import JSONResponse
+            return JSONResponse(status_code=401, content={"detail": "invalid or missing X-Bridge-Token"})
+    return await call_next(request)
+
+
+# ---------------------------------------------------------------------------
+# 模型
+# ---------------------------------------------------------------------------
+class IndexRequest(BaseModel):
+    document_id: int
+    text: str
+    filename: str = ""
+    file_type: str = ""
+    file_path: str = ""
+    page_number: int = 0
+    chunk_size: int = 800
+    chunk_overlap: int = 100
+    category_id: Optional[int] = None
+
+
+class VectorizeRequest(BaseModel):
+    document_id: int
+    filename: str = ""
+    file_type: str = ""
+    file_path: str = ""
+    chunks: List[dict]
+
+
+class DeleteByDocumentRequest(BaseModel):
+    document_id: int
+
+
+class DeleteByIdsRequest(BaseModel):
+    vector_ids: List[str]
+
+
+# ---------------------------------------------------------------------------
+# 预导入含 C 扩展的依赖(必须在主线程完成,避免在 uvicorn 工作线程中首次加载触发 segfault)
+# ---------------------------------------------------------------------------
+import pyarrow  # noqa: F401
+import pandas  # noqa: F401
+from backend.indexing.milvus_writer import MilvusWriter
+from backend.indexing.text_splitter import HierarchicalTextSplitter
+
+
+# ---------------------------------------------------------------------------
+# 延迟初始化(避免启动时立即加载大模型)
+# ---------------------------------------------------------------------------
+_splitter: Optional[HierarchicalTextSplitter] = None
+_writer: Optional[MilvusWriter] = None
+_lock = threading.Lock()
+
+
+def _get_splitter(chunk_size: int, chunk_overlap: int):
+    return HierarchicalTextSplitter(chunk_size=chunk_size, chunk_overlap=chunk_overlap)
+
+
+def _get_writer():
+    global _writer
+    if _writer is None:
+        _writer = MilvusWriter()
+    return _writer
+
+
+# ---------------------------------------------------------------------------
+# 健康检查
+# ---------------------------------------------------------------------------
+@app.get("/health")
+def health():
+    return {"status": "ok"}
+
+
+# ---------------------------------------------------------------------------
+# 文档分块 + 向量化
+# ---------------------------------------------------------------------------
+@app.post("/index")
+def index_document(req: IndexRequest):
+    start = time.time()
+    try:
+        splitter = _get_splitter(req.chunk_size, req.chunk_overlap)
+        chunks = splitter.split_text(
+            text=req.text,
+            document_id=req.document_id,
+            filename=req.filename,
+            file_type=req.file_type,
+            file_path=req.file_path,
+            page_number=req.page_number,
+        )
+        if not chunks:
+            return {"chunks": [], "collection": os.getenv("MILVUS_COLLECTION", "kb_documents"), "vector_count": 0}
+
+        # 为所有 chunk 注入 document_id
+        for c in chunks:
+            c["document_id"] = req.document_id
+
+        writer = _get_writer()
+        vector_ids = writer.write_chunks(chunks)
+
+        leaf_index = 0
+        for c in chunks:
+            if c.get("chunk_level") == 3:
+                if leaf_index < len(vector_ids):
+                    c["vector_id"] = vector_ids[leaf_index]
+                leaf_index += 1
+            # L1/L2 不设置 vector_id,避免 JSON 中出现 null
+
+        cost = round((time.time() - start) * 1000)
+        logger.info(
+            "[index] document_id=%s chunks=%s leaf=%s cost=%sms",
+            req.document_id,
+            len(chunks),
+            leaf_index,
+            cost,
+        )
+        return {
+            "chunks": chunks,
+            "collection": os.getenv("MILVUS_COLLECTION", "kb_documents"),
+            "vector_count": leaf_index,
+        }
+    except Exception as e:
+        logger.error("[index] document_id=%s 失败: %s", req.document_id, e, exc_info=True)
+        raise HTTPException(status_code=500, detail=f"向量化失败: {e}")
+
+
+# ---------------------------------------------------------------------------
+# 批量 chunk 向量化(用于单个 chunk 重试)
+# ---------------------------------------------------------------------------
+@app.post("/vectorize")
+def vectorize_chunks(req: VectorizeRequest):
+    try:
+        for c in req.chunks:
+            c["document_id"] = req.document_id
+            c.setdefault("filename", req.filename)
+            c.setdefault("file_type", req.file_type)
+            c.setdefault("file_path", req.file_path)
+            c.setdefault("page_number", 0)
+
+        writer = _get_writer()
+        vector_ids = writer.write_chunks(req.chunks)
+        return {"vector_ids": vector_ids}
+    except Exception as e:
+        logger.error("[vectorize] document_id=%s 失败: %s", req.document_id, e, exc_info=True)
+        raise HTTPException(status_code=500, detail=f"向量化失败: {e}")
+
+
+# ---------------------------------------------------------------------------
+# 按文档删除向量
+# ---------------------------------------------------------------------------
+@app.post("/delete_by_document")
+def delete_by_document(req: DeleteByDocumentRequest):
+    try:
+        from backend.indexing.milvus_client import get_milvus_store
+        store = get_milvus_store()
+        count = store.delete_by_document(req.document_id)
+        return {"deleted": count}
+    except Exception as e:
+        logger.error("[delete_by_document] document_id=%s 失败: %s", req.document_id, e, exc_info=True)
+        raise HTTPException(status_code=500, detail=f"删除失败: {e}")
+
+
+# ---------------------------------------------------------------------------
+# 按 vector_id 删除向量
+# ---------------------------------------------------------------------------
+@app.post("/delete_by_vector_ids")
+def delete_by_vector_ids(req: DeleteByIdsRequest):
+    try:
+        from backend.indexing.milvus_client import get_milvus_store
+        store = get_milvus_store()
+        count = store.delete_by_ids(req.vector_ids)
+        return {"deleted": count}
+    except Exception as e:
+        logger.error("[delete_by_vector_ids] 失败: %s", e, exc_info=True)
+        raise HTTPException(status_code=500, detail=f"删除失败: {e}")
+
+
+# ---------------------------------------------------------------------------
+# 主入口
+# ---------------------------------------------------------------------------
+if __name__ == "__main__":
+    parser = argparse.ArgumentParser()
+    parser.add_argument("--port", type=int, default=int(os.getenv("EMBEDDING_BRIDGE_PORT", "18732")))
+    parser.add_argument("--host", type=str, default=os.getenv("EMBEDDING_BRIDGE_HOST", "127.0.0.1"))
+    args = parser.parse_args()
+
+    import uvicorn
+    logger.info("Embedding Bridge 启动: %s:%s", args.host, args.port)
+    uvicorn.run(app, host=args.host, port=args.port, log_level="info")

+ 33 - 0
backend/hermes-bridge/hermes_bridge.py

@@ -33,6 +33,39 @@ for _stream in (sys.stdout, sys.stderr):
     except Exception:
         pass
 
+# ---------------------------------------------------------------------------
+# 父进程守护:Java 后端被 kill 后,子进程自动退出,释放端口与资源
+# ---------------------------------------------------------------------------
+def _start_parent_watcher():
+    """启动守护线程,当父进程退出时自杀。"""
+    try:
+        import psutil
+    except ImportError:
+        logging.warning("未安装 psutil,无法监听父进程状态;Java 退出后子进程可能残留")
+        return
+
+    try:
+        parent = psutil.Process(os.getppid())
+    except Exception:
+        return
+
+    def _watch():
+        while True:
+            time.sleep(2)
+            try:
+                if not parent.is_running() or parent.status() == psutil.STATUS_ZOMBIE:
+                    logging.info("父进程已退出,Hermes Bridge 自动终止")
+                    os._exit(0)
+            except Exception:
+                logging.info("父进程状态不可获取,Hermes Bridge 自动终止")
+                os._exit(0)
+
+    watcher = threading.Thread(target=_watch, daemon=True, name="parent-watcher")
+    watcher.start()
+
+
+_start_parent_watcher()
+
 # ---------------------------------------------------------------------------
 # Hermes 项目路径注入(让 Python 能找到 hermes-agent 的源码)
 # ---------------------------------------------------------------------------

+ 84 - 0
backend/src/main/java/com/agent/management/config/EmbeddingBridgeProperties.java

@@ -0,0 +1,84 @@
+package com.agent.management.config;
+
+import lombok.Data;
+import org.springframework.boot.context.properties.ConfigurationProperties;
+import org.springframework.stereotype.Component;
+
+/**
+ * 本地 Embedding Bridge 配置项(app.embedding-bridge.*)
+ */
+@Data
+@Component
+@ConfigurationProperties(prefix = "app.embedding-bridge")
+public class EmbeddingBridgeProperties {
+
+    /**
+     * 是否启用本地 Embedding Bridge
+     */
+    private boolean enabled = true;
+
+    /**
+     * Bridge 监听主机
+     */
+    private String host = "127.0.0.1";
+
+    /**
+     * Bridge 监听端口
+     */
+    private int port = 18732;
+
+    /**
+     * Python 可执行文件路径
+     */
+    private String pythonPath = "python";
+
+    /**
+     * Bridge 入口脚本路径(相对项目根目录)
+     */
+    private String scriptPath = "backend/embedding-bridge/server.py";
+
+    /**
+     * 启动超时(秒)
+     */
+    private int startupTimeout = 120;
+
+    /**
+     * 健康检查间隔(秒)
+     */
+    private int healthCheckInterval = 5;
+
+    /**
+     * HTTP 调用超时(毫秒)
+     */
+    private int httpTimeout = 600000;
+
+    /**
+     * 访问令牌(为空时不校验)
+     */
+    private String authToken = "";
+
+    /**
+     * 稠密向量模型名(默认 BAAI/bge-m3)
+     */
+    private String embeddingModel = "BAAI/bge-m3";
+
+    /**
+     * 模型运行设备(cpu / cuda)
+     */
+    private String embeddingDevice = "cpu";
+
+    /**
+     * 稠密向量维度
+     */
+    private int denseEmbeddingDim = 1024;
+
+    /**
+     * 叶子分块默认大小
+     */
+    private int chunkSize = 800;
+
+    /**
+     * 叶子分块默认重叠
+     */
+    private int chunkOverlap = 100;
+}

+ 19 - 7
backend/src/main/java/com/agent/management/config/MilvusConfig.java

@@ -9,6 +9,7 @@ import org.springframework.ai.vectorstore.milvus.MilvusVectorStore;
 import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
 import org.springframework.context.annotation.Bean;
 import org.springframework.context.annotation.Configuration;
+import org.springframework.util.StringUtils;
 
 /**
  * Milvus 向量库配置
@@ -32,13 +33,24 @@ public class MilvusConfig {
      */
     @Bean(destroyMethod = "close")
     public MilvusServiceClient milvusServiceClient(MilvusProperties props) {
-        ConnectParam connectParam = ConnectParam.newBuilder()
-                .withHost(props.getHost())
-                .withPort(props.getPort())
-                .build();
-        log.info("Milvus 客户端初始化: {}:{} (collection={}, dim={})",
-                props.getHost(), props.getPort(), props.getCollection(), props.getVectorDimension());
-        return new MilvusServiceClient(connectParam);
+        ConnectParam.Builder builder = ConnectParam.newBuilder();
+        String uri = props.getUri();
+        if (StringUtils.hasText(uri)) {
+            // 如果用户写 host:port 没写协议,默认补 grpc://(Java SDK 用 gRPC)
+            if (!uri.contains("://")) {
+                uri = "grpc://" + uri;
+            }
+            builder.withUri(uri);
+            log.info("Milvus 客户端初始化: uri={} (collection={}, dim={})",
+                    uri, props.getCollection(), props.getVectorDimension());
+        } else {
+            // Milvus Lite 默认 gRPC 端口 19530,显式指定 grpc 协议避免误走 HTTP
+            String grpcUri = "grpc://" + props.getHost() + ":" + props.getPort();
+            builder.withUri(grpcUri);
+            log.info("Milvus 客户端初始化: uri={} (collection={}, dim={})",
+                    grpcUri, props.getCollection(), props.getVectorDimension());
+        }
+        return new MilvusServiceClient(builder.build());
     }
 
     /**

+ 9 - 2
backend/src/main/java/com/agent/management/config/MilvusProperties.java

@@ -18,12 +18,19 @@ public class MilvusProperties {
     private boolean enabled = false;
 
     /**
-     * Milvus 主机
+     * Milvus 连接 URI(优先级高于 host/port)。
+     * 本地 Milvus Lite 服务器默认:http://127.0.0.1:19530
+     * 远程 Milvus 示例:http://82.156.83.248:19530
+     */
+    private String uri;
+
+    /**
+     * Milvus 主机(uri 为空时使用)
      */
     private String host = "localhost";
 
     /**
-     * Milvus 端口(gRPC)
+     * Milvus 端口(gRPC,uri 为空时使用
      */
     private int port = 19530;
 

+ 13 - 3
backend/src/main/java/com/agent/management/controller/KbDocumentController.java

@@ -6,8 +6,8 @@ import com.agent.management.model.entity.KbDocument;
 import com.agent.management.service.DocumentService;
 import com.agent.management.service.DocumentService.ChunkView;
 import com.agent.management.service.DocumentService.PageResult;
-import com.agent.management.service.EmbeddingService;
 import com.agent.management.service.VectorStoreService;
+import com.agent.management.service.impl.EmbeddingBridgeClient;
 import lombok.RequiredArgsConstructor;
 import org.springframework.web.bind.annotation.*;
 import org.springframework.web.multipart.MultipartFile;
@@ -25,7 +25,7 @@ public class KbDocumentController {
 
     private final DocumentService documentService;
     private final KbProperties kbProps;
-    private final EmbeddingService embeddingService;
+    private final EmbeddingBridgeClient bridgeClient;
     private final VectorStoreService vectorStoreService;
 
     /**
@@ -65,6 +65,16 @@ public class KbDocumentController {
         return Result.success(documentService.revectorize(id));
     }
 
+    @PostMapping("/{id}/vectorize")
+    public Result<KbDocument> vectorizeDocument(@PathVariable Long id) {
+        return Result.success(documentService.vectorizeDocument(id));
+    }
+
+    @PostMapping("/chunks/{chunkId}/vectorize")
+    public Result<KbDocument> vectorizeChunk(@PathVariable Long chunkId) {
+        return Result.success(documentService.vectorizeChunk(chunkId));
+    }
+
     @PostMapping("/{id}/move")
     public Result<KbDocument> move(@PathVariable Long id, @RequestBody MoveReq req) {
         return Result.success(documentService.move(id, req.getCategoryId()));
@@ -83,7 +93,7 @@ public class KbDocumentController {
     public Result<Map<String, Boolean>> dependencies() {
         return Result.success(Map.of(
                 "kbEnabled", kbProps.isEnabled(),
-                "embeddingAvailable", embeddingService.isAvailable(),
+                "embeddingAvailable", bridgeClient.health(),
                 "vectorStoreAvailable", vectorStoreService.isAvailable()
         ));
     }

+ 24 - 0
backend/src/main/java/com/agent/management/model/entity/KbChunk.java

@@ -44,4 +44,28 @@ public class KbChunk {
      */
     @Column(name = "char_count")
     private Integer charCount;
+
+    /**
+     * 分块层级(1=粗粒度父块,2=中粒度父块,3=叶子检索块)
+     */
+    @Column(name = "chunk_level")
+    private Integer chunkLevel;
+
+    /**
+     * 分块唯一标识(Milvus 中用于关联父子块)
+     */
+    @Column(name = "chunk_id", length = 512)
+    private String chunkId;
+
+    /**
+     * 父分块标识(L1 为空)
+     */
+    @Column(name = "parent_chunk_id", length = 512)
+    private String parentChunkId;
+
+    /**
+     * 根分块标识(最顶层 L1)
+     */
+    @Column(name = "root_chunk_id", length = 512)
+    private String rootChunkId;
 }

+ 6 - 0
backend/src/main/java/com/agent/management/repository/KbChunkRepository.java

@@ -14,4 +14,10 @@ public interface KbChunkRepository extends JpaRepository<KbChunk, Long> {
     void deleteByDocumentId(Long documentId);
 
     long countByDocumentId(Long documentId);
+
+    long countByDocumentIdAndVectorIdIsNotNull(Long documentId);
+
+    List<KbChunk> findByDocumentIdAndChunkLevelOrderByChunkIndexAsc(Long documentId, Integer chunkLevel);
+
+    List<KbChunk> findByChunkIdIn(List<String> chunkIds);
 }

+ 11 - 1
backend/src/main/java/com/agent/management/service/DocumentService.java

@@ -31,6 +31,16 @@ public interface DocumentService {
      */
     KbDocument revectorize(Long id);
 
+    /**
+     * 对文档中所有未向量化的分块执行向量化(不会清理已向量化的记录)
+     */
+    KbDocument vectorizeDocument(Long id);
+
+    /**
+     * 对单个未向量化的分块执行向量化
+     */
+    KbDocument vectorizeChunk(Long chunkId);
+
     /**
      * 删除文档(含文件、chunks、向量记录)
      */
@@ -49,7 +59,7 @@ public interface DocumentService {
     /**
      * 分块视图(DTO)
      */
-    record ChunkView(Integer chunkIndex, Integer charCount, String contentPreview, String vectorId) {}
+    record ChunkView(Long id, Integer chunkIndex, Integer chunkLevel, Integer charCount, String contentPreview, String content, String vectorId) {}
 
     /**
      * 简单分页结果(DTO)

+ 6 - 0
backend/src/main/java/com/agent/management/service/Neo4jExecutorService.java

@@ -16,6 +16,7 @@ import org.neo4j.driver.exceptions.ServiceUnavailableException;
 import org.neo4j.driver.exceptions.Neo4jException;
 import org.neo4j.driver.SessionConfig;
 import org.springframework.stereotype.Service;
+import jakarta.annotation.PreDestroy;
 import org.neo4j.driver.types.Node;
 import org.neo4j.driver.types.Path;
 import org.neo4j.driver.types.Relationship;
@@ -173,6 +174,11 @@ public class Neo4jExecutorService {
         }
     }
 
+    @PreDestroy
+    public void destroy() {
+        closeAll();
+    }
+
     public void closeAll() {
         synchronized (driverCache) {
             for (Map.Entry<Long, Driver> e : driverCache.entrySet()) {

+ 52 - 63
backend/src/main/java/com/agent/management/service/impl/DocumentPipelineImpl.java

@@ -7,26 +7,22 @@ import com.agent.management.model.entity.KbDocument;
 import com.agent.management.repository.KbChunkRepository;
 import com.agent.management.repository.KbDocumentRepository;
 import com.agent.management.service.DocumentPipeline;
-import com.agent.management.service.VectorStoreService;
 import lombok.RequiredArgsConstructor;
 import lombok.extern.slf4j.Slf4j;
 import org.apache.tika.Tika;
-import org.springframework.ai.document.Document;
 import org.springframework.scheduling.annotation.Async;
 import org.springframework.stereotype.Component;
+import org.springframework.transaction.annotation.Transactional;
 
 import java.io.File;
-import java.io.IOException;
 import java.nio.file.Path;
 import java.nio.file.Paths;
 import java.util.ArrayList;
-import java.util.HashMap;
 import java.util.List;
 import java.util.Map;
-import java.util.UUID;
 
 /**
- * 文档异步流水线实现
+ * 文档异步流水线实现:调用本地 Embedding Bridge 完成三级分块与向量化。
  */
 @Slf4j
 @Component
@@ -35,13 +31,14 @@ public class DocumentPipelineImpl implements DocumentPipeline {
 
     private final KbDocumentRepository documentRepository;
     private final KbChunkRepository chunkRepository;
-    private final VectorStoreService vectorStoreService;
+    private final EmbeddingBridgeClient bridgeClient;
     private final KbProperties kbProps;
 
     private final Tika tika = new Tika();
 
     @Override
     @Async
+    @Transactional
     public void process(Long documentId) {
         KbDocument doc = documentRepository.findById(documentId).orElse(null);
         if (doc == null) {
@@ -57,32 +54,37 @@ public class DocumentPipelineImpl implements DocumentPipeline {
             String text = parseFile(doc);
             log.info("[解析完成] doc={} 字符数={}", doc.getId(), text.length());
 
-            // 2. CHUNKING
-            doc.setStatus("CHUNKING");
-            documentRepository.save(doc);
-            List<String> chunkTexts = splitText(text, kbProps.getChunkSize(), kbProps.getChunkOverlap());
-            List<KbChunk> savedChunks = saveChunks(doc.getId(), chunkTexts);
-            doc.setChunkCount(savedChunks.size());
-            documentRepository.save(doc);
-            log.info("[分块完成] doc={} chunkCount={}", doc.getId(), savedChunks.size());
-
-            // 3. EMBEDDING + VECTORIZING(VectorStore 内部完成 embedding)
+            // 2. CHUNKING + EMBEDDING + VECTORIZING(通过 Bridge 完成)
             doc.setStatus("EMBEDDING");
             documentRepository.save(doc);
-            List<Document> documents = toSpringDocuments(doc, savedChunks);
-            List<String> vectorIds = vectorStoreService.add(documents);
-            // 回写 vectorId
-            for (int i = 0; i < savedChunks.size() && i < vectorIds.size(); i++) {
-                savedChunks.get(i).setVectorId(vectorIds.get(i));
+
+            Map<String, Object> result = bridgeClient.indexDocument(
+                    doc.getId(),
+                    text,
+                    doc.getName(),
+                    doc.getMimeType() == null ? "" : doc.getMimeType(),
+                    doc.getSourcePath() == null ? "" : doc.getSourcePath(),
+                    doc.getCategoryId()
+            );
+
+            @SuppressWarnings("unchecked")
+            List<Map<String, Object>> chunkData = (List<Map<String, Object>>) result.get("chunks");
+            if (chunkData == null) {
+                chunkData = List.of();
             }
-            chunkRepository.saveAll(savedChunks);
 
-            doc.setStatus("VECTORIZING");
-            doc.setVectorCount(vectorIds.size());
+            List<KbChunk> savedChunks = saveChunks(doc, chunkData);
+            int vectorCount = (int) savedChunks.stream().filter(c -> c.getVectorId() != null).count();
+
+            doc.setChunkCount(savedChunks.size());
+            doc.setVectorCount(vectorCount);
             documentRepository.save(doc);
+            log.info("[分块/向量化完成] doc={} chunks={} vectors={}",
+                    doc.getId(), savedChunks.size(), vectorCount);
 
-            // 4. READY
+            // 3. READY
             doc.setStatus("READY");
+            doc.setErrorMessage(null);
             documentRepository.save(doc);
             log.info("[流水线完成] doc={} status=READY", doc.getId());
 
@@ -108,49 +110,36 @@ public class DocumentPipelineImpl implements DocumentPipeline {
         }
     }
 
-    private List<String> splitText(String text, int chunkSize, int overlap) {
-        List<String> result = new ArrayList<>();
-        if (text == null || text.isEmpty()) return result;
-        int len = text.length();
-        int start = 0;
-        while (start < len) {
-            int end = Math.min(start + chunkSize, len);
-            result.add(text.substring(start, end));
-            if (end == len) break;
-            start = Math.max(0, end - overlap);
-        }
-        return result;
-    }
-
-    private List<KbChunk> saveChunks(Long documentId, List<String> chunkTexts) {
-        List<KbChunk> entities = new ArrayList<>(chunkTexts.size());
-        for (int i = 0; i < chunkTexts.size(); i++) {
+    private List<KbChunk> saveChunks(KbDocument doc, List<Map<String, Object>> chunkData) {
+        List<KbChunk> entities = new ArrayList<>(chunkData.size());
+        int idx = 0;
+        for (Map<String, Object> data : chunkData) {
             KbChunk c = new KbChunk();
-            c.setDocumentId(documentId);
-            c.setChunkIndex(i);
-            c.setContent(chunkTexts.get(i));
-            c.setCharCount(chunkTexts.get(i).length());
+            c.setDocumentId(doc.getId());
+            c.setChunkIndex(idx++);
+            c.setContent(asString(data.get("text")));
+            c.setCharCount(c.getContent() == null ? 0 : c.getContent().length());
+            c.setChunkLevel(asInt(data.get("chunk_level")));
+            c.setChunkId(asString(data.get("chunk_id")));
+            c.setParentChunkId(asString(data.get("parent_chunk_id")));
+            c.setRootChunkId(asString(data.get("root_chunk_id")));
+            c.setVectorId(asString(data.get("vector_id")));
             entities.add(c);
         }
         return chunkRepository.saveAll(entities);
     }
 
-    private List<Document> toSpringDocuments(KbDocument doc, List<KbChunk> chunks) {
-        List<Document> docs = new ArrayList<>(chunks.size());
-        for (KbChunk c : chunks) {
-            Map<String, Object> meta = new HashMap<>();
-            meta.put("documentId", doc.getId());
-            meta.put("chunkId", c.getId());
-            meta.put("chunkIndex", c.getChunkIndex());
-            meta.put("categoryId", doc.getCategoryId());
-            meta.put("docName", doc.getName());
-            Document d = Document.builder()
-                    .id(UUID.randomUUID().toString())
-                    .text(c.getContent())
-                    .metadata(meta)
-                    .build();
-            docs.add(d);
+    private String asString(Object value) {
+        return value == null ? null : String.valueOf(value);
+    }
+
+    private int asInt(Object value) {
+        if (value == null) return 0;
+        if (value instanceof Number n) return n.intValue();
+        try {
+            return Integer.parseInt(String.valueOf(value));
+        } catch (NumberFormatException e) {
+            return 0;
         }
-        return docs;
     }
 }

+ 82 - 24
backend/src/main/java/com/agent/management/service/impl/DocumentServiceImpl.java

@@ -8,12 +8,12 @@ import com.agent.management.repository.KbChunkRepository;
 import com.agent.management.repository.KbDocumentRepository;
 import com.agent.management.service.DocumentPipeline;
 import com.agent.management.service.DocumentService;
-import com.agent.management.service.VectorStoreService;
 import lombok.RequiredArgsConstructor;
 import lombok.extern.slf4j.Slf4j;
 import org.springframework.data.domain.Page;
 import org.springframework.data.domain.PageRequest;
 import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
 import org.springframework.web.multipart.MultipartFile;
 
 import java.io.IOException;
@@ -22,17 +22,20 @@ import java.nio.file.Path;
 import java.nio.file.Paths;
 import java.nio.file.StandardCopyOption;
 import java.util.ArrayList;
+import java.util.HashMap;
 import java.util.List;
+import java.util.Map;
 import java.util.UUID;
 
 @Slf4j
 @Service
 @RequiredArgsConstructor
+@Transactional
 public class DocumentServiceImpl implements DocumentService {
 
     private final KbDocumentRepository documentRepository;
     private final KbChunkRepository chunkRepository;
-    private final VectorStoreService vectorStoreService;
+    private final EmbeddingBridgeClient bridgeClient;
     private final DocumentPipeline pipeline;
     private final KbProperties kbProps;
 
@@ -109,19 +112,13 @@ public class DocumentServiceImpl implements DocumentService {
     @Override
     public KbDocument revectorize(Long id) {
         KbDocument doc = get(id);
-        // 清理旧的 chunks 与向量
-        List<KbChunk> oldChunks = chunkRepository.findByDocumentIdOrderByChunkIndexAsc(id);
-        if (!oldChunks.isEmpty()) {
-            List<String> oldIds = oldChunks.stream().map(KbChunk::getVectorId).filter(java.util.Objects::nonNull).toList();
-            if (!oldIds.isEmpty()) {
-                try {
-                    vectorStoreService.deleteByIds(oldIds);
-                } catch (Exception e) {
-                    log.warn("清理旧向量失败(忽略,继续 revectorize): {}", e.getMessage());
-                }
-            }
-            chunkRepository.deleteByDocumentId(id);
+        // 清理旧向量
+        try {
+            bridgeClient.deleteByDocument(id);
+        } catch (Exception e) {
+            log.warn("清理旧向量失败(忽略,继续 revectorize): {}", e.getMessage());
         }
+        chunkRepository.deleteByDocumentId(id);
         doc.setStatus("PENDING");
         doc.setErrorMessage(null);
         doc.setChunkCount(0);
@@ -131,18 +128,79 @@ public class DocumentServiceImpl implements DocumentService {
         return doc;
     }
 
+    @Override
+    public KbDocument vectorizeDocument(Long id) {
+        KbDocument doc = get(id);
+        List<KbChunk> chunks = chunkRepository.findByDocumentIdOrderByChunkIndexAsc(id)
+                .stream()
+                .filter(c -> c.getChunkLevel() != null && c.getChunkLevel() == 3)
+                .filter(c -> c.getVectorId() == null || c.getVectorId().isBlank())
+                .toList();
+        if (chunks.isEmpty()) {
+            return doc;
+        }
+        List<String> ids = vectorizeChunksInternal(doc, chunks);
+        refreshVectorCount(doc);
+        log.info("文档向量化完成: doc={}, 新增向量={}", id, ids.size());
+        return doc;
+    }
+
+    @Override
+    public KbDocument vectorizeChunk(Long chunkId) {
+        KbChunk chunk = chunkRepository.findById(chunkId)
+                .orElseThrow(() -> new BusinessException(404, "分块不存在"));
+        if (chunk.getVectorId() != null && !chunk.getVectorId().isBlank()) {
+            throw new BusinessException("该分块已向量化,请勿重复操作");
+        }
+        if (chunk.getChunkLevel() == null || chunk.getChunkLevel() != 3) {
+            throw new BusinessException("仅叶子分块(L3)支持向量化");
+        }
+        KbDocument doc = get(chunk.getDocumentId());
+        List<String> ids = vectorizeChunksInternal(doc, List.of(chunk));
+        refreshVectorCount(doc);
+        log.info("分块向量化完成: chunkId={}, vectorId={}", chunkId, ids.isEmpty() ? null : ids.get(0));
+        return doc;
+    }
+
+    private List<String> vectorizeChunksInternal(KbDocument doc, List<KbChunk> chunks) {
+        if (chunks.isEmpty()) return List.of();
+        List<Map<String, Object>> chunkMaps = new ArrayList<>(chunks.size());
+        for (KbChunk c : chunks) {
+            Map<String, Object> map = new HashMap<>();
+            map.put("chunk_id", c.getChunkId());
+            map.put("text", c.getContent());
+            map.put("chunk_level", c.getChunkLevel());
+            map.put("parent_chunk_id", c.getParentChunkId());
+            map.put("root_chunk_id", c.getRootChunkId());
+            map.put("chunk_idx", c.getChunkIndex());
+            chunkMaps.add(map);
+        }
+        List<String> vectorIds = bridgeClient.vectorizeChunks(
+                doc.getId(), doc.getName(),
+                doc.getMimeType() == null ? "" : doc.getMimeType(),
+                doc.getSourcePath() == null ? "" : doc.getSourcePath(),
+                chunkMaps);
+        for (int i = 0; i < chunks.size() && i < vectorIds.size(); i++) {
+            chunks.get(i).setVectorId(vectorIds.get(i));
+        }
+        chunkRepository.saveAll(chunks);
+        return vectorIds;
+    }
+
+    private void refreshVectorCount(KbDocument doc) {
+        long count = chunkRepository.countByDocumentIdAndVectorIdIsNotNull(doc.getId());
+        doc.setVectorCount((int) count);
+        documentRepository.save(doc);
+    }
+
     @Override
     public void delete(Long id) {
         KbDocument doc = get(id);
-        // 删除 chunks
-        List<KbChunk> chunks = chunkRepository.findByDocumentIdOrderByChunkIndexAsc(id);
-        List<String> vectorIds = chunks.stream().map(KbChunk::getVectorId).filter(java.util.Objects::nonNull).toList();
-        if (!vectorIds.isEmpty()) {
-            try {
-                vectorStoreService.deleteByIds(vectorIds);
-            } catch (Exception e) {
-                log.warn("删除向量失败(继续删除 chunks 与文件): {}", e.getMessage());
-            }
+        // 删除 Milvus 向量(按 document_id)
+        try {
+            bridgeClient.deleteByDocument(id);
+        } catch (Exception e) {
+            log.warn("删除向量失败(继续删除 chunks 与文件): {}", e.getMessage());
         }
         chunkRepository.deleteByDocumentId(id);
         // 删除源文件
@@ -172,7 +230,7 @@ public class DocumentServiceImpl implements DocumentService {
         for (KbChunk c : chunks) {
             String preview = c.getContent() == null ? "" :
                     c.getContent().length() > 200 ? c.getContent().substring(0, 200) + "..." : c.getContent();
-            views.add(new ChunkView(c.getChunkIndex(), c.getCharCount(), preview, c.getVectorId()));
+            views.add(new ChunkView(c.getId(), c.getChunkIndex(), c.getChunkLevel(), c.getCharCount(), preview, c.getContent(), c.getVectorId()));
         }
         return views;
     }

+ 143 - 0
backend/src/main/java/com/agent/management/service/impl/EmbeddingBridgeClient.java

@@ -0,0 +1,143 @@
+package com.agent.management.service.impl;
+
+import com.agent.management.common.exception.BusinessException;
+import com.agent.management.config.EmbeddingBridgeProperties;
+import com.fasterxml.jackson.core.type.TypeReference;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.http.*;
+import org.springframework.http.client.SimpleClientHttpRequestFactory;
+import org.springframework.stereotype.Component;
+import org.springframework.web.client.RestClientResponseException;
+import org.springframework.web.client.RestTemplate;
+
+import java.util.List;
+import java.util.Map;
+
+/**
+ * 本地 Embedding Bridge HTTP 客户端
+ */
+@Slf4j
+@Component
+public class EmbeddingBridgeClient {
+
+    private final EmbeddingBridgeProperties props;
+    private final RestTemplate restTemplate;
+    private final ObjectMapper objectMapper;
+
+    public EmbeddingBridgeClient(EmbeddingBridgeProperties props) {
+        this.props = props;
+        SimpleClientHttpRequestFactory factory = new SimpleClientHttpRequestFactory();
+        int timeout = Math.max(props.getHttpTimeout(), 60000);
+        factory.setConnectTimeout(5000);
+        factory.setReadTimeout(timeout);
+        this.restTemplate = new RestTemplate(factory);
+        this.objectMapper = new ObjectMapper();
+    }
+
+    private String baseUrl() {
+        return "http://" + props.getHost() + ":" + props.getPort();
+    }
+
+    private HttpHeaders headers() {
+        HttpHeaders h = new HttpHeaders();
+        h.setContentType(MediaType.APPLICATION_JSON);
+        if (props.getAuthToken() != null && !props.getAuthToken().isBlank()) {
+            h.set("X-Bridge-Token", props.getAuthToken());
+        }
+        return h;
+    }
+
+    /**
+     * 健康检查
+     */
+    public boolean health() {
+        try {
+            ResponseEntity<String> resp = restTemplate.exchange(
+                    baseUrl() + "/health", HttpMethod.GET, new HttpEntity<>(headers()), String.class);
+            return resp.getStatusCode().is2xxSuccessful();
+        } catch (Exception e) {
+            return false;
+        }
+    }
+
+    /**
+     * 对文档全文执行三级分块 + 向量化
+     */
+    @SuppressWarnings("unchecked")
+    public Map<String, Object> indexDocument(long documentId, String text, String filename,
+                                             String fileType, String filePath, Long categoryId) {
+        Map<String, Object> body = Map.of(
+                "document_id", documentId,
+                "text", text,
+                "filename", filename,
+                "file_type", fileType,
+                "file_path", filePath == null ? "" : filePath,
+                "page_number", 0,
+                "chunk_size", props.getChunkSize(),
+                "chunk_overlap", props.getChunkOverlap(),
+                "category_id", categoryId == null ? 0L : categoryId
+        );
+        return postJson("/index", body, new TypeReference<>() {});
+    }
+
+    /**
+     * 批量向量化指定 chunk(仅用于补向量)
+     */
+    @SuppressWarnings("unchecked")
+    public List<String> vectorizeChunks(long documentId, String filename, String fileType,
+                                        String filePath, List<Map<String, Object>> chunks) {
+        Map<String, Object> body = Map.of(
+                "document_id", documentId,
+                "filename", filename,
+                "file_type", fileType,
+                "file_path", filePath,
+                "chunks", chunks
+        );
+        Map<String, Object> resp = postJson("/vectorize", body, new TypeReference<>() {});
+        Object ids = resp.get("vector_ids");
+        if (ids instanceof List<?> list) {
+            return list.stream().map(String::valueOf).toList();
+        }
+        return List.of();
+    }
+
+    /**
+     * 按文档 ID 删除 Milvus 向量
+     */
+    public int deleteByDocument(long documentId) {
+        Map<String, Object> body = Map.of("document_id", documentId);
+        Map<String, Object> resp = postJson("/delete_by_document", body, new TypeReference<>() {});
+        Object deleted = resp.get("deleted");
+        return deleted instanceof Number n ? n.intValue() : 0;
+    }
+
+    /**
+     * 按 vector_id 删除 Milvus 向量
+     */
+    public int deleteByVectorIds(List<String> vectorIds) {
+        Map<String, Object> body = Map.of("vector_ids", vectorIds);
+        Map<String, Object> resp = postJson("/delete_by_vector_ids", body, new TypeReference<>() {});
+        Object deleted = resp.get("deleted");
+        return deleted instanceof Number n ? n.intValue() : 0;
+    }
+
+    private <T> T postJson(String path, Object body, TypeReference<T> typeRef) {
+        try {
+            String jsonBody = objectMapper.writeValueAsString(body);
+            HttpEntity<String> entity = new HttpEntity<>(jsonBody, headers());
+            ResponseEntity<String> resp = restTemplate.exchange(
+                    baseUrl() + path, HttpMethod.POST, entity, String.class);
+            if (!resp.getStatusCode().is2xxSuccessful() || resp.getBody() == null) {
+                throw new BusinessException("Embedding Bridge 调用失败: HTTP " + resp.getStatusCode());
+            }
+            return objectMapper.readValue(resp.getBody(), typeRef);
+        } catch (RestClientResponseException e) {
+            log.error("Embedding Bridge 调用失败: {}", e.getResponseBodyAsString(), e);
+            throw new BusinessException("Embedding Bridge 调用失败: " + e.getResponseBodyAsString());
+        } catch (Exception e) {
+            log.error("Embedding Bridge 调用失败: {}", e.getMessage(), e);
+            throw new BusinessException("Embedding Bridge 调用失败: " + e.getMessage());
+        }
+    }
+}

+ 191 - 0
backend/src/main/java/com/agent/management/service/impl/EmbeddingBridgeProcessManager.java

@@ -0,0 +1,191 @@
+package com.agent.management.service.impl;
+
+import com.agent.management.config.EmbeddingBridgeProperties;
+import com.agent.management.config.MilvusProperties;
+import jakarta.annotation.PostConstruct;
+import jakarta.annotation.PreDestroy;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
+import org.springframework.stereotype.Component;
+
+import java.io.BufferedReader;
+import java.io.File;
+import java.io.IOException;
+import java.io.InputStreamReader;
+import java.net.HttpURLConnection;
+import java.net.URI;
+import java.nio.charset.StandardCharsets;
+import java.util.Map;
+import java.util.concurrent.Executors;
+import java.util.concurrent.ScheduledExecutorService;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicBoolean;
+
+/**
+ * 管理本地 Embedding Bridge Python 子进程的生命周期。
+ * 仅在 app.embedding-bridge.enabled=true 时激活。
+ */
+@Slf4j
+@Component
+@ConditionalOnProperty(name = "app.embedding-bridge.enabled", havingValue = "true")
+public class EmbeddingBridgeProcessManager {
+
+    private final EmbeddingBridgeProperties props;
+    private final MilvusProperties milvusProps;
+    private final EmbeddingBridgeClient client;
+
+    private Process process;
+    private final AtomicBoolean starting = new AtomicBoolean(false);
+    private ScheduledExecutorService healthScheduler;
+    private volatile boolean stopped = false;
+
+    public EmbeddingBridgeProcessManager(EmbeddingBridgeProperties props, MilvusProperties milvusProps, EmbeddingBridgeClient client) {
+        this.props = props;
+        this.milvusProps = milvusProps;
+        this.client = client;
+    }
+
+    @PostConstruct
+    public void start() {
+        log.info("[EmbeddingBridge] 启动子进程...");
+        starting.set(true);
+
+        try {
+            String scriptPath = resolveScriptPath();
+            String pythonPath = props.getPythonPath();
+            int port = props.getPort();
+
+            ProcessBuilder pb = new ProcessBuilder(
+                    pythonPath, scriptPath,
+                    "--port", String.valueOf(port),
+                    "--host", props.getHost()
+            );
+            pb.redirectErrorStream(true);
+
+            Map<String, String> env = pb.environment();
+            env.put("PYTHONUNBUFFERED", "1");
+            env.put("PYTHONIOENCODING", "utf-8");
+            env.put("PYTHONUTF8", "1");
+            env.put("EMBEDDING_MODEL", props.getEmbeddingModel());
+            env.put("EMBEDDING_DEVICE", props.getEmbeddingDevice());
+            env.put("DENSE_EMBEDDING_DIM", String.valueOf(props.getDenseEmbeddingDim()));
+            env.put("MILVUS_HOST", milvusProps.getHost());
+            env.put("MILVUS_PORT", String.valueOf(milvusProps.getPort()));
+            env.put("MILVUS_COLLECTION", milvusProps.getCollection());
+
+            String authToken = props.getAuthToken();
+            if (authToken == null || authToken.isBlank()) {
+                authToken = generateRandomToken();
+                log.warn("[EmbeddingBridge] auth-token 未配置,已生成临时 token(重启后变化,建议显式配置)");
+                props.setAuthToken(authToken);
+            }
+            env.put("EMBEDDING_BRIDGE_AUTH_TOKEN", authToken);
+
+            File projectRoot = new File(System.getProperty("user.dir"));
+            pb.directory(projectRoot);
+
+            process = pb.start();
+
+            Thread outputReader = new Thread(() -> {
+                try (BufferedReader reader = new BufferedReader(
+                        new InputStreamReader(process.getInputStream(), StandardCharsets.UTF_8))) {
+                    String line;
+                    while ((line = reader.readLine()) != null) {
+                        log.debug("[EmbeddingBridge] {}", line);
+                    }
+                } catch (IOException e) {
+                    if (!stopped) {
+                        log.warn("[EmbeddingBridge] 输出流关闭: {}", e.getMessage());
+                    }
+                }
+            }, "embedding-bridge-output");
+            outputReader.setDaemon(true);
+            outputReader.start();
+
+            long deadline = System.currentTimeMillis() + props.getStartupTimeout() * 1000L;
+            while (System.currentTimeMillis() < deadline) {
+                Thread.sleep(1000);
+                if (client.health()) {
+                    log.info("[EmbeddingBridge] 子进程启动成功,端口: {}", port);
+                    starting.set(false);
+                    startHealthCheck();
+                    return;
+                }
+            }
+
+            log.error("[EmbeddingBridge] 子进程启动超时({}秒)", props.getStartupTimeout());
+            starting.set(false);
+
+        } catch (Exception e) {
+            log.error("[EmbeddingBridge] 子进程启动失败: {}", e.getMessage(), e);
+            starting.set(false);
+        }
+    }
+
+    @PreDestroy
+    public void stop() {
+        stopped = true;
+        if (healthScheduler != null) {
+            healthScheduler.shutdownNow();
+        }
+        if (process != null && process.isAlive()) {
+            log.info("[EmbeddingBridge] 停止子进程...");
+            process.destroy();
+            try {
+                if (!process.waitFor(5, TimeUnit.SECONDS)) {
+                    process.destroyForcibly();
+                }
+            } catch (InterruptedException e) {
+                Thread.currentThread().interrupt();
+                process.destroyForcibly();
+            }
+            log.info("[EmbeddingBridge] 子进程已停止");
+        }
+    }
+
+    /**
+     * 检查 Bridge 是否就绪
+     */
+    public boolean isReady() {
+        if (starting.get()) return false;
+        if (process == null || !process.isAlive()) return false;
+        return client.health();
+    }
+
+    private void startHealthCheck() {
+        int interval = props.getHealthCheckInterval();
+        if (interval <= 0) return;
+
+        healthScheduler = Executors.newSingleThreadScheduledExecutor(r -> {
+            Thread t = new Thread(r, "embedding-bridge-health-check");
+            t.setDaemon(true);
+            return t;
+        });
+
+        healthScheduler.scheduleAtFixedRate(() -> {
+            if (stopped) return;
+            if (process != null && !process.isAlive()) {
+                log.warn("[EmbeddingBridge] 子进程已退出,尝试重启...");
+                start();
+            }
+        }, interval, interval, TimeUnit.SECONDS);
+    }
+
+    private String resolveScriptPath() {
+        String path = props.getScriptPath();
+        File file = new File(path);
+        if (!file.isAbsolute()) {
+            file = new File(System.getProperty("user.dir"), path);
+        }
+        if (!file.exists()) {
+            throw new IllegalStateException("Embedding Bridge 脚本不存在: " + file.getAbsolutePath());
+        }
+        return file.getAbsolutePath();
+    }
+
+    private String generateRandomToken() {
+        byte[] bytes = new byte[24];
+        new java.security.SecureRandom().nextBytes(bytes);
+        return java.util.HexFormat.of().formatHex(bytes);
+    }
+}

+ 12 - 68
backend/src/main/java/com/agent/management/service/impl/VectorStoreServiceImpl.java

@@ -1,102 +1,46 @@
 package com.agent.management.service.impl;
 
-import com.agent.management.common.exception.BusinessException;
 import com.agent.management.service.VectorStoreService;
 import lombok.extern.slf4j.Slf4j;
 import org.springframework.ai.document.Document;
-import org.springframework.ai.vectorstore.SearchRequest;
-import org.springframework.ai.vectorstore.VectorStore;
 import org.springframework.beans.factory.ObjectProvider;
 import org.springframework.stereotype.Service;
 
-import java.util.ArrayList;
 import java.util.List;
 
 /**
- * VectorStoreService 实现:通过 {@link ObjectProvider} 懒加载 {@link VectorStore} bean
+ * VectorStoreService 实现:当前版本已切换到本地 Embedding Bridge 写入 Milvus
  *
- * <p>当 app.milvus.enabled=false 时,{@link com.agent.management.config.MilvusConfig}
- * 不装配 VectorStore bean,本服务的所有调用都会抛业务异常。</p>
+ * <p>本类保留仅用于:
+ * 1. {@link #isAvailable()} 检查 Milvus 连接是否可达;
+ * 2. 兼容旧的检索入口({@link #similaritySearch}),但本次实现暂返回空列表,
+ *    待后续接入 Bridge 的 Hybrid Search 后再提供完整检索能力。</p>
  */
 @Slf4j
 @Service
 public class VectorStoreServiceImpl implements VectorStoreService {
 
-    private final ObjectProvider<VectorStore> vectorStoreProvider;
+    private final ObjectProvider<org.springframework.ai.vectorstore.VectorStore> vectorStoreProvider;
 
-    public VectorStoreServiceImpl(ObjectProvider<VectorStore> vectorStoreProvider) {
+    public VectorStoreServiceImpl(ObjectProvider<org.springframework.ai.vectorstore.VectorStore> vectorStoreProvider) {
         this.vectorStoreProvider = vectorStoreProvider;
     }
 
     @Override
     public List<String> add(List<Document> documents) {
-        if (documents == null || documents.isEmpty()) {
-            return List.of();
-        }
-        VectorStore store = vectorStoreProvider.getIfAvailable();
-        if (store == null) {
-            throw new BusinessException("Milvus 未启用或未连接,请在 application.yml 设置 app.milvus.enabled=true 并启动 Milvus 实例");
-        }
-        try {
-            // 收集每个 Document 的 id 作为 vectorId 返回
-            List<String> ids = new ArrayList<>(documents.size());
-            for (Document d : documents) {
-                ids.add(d.getId());
-            }
-            log.info("向 Milvus 插入 {} 条记录(内部 embedding)", documents.size());
-            store.add(documents);
-            return ids;
-        } catch (Exception e) {
-            log.error("Milvus 写入失败: {}", e.getMessage());
-            throw new BusinessException("Milvus 写入失败:" + e.getMessage());
-        }
+        log.warn("VectorStoreService.add 已弃用,写入请通过 EmbeddingBridgeClient");
+        return List.of();
     }
 
     @Override
     public void deleteByIds(List<String> ids) {
-        if (ids == null || ids.isEmpty()) {
-            return;
-        }
-        VectorStore store = vectorStoreProvider.getIfAvailable();
-        if (store == null) {
-            throw new BusinessException("Milvus 未启用或未连接");
-        }
-        try {
-            log.info("从 Milvus 删除 {} 条记录", ids.size());
-            store.delete(ids);
-        } catch (Exception e) {
-            log.error("Milvus 删除失败: {}", e.getMessage());
-            throw new BusinessException("Milvus 删除失败:" + e.getMessage());
-        }
+        log.warn("VectorStoreService.deleteByIds 已弃用,删除请通过 EmbeddingBridgeClient");
     }
 
     @Override
     public List<Document> similaritySearch(String query, int topK, Double similarityThreshold, String filterExpression) {
-        if (query == null || query.isBlank()) {
-            return List.of();
-        }
-        VectorStore store = vectorStoreProvider.getIfAvailable();
-        if (store == null) {
-            throw new BusinessException("Milvus 未启用或未连接,无法执行检索");
-        }
-        try {
-            SearchRequest.Builder builder = SearchRequest.builder()
-                    .query(query)
-                    .topK(topK > 0 ? topK : 5);
-            if (similarityThreshold != null) {
-                builder.similarityThreshold(similarityThreshold);
-            }
-            if (filterExpression != null && !filterExpression.isBlank()) {
-                builder.filterExpression(filterExpression);
-            }
-            List<Document> hits = store.similaritySearch(builder.build());
-            log.info("[SimilaritySearch] query='{}' topK={} filter='{}' -> hits={}",
-                    query, topK, filterExpression, hits == null ? 0 : hits.size());
-            return hits == null ? List.of() : hits;
-        } catch (Exception e) {
-            log.error("Milvus 检索失败: {}", e.getMessage());
-            throw new BusinessException("Milvus 检索失败:" + e.getMessage());
-        }
+        log.warn("similaritySearch 暂未实现(待接入 Embedding Bridge Hybrid Search),返回空结果");
+        return List.of();
     }
 
     @Override

+ 29 - 4
backend/src/main/resources/application.yml.example

@@ -33,11 +33,35 @@ app:
   # Milvus 向量库(文档向量化)
   milvus:
     enabled: ${MILVUS_ENABLED:true}
-    # ⚠️ 请改为你的 Milvus 实例地址
-    host: ${MILVUS_HOST:localhost}
+    # Java SDK 使用 gRPC 连接 Milvus Lite,不要加 http:// 前缀
+    # 本地 Milvus Lite 默认 gRPC 地址:127.0.0.1:19530
+    # HTTP REST 端口是 9091,不要混用
+    host: ${MILVUS_HOST:127.0.0.1}
     port: ${MILVUS_PORT:19530}
     collection: ${MILVUS_COLLECTION:kb_documents}
-    vector-dimension: 1024  # 智谱 embedding-2 输出维度
+    vector-dimension: 1024
+
+  # 本地 Embedding Bridge(稠密+稀疏向量、三级分块、Leaf-only 存储)
+  # 使用前需安装依赖: pip install -r backend/embedding-bridge/requirements.txt
+  # 首次运行会自动下载 HuggingFace 模型(约 2GB)
+  embedding-bridge:
+    enabled: ${EMBEDDING_BRIDGE_ENABLED:true}
+    host: ${EMBEDDING_BRIDGE_HOST:127.0.0.1}
+    port: ${EMBEDDING_BRIDGE_PORT:18732}
+    python-path: ${EMBEDDING_BRIDGE_PYTHON_PATH:python}
+    script-path: ${EMBEDDING_BRIDGE_SCRIPT_PATH:embedding-bridge/server.py}
+    startup-timeout: 120
+    health-check-interval: 5
+    http-timeout: 600000
+    # 访问令牌(为空时自动生成临时 token)
+    auth-token: ${EMBEDDING_BRIDGE_AUTH_TOKEN:}
+    # 本地 HuggingFace 嵌入模型
+    embedding-model: ${EMBEDDING_MODEL:BAAI/bge-m3}
+    embedding-device: ${EMBEDDING_DEVICE:cpu}
+    dense-embedding-dim: ${DENSE_EMBEDDING_DIM:1024}
+    # 三级分块叶子节点参数(L1/L2 按比例放大)
+    chunk-size: ${EMBEDDING_CHUNK_SIZE:800}
+    chunk-overlap: ${EMBEDDING_CHUNK_OVERLAP:100}
 
   # ========== SQL Console(结构化数据源管理) ==========
   sql-console:
@@ -104,7 +128,8 @@ spring:
           # 模型名称需与 base-url 对应服务一致
           model: glm-5.2
           temperature: 0.3
-      # 智谱 embedding-2(文档向量化用,未开通时调用会失败,UI 显示 FAILED 状态可重试)
+      # 智谱 embedding-2(已弃用,文档向量化现由本地 Embedding Bridge 完成)
+      # 保留本配置仅为兼容 Spring AI EmbeddingModel bean 创建
       embedding:
         options:
           model: embedding-2

+ 302 - 0
docs/local-semantic-chunking-and-embedding.md

@@ -0,0 +1,302 @@
+# 本地语义分块与向量化实现文档
+
+> 本文档记录项目中文档数据的本地语义分块、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` 发生变更时,应同步更新本文档。

+ 314 - 0
docs/super-mew-readme.md

@@ -0,0 +1,314 @@
+# SuperMew 项目说明
+
+> 本文件基于对 `SuperMew/` 目录的源码遍历整理,聚焦其技术栈、Embedding 方法及必需依赖,供 RAG / 向量化 / 知识库相关模块参考。
+
+---
+
+## 1. 项目主要工作
+
+SuperMew 是一个以 **RAG(检索增强生成)** 为核心的智能对话系统,定位为可本地部署的“猫咪机器人”知识库问答平台。其主营业务流程包括:
+
+1. **文档知识库**
+   - 支持 PDF、Word(.doc/.docx)、Excel(.xlsx)、HTML 等格式文档上传。
+   - 对文档进行三级滑动窗口分块(L1 / L2 / L3)。
+   - 将叶子分块(L3)向量化写入 Milvus,父级分块(L1/L2)写入 PostgreSQL。
+   - 重复上传同名文档时,先执行事务级清理(Milvus + PostgreSQL + Redis 缓存)。
+
+2. **智能对话**
+   - 基于 **LangChain Agent + LangGraph** 的流式对话。
+   - Agent 可调用自定义工具,例如:
+     - `search_knowledge_base`:检索知识库并回答。
+     - `get_current_weather`:天气查询示例。
+   - 简单问题直接检索;复杂问题由 LLM 分解为 2–4 个子问题,并行启动子 Agent 检索后合成答案。
+
+3. **混合检索与精排**
+   - 稠密向量(Dense)+ BM25 稀疏向量(Sparse)混合检索。
+   - 使用 Milvus Hybrid Search + RRF 融合。
+   - 对召回结果进行 Jina Rerank 精排,并支持 `rerank_score` 门控过滤。
+
+4. **RAG 可观测性**
+   - 在模型“思考”阶段,通过 SSE 实时推送 RAG 每一步状态(Searching / Grading / Rewriting / Auto-merging 等)。
+   - 前端可展开查看检索来源、得分、合并层级、页码等详细信息。
+
+5. **用户体系**
+   - JWT 鉴权、RBAC 角色控制(admin / user)。
+   - 会话历史持久化到 PostgreSQL,Redis 缓存热点会话与父文档。
+
+---
+
+## 2. 技术栈
+
+### 2.1 后端
+
+| 层级 | 技术 | 说明 |
+|------|------|------|
+| Web 框架 | **FastAPI** | API 层,提供 `/docs` 自动文档与 SSE 流式接口。 |
+| ASGI 服务器 | **Uvicorn** | 运行 FastAPI 应用。 |
+| LLM 框架 | **LangChain + LangGraph** | Agent、工具调用、RAG 工作流编排。 |
+| LLM 接入 | **langchain-openai** | 兼容 OpenAI 协议的 API(如火山方舟等)。 |
+| 向量嵌入 | **langchain-huggingface** | 本地 HuggingFace 嵌入模型。 |
+| 向量数据库 | **Milvus 2.5+** | 稠密向量索引 + 原生 BM25 稀疏索引。 |
+| 关系数据库 | **PostgreSQL 15** | 用户、会话、消息、父级分块存储。 |
+| ORM | **SQLAlchemy 2.x** | 数据库模型与访问。 |
+| 缓存 | **Redis 7** | 会话列表、消息、父文档缓存。 |
+| 认证 | **python-jose + PBKDF2** | JWT HS256 + 密码哈希。 |
+| 文档解析 | **PyPDF / docx2txt / unstructured / openpyxl / beautifulsoup4** | PDF、Word、Excel、HTML 解析。 |
+
+### 2.2 前端
+
+| 技术 | 说明 |
+|------|------|
+| **Vue 3** | 组合式 API + 单文件组件(SFC)。 |
+| **TypeScript** | 类型安全。 |
+| **Vite** | 构建工具与开发服务器。 |
+| **Pinia** | 全局状态管理(auth / sessions / chat / documents)。 |
+| **Axios / fetch** | HTTP 客户端;SSE 流式使用 `response.body.getReader()`。 |
+| **Marked + Highlight.js** | Markdown 渲染与代码高亮。 |
+| **FontAwesome + Sass** | 图标与样式。 |
+
+### 2.3 基础设施(Docker Compose)
+
+| 服务 | 镜像 | 端口 | 用途 |
+|------|------|------|------|
+| postgres | `postgres:15` | 5432 | 业务数据库 |
+| redis | `redis:7-alpine` | 6379 | 缓存 |
+| etcd | `quay.io/coreos/etcd:v3.5.18` | 2379 | Milvus 元数据 |
+| minio | `minio/minio:RELEASE.2024-05-28T17-19-04Z` | 9000 / 9001 | Milvus 对象存储 |
+| standalone | `milvusdb/milvus:v2.5.14` | 19530 / 9091 | Milvus 向量数据库 |
+| attu | `zilliz/attu:v2.5.11` | 8080 | Milvus 可视化管理 |
+
+---
+
+## 3. Embedding 具体方法
+
+### 3.1 稠密向量(Dense Embedding)
+
+- **实现文件**:`SuperMew/backend/indexing/embedding.py`
+- **使用库**:`langchain_huggingface.HuggingFaceEmbeddings`
+- **默认模型**:`BAAI/bge-m3`
+- **默认维度**:1024(通过环境变量 `DENSE_EMBEDDING_DIM` 配置,需与 Milvus 集合维度一致)
+- **默认设备**:`cpu`(可通过 `EMBEDDING_DEVICE` 改为 `cuda`)
+- **归一化**:启用 `normalize_embeddings=True`,配合 Milvus 的 `IP`(内积)距离度量。
+
+```python
+HuggingFaceEmbeddings(
+    model_name=model_name,                 # 默认 BAAI/bge-m3
+    model_kwargs={"device": device},       # 默认 cpu
+    encode_kwargs={"normalize_embeddings": True},
+)
+```
+
+### 3.2 稀疏向量 / BM25(Milvus 2.5+ 原生)
+
+项目已迁移至 **Milvus 2.5+ 原生 BM25**,不再在客户端维护 `bm25_state.json`。
+
+- **实现文件**:`SuperMew/backend/indexing/milvus_client.py`
+- **核心做法**:
+  1. Schema 中为 `text` 字段启用中文分析器:
+     ```python
+     schema.add_field(
+         "text", DataType.VARCHAR, max_length=65535,
+         enable_analyzer=True,
+         analyzer_params={"type": "chinese"},
+         enable_match=True,
+     )
+     ```
+  2. 绑定 BM25 计算函数,由 Milvus 服务端自动根据 `text` 生成 `sparse_embedding`:
+     ```python
+     bm25_function = Function(
+         name="text_bm25_emb",
+         function_type=FunctionType.BM25,
+         input_field_names=["text"],
+         output_field_names=["sparse_embedding"],
+     )
+     schema.add_function(bm25_function)
+     ```
+  3. 为 `sparse_embedding` 建立 `SPARSE_INVERTED_INDEX`,度量类型为 `BM25`。
+
+### 3.3 文档分块策略
+
+- **实现文件**:`SuperMew/backend/indexing/document_loader.py`
+- **策略**:三级层次化滑动窗口分块(L1 / L2 / L3)
+
+| 层级 | 默认 chunk_size | 默认 chunk_overlap | 用途 |
+|------|----------------|-------------------|------|
+| L1 | 2400 | 300 | 粗粒度父块 |
+| L2 | 1600 | 200 | 中粒度父块 |
+| L3 | 800  | 100 | 叶子检索块 |
+
+- **分隔符(针对中文优化)**:
+  ```python
+  separators=["\n\n", "。", "!", "?", "\n", ",", "、", " ", ""]
+  ```
+- **元数据**:每个 chunk 记录 `chunk_id`、`parent_chunk_id`、`root_chunk_id`、`chunk_level`、`page` 等。
+
+### 3.4 存储策略
+
+- **Leaf-only 向量化**:仅 L3 叶子块写入 Milvus(稠密向量 + 稀疏向量)。
+- **父级分块**:L1 / L2 写入 PostgreSQL 的 `parent_chunks` 表,便于检索时 Auto-merging 向上聚合上下文。
+- **文本清洗**:入库前通过 `sanitize_text()` 进行 Unicode NFC 规范化,并过滤零宽字符、BOM、控制字符、PUA 区字符及孤立 UTF-16 代理项。
+
+### 3.5 检索流水线
+
+- **实现文件**:`SuperMew/backend/rag/utils.py`
+- **流程**:
+  1. 查询生成稠密向量。
+  2. `MilvusStore.hybrid_retrieve()` 同时发起 Dense(IP, `ef=64`)与 Sparse(BM25)两路 `AnnSearchRequest`。
+  3. 使用 `RRFRanker(k=60)` 融合两路结果。
+  4. **Auto-merging**:同一父块下命中子块数 ≥ `AUTO_MERGE_THRESHOLD`(默认 2)时,用父块替换子块(L3 → L2 → L1)。
+  5. **Rerank**:调用 Jina Rerank API 精排,按 `RERANK_MIN_SCORE` 过滤,最终截断到 `top_k`。
+- **降级策略**:Hybrid 失败时自动降级为纯 Dense 检索;完全失败返回空结果。
+
+---
+
+## 4. 必需依赖
+
+### 4.1 Python 依赖(pyproject.toml)
+
+项目要求 **Python >= 3.12**,推荐用 `uv` 管理。
+
+```toml
+[project]
+dependencies = [
+    "rich>=14.2.0",
+    "fastapi>=0.115.0",
+    "uvicorn>=0.30.0",
+    "python-dotenv>=1.0.1",
+    "requests>=2.32.0",
+    "pymilvus>=2.5.0",
+    "python-multipart>=0.0.9",
+    "pydantic>=2.8.0",
+    "langchain>=0.2.14",
+    "langchain-core>=0.2.37",
+    "langchain-community>=0.2.12",
+    "langchain-text-splitters>=0.2.2",
+    "langchain-huggingface>=0.1.0",
+    "langchain-openai>=0.1.22",
+    "sentence-transformers>=3.0.0",
+    "langgraph>=0.2.31",
+    "pypdf>=4.3.1",
+    "docx2txt>=0.8",
+    "unstructured",
+    "openpyxl",
+    "tabulate",
+    "msoffcrypto-tool",
+    "sqlalchemy>=2.0.36",
+    "psycopg2-binary>=2.9.10",
+    "redis>=5.2.1",
+    "passlib[bcrypt]>=1.7.4",
+    "python-jose[cryptography]>=3.3.0",
+    "beautifulsoup4>=4.12.0",
+]
+
+[project.optional-dependencies]
+study = [
+    "langchain-classic>=0.2.0",
+    "chromadb>=0.5.5",
+    "bilibili-api-python>=17.0.0",
+]
+```
+
+**核心依赖分组说明**:
+
+- **Web / API**:`fastapi`、`uvicorn`、`python-multipart`、`pydantic`
+- **LLM / RAG**:`langchain*`、`langgraph`、`langchain-openai`、`langchain-huggingface`
+- **向量库**:`pymilvus`
+- **Embedding**:`sentence-transformers`(`HuggingFaceEmbeddings` 底层需要)
+- **文档解析**:`pypdf`、`docx2txt`、`unstructured`、`openpyxl`、`beautifulsoup4`
+- **数据持久化**:`sqlalchemy`、`psycopg2-binary`、`redis`
+- **认证安全**:`passlib[bcrypt]`、`python-jose[cryptography]`
+
+### 4.2 前端依赖(frontend/package.json)
+
+```json
+{
+  "dependencies": {
+    "@fortawesome/fontawesome-free": "^6.4.0",
+    "axios": "^1.6.0",
+    "highlight.js": "^11.7.0",
+    "marked": "^9.1.0",
+    "pinia": "^2.1.0",
+    "vue": "^3.3.0"
+  },
+  "devDependencies": {
+    "@types/marked": "^4.3.0",
+    "@types/node": "^20.0.0",
+    "@vitejs/plugin-vue": "^4.2.0",
+    "sass": "^1.63.0",
+    "typescript": "^5.0.0",
+    "vite": "^4.3.0",
+    "vue-tsc": "^3.3.4"
+  }
+}
+```
+
+### 4.3 Docker 基础设施依赖
+
+启动完整依赖环境需要安装 **Docker + Docker Compose**,然后执行:
+
+```bash
+docker compose up -d
+```
+
+涉及的容器服务:PostgreSQL、Redis、etcd、MinIO、Milvus standalone、Attu。
+
+---
+
+## 5. 关键环境变量
+
+| 变量 | 默认值 | 说明 |
+|------|--------|------|
+| `ARK_API_KEY` | - | LLM API Key |
+| `MODEL` | - | 主模型名 |
+| `FAST_MODEL` | `MODEL` | 快速模型(复杂度分类、子问题分解) |
+| `GRADE_MODEL` | `gpt-4.1` | 文档评分模型 |
+| `BASE_URL` | - | OpenAI 兼容 API 基地址 |
+| `EMBEDDING_MODEL` | `BAAI/bge-m3` | 本地嵌入模型 |
+| `EMBEDDING_DEVICE` | `cpu` | 嵌入设备(cpu / cuda) |
+| `DENSE_EMBEDDING_DIM` | `1024` | 稠密向量维度 |
+| `RERANK_MODEL` | - | Rerank 模型名 |
+| `RERANK_BINDING_HOST` | - | Rerank API 地址 |
+| `RERANK_API_KEY` | - | Rerank API Key |
+| `MILVUS_HOST` | `127.0.0.1` | Milvus 地址 |
+| `MILVUS_PORT` | `19530` | Milvus 端口 |
+| `DATABASE_URL` | - | PostgreSQL 连接串 |
+| `REDIS_URL` | `redis://127.0.0.1:6379/0` | Redis 连接 |
+| `JWT_SECRET_KEY` | - | JWT 密钥 |
+| `ADMIN_INVITE_CODE` | `supermew-admin-2026` | 管理员邀请码 |
+
+---
+
+## 6. 核心目录结构
+
+```
+SuperMew/
+├── backend/
+│   ├── app.py                    # FastAPI 入口
+│   ├── api/                      # HTTP 路由
+│   ├── chat/                     # 对话、Agent 运行时、SSE 推送、会话存储
+│   ├── rag/                      # RAG 工作流(LangGraph)与检索工具
+│   ├── indexing/                 # 文档加载、分块、Embedding、Milvus 写入
+│   ├── tools/                    # Agent 可调用的工具
+│   ├── infra/                    # 数据库、缓存、认证
+│   ├── db/                       # SQLAlchemy ORM 模型
+│   └── schemas/                  # Pydantic 模型
+├── frontend/                     # Vite + Vue 3 + TypeScript 前端
+├── docker-compose.yml            # 基础设施编排
+├── pyproject.toml                # Python 依赖
+└── README.md                     # 项目原 README
+```
+
+---
+
+## 7. 总结
+
+SuperMew 是一个工程化较完整的 RAG 参考项目,核心亮点包括:
+
+- **本地 Embedding**:基于 `langchain-huggingface` 运行 `BAAI/bge-m3` 等本地模型生成稠密向量。
+- **Milvus 2.5+ 原生 BM25**:利用服务端 `FunctionType.BM25` 自动生成稀疏向量,避免客户端维护 BM25 状态。
+- **三级分块 + Auto-merging**:L1/L2/L3 层次化切分,仅 L3 入向量库,检索时自动向上合并。
+- **Hybrid Search + RRF + Jina Rerank**:兼顾语义召回与词匹配,并提供精排序。
+- **自适应复杂问题处理**:简单问题直发检索,复杂问题分解为子问题并行执行子 Agent。
+- **实时可观测 SSE**:RAG 每一步实时推送到前端,解决“静默思考”问题。
+
+如需复刻其 Embedding 与检索链路,重点关注 `backend/indexing/embedding.py`、`backend/indexing/milvus_client.py`、`backend/indexing/document_loader.py` 与 `backend/rag/utils.py` 四个文件。

+ 8 - 0
frontend/src/api/knowledge.js

@@ -68,6 +68,14 @@ export function revectorizeKbDocument(id) {
   return request.post(`/kb/documents/${id}/revectorize`)
 }
 
+export function vectorizeKbDocument(id) {
+  return request.post(`/kb/documents/${id}/vectorize`)
+}
+
+export function vectorizeKbChunk(chunkId) {
+  return request.post(`/kb/documents/chunks/${chunkId}/vectorize`)
+}
+
 export function moveKbDocument(id, categoryId) {
   return request.post(`/kb/documents/${id}/move`, { categoryId })
 }

+ 300 - 3
frontend/src/views/knowledge/DocumentManagement.vue

@@ -5,14 +5,14 @@ import { NButton, NIcon, NInput, NSelect, NTag, NSpin, NBreadcrumb, NInputNumber
 import {
   AddOutline, RefreshOutline, CloudUploadOutline, SearchOutline,
   TrashOutline, ReloadOutline, ChevronForwardOutline, DocumentTextOutline,
-  LayersOutline, AlertCircleOutline, SparklesOutline
+  LayersOutline, AlertCircleOutline, SparklesOutline, ListOutline
 } from '@vicons/ionicons5'
 import KbCategoryTree from '../../components/knowledge/KbCategoryTree.vue'
 import DocumentUploader from '../../components/knowledge/DocumentUploader.vue'
 import {
   getKbCategories, createKbCategory, updateKbCategory, deleteKbCategory,
-  getKbDocuments, deleteKbDocument, revectorizeKbDocument,
-  getKbDependencies, searchKb
+  getKbDocuments, deleteKbDocument, revectorizeKbDocument, vectorizeKbDocument,
+  getKbDependencies, searchKb, getKbDocumentChunks, vectorizeKbChunk
 } from '../../api/knowledge'
 
 const message = useMessage()
@@ -34,6 +34,16 @@ const total = ref(0)
 
 const showUploader = ref(false)
 
+// ============ 分块查看状态 ============
+const showChunks = ref(false)
+const chunksLoading = ref(false)
+const chunksDoc = ref(null)
+const chunks = ref([])
+
+// ============ 分块完整内容查看状态 ============
+const showFullContent = ref(false)
+const fullChunk = ref(null)
+
 // ============ 向量检索状态 ============
 const showSearch = ref(false)
 const searchQuery = ref('')
@@ -265,6 +275,60 @@ async function revectorize(doc) {
   }
 }
 
+async function vectorizeDocument(doc) {
+  try {
+    await vectorizeKbDocument(doc.id)
+    message.success('已向量化')
+    await loadDocuments()
+    if (showChunks.value && chunksDoc.value?.id === doc.id) {
+      await viewChunks(doc)
+    }
+  } catch (e) {
+    message.error('向量化失败:' + e.message)
+  }
+}
+
+async function vectorizeChunk(chunk) {
+  try {
+    await vectorizeKbChunk(chunk.id)
+    message.success('分块已向量化')
+    if (chunksDoc.value) await viewChunks(chunksDoc.value)
+  } catch (e) {
+    message.error('分块向量化失败:' + e.message)
+  }
+}
+
+async function viewChunks(doc) {
+  chunksDoc.value = doc
+  showChunks.value = true
+  chunksLoading.value = true
+  chunks.value = []
+  try {
+    const res = await getKbDocumentChunks(doc.id)
+    chunks.value = res.data || []
+  } catch (e) {
+    message.error('加载分块失败:' + e.message)
+  } finally {
+    chunksLoading.value = false
+  }
+}
+
+function closeChunks() {
+  showChunks.value = false
+  chunksDoc.value = null
+  chunks.value = []
+}
+
+function viewFullContent(chunk) {
+  fullChunk.value = chunk
+  showFullContent.value = true
+}
+
+function closeFullContent() {
+  showFullContent.value = false
+  fullChunk.value = null
+}
+
 function onUploaded() {
   loadDocuments()
 }
@@ -557,6 +621,21 @@ onUnmounted(() => {
               <div class="doc-card-footer">
                 <span class="doc-time">{{ doc.createdAt }}</span>
                 <div class="footer-actions">
+                  <button
+                    class="action-btn"
+                    title="查看分块"
+                    @click="viewChunks(doc)"
+                  >
+                    <n-icon size="14"><ListOutline /></n-icon>
+                  </button>
+                  <button
+                    class="action-btn primary"
+                    title="向量化"
+                    @click="vectorizeDocument(doc)"
+                    :disabled="doc.status === 'PENDING' || doc.status === 'PARSING' || doc.status === 'CHUNKING' || doc.status === 'EMBEDDING' || doc.status === 'VECTORIZING' || doc.vectorCount === doc.chunkCount"
+                  >
+                    <n-icon size="14"><SparklesOutline /></n-icon>
+                  </button>
                   <button
                     class="action-btn"
                     title="重新向量化"
@@ -591,6 +670,68 @@ onUnmounted(() => {
       </main>
     </div>
 
+    <!-- 分块查看弹窗 -->
+    <div v-if="showChunks" class="chunks-modal-overlay" @click.self="closeChunks">
+      <div class="chunks-modal">
+        <div class="chunks-modal-header">
+          <div class="chunks-title">
+            <n-icon size="18" color="var(--color-accent)"><ListOutline /></n-icon>
+            <span>「{{ chunksDoc?.name }}」分块详情</span>
+          </div>
+          <button class="chunks-close" @click="closeChunks">×</button>
+        </div>
+        <div class="chunks-modal-body">
+          <div v-if="chunksLoading" class="loading-block"><n-spin size="small" /> 加载中…</div>
+          <div v-else-if="chunks.length === 0" class="empty-block">暂无分块数据</div>
+          <div v-else class="chunks-list">
+            <div v-for="(chunk, idx) in chunks" :key="idx" class="chunk-item">
+              <div class="chunk-header">
+                <span class="chunk-index">#{{ chunk.chunkIndex + 1 }}</span>
+                <span class="chunk-meta level">L{{ chunk.chunkLevel ?? 3 }}</span>
+                <span class="chunk-meta">{{ chunk.charCount }} 字符</span>
+                <span v-if="chunk.vectorId" class="chunk-meta vector">已向量化</span>
+                <span v-else-if="(chunk.chunkLevel ?? 3) === 3" class="chunk-meta no-vector">未向量化</span>
+                <span v-else class="chunk-meta no-vector">父块不入库</span>
+                <button
+                  v-if="!chunk.vectorId && (chunk.chunkLevel ?? 3) === 3"
+                  class="chunk-vectorize-btn"
+                  @click="vectorizeChunk({ id: chunk.id, chunkIndex: chunk.chunkIndex })"
+                >
+                  <n-icon size="12"><SparklesOutline /></n-icon> 向量化
+                </button>
+              </div>
+              <div class="chunk-preview">
+                <pre>{{ chunk.contentPreview || chunk.preview || chunk.content }}</pre>
+                <button
+                  v-if="(chunk.contentPreview || chunk.preview || '').length >= 200 || (chunk.content && chunk.content.length > (chunk.contentPreview || chunk.preview || '').length)" 
+                  class="view-full-btn"
+                  @click="viewFullContent(chunk)"
+                >
+                  查看完整内容
+                </button>
+              </div>
+            </div>
+          </div>
+        </div>
+      </div>
+    </div>
+
+    <!-- 分块完整内容弹窗 -->
+    <div v-if="showFullContent" class="chunks-modal-overlay" @click.self="closeFullContent">
+      <div class="chunks-modal full-content-modal">
+        <div class="chunks-modal-header">
+          <div class="chunks-title">
+            <n-icon size="18" color="var(--color-accent)"><ListOutline /></n-icon>
+            <span>#{{ fullChunk?.chunkIndex + 1 }} 完整内容(L{{ fullChunk?.chunkLevel ?? 3 }} · {{ fullChunk?.charCount }} 字符)</span>
+          </div>
+          <button class="chunks-close" @click="closeFullContent">×</button>
+        </div>
+        <div class="chunks-modal-body">
+          <pre class="full-chunk-content">{{ fullChunk?.content }}</pre>
+        </div>
+      </div>
+    </div>
+
     <!-- 上传弹窗 -->
     <DocumentUploader
       v-model:show="showUploader"
@@ -897,9 +1038,26 @@ onUnmounted(() => {
   align-items: center;
 }
 .action-btn:hover { color: var(--color-accent); background: rgba(37, 99, 235, 0.1); }
+.action-btn.primary { color: var(--color-accent); }
+.action-btn.primary:hover { color: #fff; background: var(--color-accent); }
 .action-btn.danger:hover { color: #ef4444; background: rgba(239, 68, 68, 0.1); }
 .action-btn:disabled { opacity: 0.4; cursor: not-allowed; }
 
+.chunk-vectorize-btn {
+  margin-left: auto;
+  display: inline-flex;
+  align-items: center;
+  gap: 4px;
+  font-size: 11px;
+  padding: 2px 8px;
+  border-radius: 4px;
+  border: 1px solid var(--color-accent);
+  background: rgba(37, 99, 235, 0.1);
+  color: var(--color-accent);
+  cursor: pointer;
+}
+.chunk-vectorize-btn:hover { background: var(--color-accent); color: #fff; }
+
 .pagination {
   display: flex;
   align-items: center;
@@ -910,4 +1068,143 @@ onUnmounted(() => {
   color: var(--text-muted);
 }
 .page-info { font-variant-numeric: tabular-nums; }
+
+/* 分块查看弹窗 */
+.chunks-modal-overlay {
+  position: fixed;
+  inset: 0;
+  background: rgba(0, 0, 0, 0.6);
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  z-index: 2000;
+}
+.chunks-modal {
+  width: 720px;
+  max-width: 90vw;
+  max-height: 80vh;
+  background: var(--bg-primary);
+  border: 1px solid var(--border-color);
+  border-radius: 10px;
+  box-shadow: 0 20px 60px rgba(0, 0, 0, 0.4);
+  display: flex;
+  flex-direction: column;
+  overflow: hidden;
+}
+.chunks-modal-header {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  padding: 14px 16px;
+  border-bottom: 1px solid var(--border-color);
+}
+.chunks-title {
+  display: flex;
+  align-items: center;
+  gap: 8px;
+  font-size: 14px;
+  font-weight: 600;
+  color: var(--text-primary);
+}
+.chunks-close {
+  background: none;
+  border: none;
+  color: var(--text-muted);
+  font-size: 22px;
+  cursor: pointer;
+  line-height: 1;
+}
+.chunks-close:hover { color: var(--text-primary); }
+.chunks-modal-body {
+  flex: 1;
+  overflow-y: auto;
+  padding: 12px 16px;
+  min-height: 0;
+}
+.chunks-list {
+  display: flex;
+  flex-direction: column;
+  gap: 10px;
+}
+.chunk-item {
+  border: 1px solid var(--border-color);
+  border-radius: 8px;
+  background: var(--bg-secondary);
+  overflow: hidden;
+}
+.chunk-header {
+  display: flex;
+  align-items: center;
+  gap: 10px;
+  padding: 8px 12px;
+  background: rgba(37, 99, 235, 0.08);
+  border-bottom: 1px solid var(--border-color);
+  font-size: 11px;
+}
+.chunk-index {
+  font-weight: 600;
+  color: var(--color-accent);
+}
+.chunk-meta { color: var(--text-muted); }
+.chunk-meta.vector { color: #10b981; }
+.chunk-meta.no-vector { color: #f59e0b; }
+.chunk-preview {
+  padding: 10px 12px;
+  font-family: 'Cascadia Code', 'Fira Code', monospace;
+  font-size: 12px;
+  line-height: 1.6;
+  color: var(--text-secondary);
+}
+.chunk-preview pre {
+  margin: 0;
+  white-space: pre-wrap;
+  word-break: break-word;
+  max-height: 240px;
+  overflow-y: auto;
+}
+.view-full-btn {
+  margin-top: 8px;
+  padding: 4px 10px;
+  border: 1px solid var(--color-accent);
+  border-radius: 4px;
+  background: transparent;
+  color: var(--color-accent);
+  font-size: 12px;
+  cursor: pointer;
+  transition: all 0.2s;
+}
+.view-full-btn:hover {
+  background: var(--color-accent);
+  color: #fff;
+}
+.full-content-modal {
+  width: 80vw;
+  max-width: 960px;
+}
+.full-chunk-content {
+  margin: 0;
+  padding: 12px;
+  font-family: 'Cascadia Code', 'Fira Code', monospace;
+  font-size: 13px;
+  line-height: 1.7;
+  color: var(--text-secondary);
+  white-space: pre-wrap;
+  word-break: break-word;
+  max-height: 70vh;
+  overflow-y: auto;
+  background: var(--bg-primary);
+  border-radius: 6px;
+}
+.chunk-content {
+  margin: 0;
+  padding: 10px 12px;
+  font-family: 'Cascadia Code', 'Fira Code', monospace;
+  font-size: 12px;
+  line-height: 1.6;
+  color: var(--text-secondary);
+  white-space: pre-wrap;
+  word-break: break-word;
+  max-height: 240px;
+  overflow-y: auto;
+}
 </style>

+ 49 - 16
frontend/src/views/workflow/WorkflowEditor.vue

@@ -53,7 +53,8 @@ const promptEditValue = ref('')
 const {
   onConnect, addEdges, addNodes, removeNodes, removeEdges,
   updateNodeData: vfUpdateNode,
-  project, vueFlowRef, getNodes, getEdges, fitView, onInit
+  project, vueFlowRef, getNodes, getEdges, fitView, onInit,
+  onNodeDragStart, onNodeDrag, onNodeDragStop
 } = useVueFlow()
 
 const selectedNode = ref(null)
@@ -126,6 +127,37 @@ onConnect((params) => {
   }])
 })
 
+// ========== 自动关联虚线组拖动联动 ==========
+// 虚线框(virtualGroup)拖动时,同步移动其内部所有技能子节点
+// 使用绝对画布坐标而非 parentId 机制(parentId 依赖 dimensions 异步测量,不可靠)
+const dragStartPositions = new Map()
+
+onNodeDragStart(({ node }) => {
+  if (node?.type === 'virtualGroup') {
+    dragStartPositions.set(node.id, { ...node.position })
+  }
+})
+
+onNodeDrag(({ node }) => {
+  if (node?.type !== 'virtualGroup') return
+  const start = dragStartPositions.get(node.id)
+  if (!start) return
+  const dx = node.position.x - start.x
+  const dy = node.position.y - start.y
+  if (dx === 0 && dy === 0) return
+  // 联动所有归属该虚框的技能节点(通过 data.virtualGroupId 标记)
+  getNodes.value.forEach(n => {
+    if (n.data?.virtualGroupId === node.id) {
+      n.position = { x: n.position.x + dx, y: n.position.y + dy }
+    }
+  })
+  dragStartPositions.set(node.id, { ...node.position })
+})
+
+onNodeDragStop(() => {
+  dragStartPositions.clear()
+})
+
 // 批量更新边的映射数据和样式
 function applyEdgeMappingUpdates(updates) {
   for (const upd of updates) {
@@ -443,23 +475,26 @@ async function handleAutoAssociate() {
           return
         }
         const skillId = 'virtual-' + item.folderName + '-' + ts
-        // 技能节点作为虚框的子节点:坐标相对于父节点,跟随父节点拖动
-        const skillX = GROUP_PADDING_X
-        const skillY = GROUP_PADDING_TOP + idx * (SKILL_HEIGHT + SKILL_GAP)
+        // 绝对画布坐标:父节点位置 + 相对偏移。
+        // 不使用 parentId/extent(依赖 dimensions 异步测量,未完成时子节点会跑到画布左上角)。
+        // 改为通过 data.virtualGroupId 标记归属,onNodeDrag 时手动联动子节点位置。
+        const skillAbsX = groupX + GROUP_PADDING_X
+        const skillAbsY = groupY + GROUP_PADDING_TOP + idx * (SKILL_HEIGHT + SKILL_GAP)
         skillNodes.push({
           id: skillId,
           type: 'skill',
-          position: { x: skillX, y: skillY },
+          position: { x: skillAbsX, y: skillAbsY },
           data: {
             label: item.skillName,
             skillId: item.folderName,
             skillName: item.skillName,
-            virtual: true
+            virtual: true,
+            virtualGroupId: groupId    // 标记归属,拖动联动时按此查找
           },
           class: 'virtual-node',
-          parentId: groupId,       // 跟随父节点(虚框)拖动
-          extent: 'parent',        // 限制在父节点范围内
-          draggable: false         // 子节点不单独拖动,事件穿透到父节点实现整体拖动
+          draggable: false,            // 子节点不单独拖动,事件穿透到父节点实现整体拖动
+          selectable: false,
+          connectable: false
         })
         skillNodeMap[item.folderName] = skillId
       })
@@ -478,11 +513,9 @@ async function handleAutoAssociate() {
       })
     }
 
-    // 先添加父节点(虚框),让 Vue Flow 完成注册和 dimensions 测量;
-    // 再添加子节点(技能),此时 parentId 关系才能正确建立,避免子节点跑到画布左上角。
-    if (groupNodes.length) addNodes(groupNodes)
-    await nextTick()
-    if (skillNodes.length) addNodes(skillNodes)
+    // 一次性添加所有节点(父+子)与边。子节点使用绝对坐标,无需等待 dimensions 测量。
+    const allNodes = [...groupNodes, ...skillNodes]
+    if (allNodes.length) addNodes(allNodes)
     if (edgesToAdd.length) addEdges(edgesToAdd)
 
     hasAssociations.value = true
@@ -2078,9 +2111,9 @@ function applyGraphData(graphData) {
   border-color: #a5b4fc !important;
 }
 
-/* 虚线分组容器节点 */
+/* 虚线分组容器节点:必须保留 pointer-events 以接收拖动事件实现整体拖动 */
 .canvas-area :deep(.vue-flow__node.virtual-group-node) {
-  pointer-events: none !important;
+  pointer-events: all !important;
 }
 .canvas-area :deep(.vue-flow__node.virtual-group-node .vue-flow__handle) {
   pointer-events: none !important;

+ 143 - 1
prompt.md

@@ -630,4 +630,146 @@ hermes报错乱码,请帮我看一下什么意思。另外,解决该乱码
 
 对比application.yml和application.yml.example,将更新写入.example文件中。注意,敏感信息不要写入。
 
----
+---
+
+自动关联功能,识别成功后,相关技能还是出现在画布左上方,与虚线框不在一起。我需要该节点位于虚线框中,同时技能和虚线框要组合成为一个整体去拖动,不能分开各拖各的。请修复。
+
+---
+
+我在本地使用pip安装了pymilvus,支持使用本地db文件存储向量数据。参考“https://github.com/milvus-io/milvus”,修改为连接本地的milvus吧
+
+---
+
+我已经使用pip安装了pymilvus,但是执行:
+python -m milvus.server --data ./milvus.db
+报错:
+C:\Python311\python.exe: Error while finding module specification for 'milvus.server' (ModuleNotFoundError: No module named 'milvus')
+请帮我解决。注意,只解决问题即可,不要帮我启动服务,我自己来启动。
+
+---
+
+我补充安装了milvus-lite,但是启动时还是报同样的错误。
+
+---
+
+现在,删除“文档数据”中的文档时,报“服务器内部错误”。
+
+---
+
+2026-06-23 10:14:54.624 [task-1] INFO  c.a.m.service.impl.DocumentPipelineImpl - [流水线启动] doc=2 (火箭军某旅对接实战深训同一课目.docx)
+2026-06-23 10:14:56.026 [task-1] INFO  c.a.m.service.impl.DocumentPipelineImpl - [解析完成] doc=2 字符数=1223
+2026-06-23 10:14:56.054 [task-1] INFO  c.a.m.service.impl.DocumentPipelineImpl - [分块完成] doc=2 chunkCount=2
+2026-06-23 10:14:56.060 [task-1] ERROR c.a.m.service.impl.DocumentPipelineImpl - [流水线失败] doc=2 err=metadata cannot have null values
+java.lang.IllegalArgumentException: metadata cannot have null values
+
+---
+
+重新向量化报错:
+2026-06-23 10:44:34.253 [http-nio-2438-exec-2] ERROR c.a.m.c.exception.GlobalExceptionHandler - 系统异常
+org.springframework.dao.InvalidDataAccessApiUsageException: No EntityManager with actual transaction available for current thread - cannot reliably process 'remove' call
+
+---
+
+1. 文章分块过粗,一个新闻稿只分了2块;
+2. 增加分块查看功能;
+3. 写入报错:
+2026-06-23 11:39:10.096 [task-1] ERROR c.a.m.s.impl.VectorStoreServiceImpl - Milvus 写入失败: HTTP 404 - <html><body><h1>Whitelabel Error Page</h1><p>This application has no explicit mapping for /error, so you are seeing this as a fallback.</p><div id='created'>Tue Jun 23 11:39:09 CST 2026</div><div>There was an unexpected error (type=Not Found, status=404).</div></body></html>
+2026-06-23 11:39:10.097 [task-1] ERROR c.a.m.service.impl.DocumentPipelineImpl - [流水线失败] doc=2 err=Milvus 写入失败:HTTP 404 - <html><body><h1>Whitelabel Error Page</h1><p>This application has no explicit mapping for /error, so you are seeing this as a fallback.</p><div id='created'>Tue Jun 23 11:39:09 CST 2026</div><div>There was an unexpected error (type=Not Found, status=404).</div></body></html>
+com.agent.management.common.exception.BusinessException: Milvus 写入失败:HTTP 404 - <html><body><h1>Whitelabel Error Page</h1><p>This application has no explicit mapping for /error, so you are seeing this as a fallback.</p><div id='created'>Tue Jun 23 11:39:09 CST 2026</div><div>There was an unexpected error (type=Not Found, status=404).</div></body></html>
+4. 检索测试报错:
+2026-06-23 11:41:57.274 [http-nio-2438-exec-2] INFO  c.a.m.config.AiRequestLoggingConfig - [AI-HTTP] 响应 Body (275 chars): <html><body><h1>Whitelabel Error Page</h1><p>This application has no explicit mapping for /error, so you are seeing this as a fallback.</p><div id='created'>Tue Jun 23 11:41:57 CST 2026</div><div>There was an unexpected error (type=Not Found, status=404).</div></body></html>
+2026-06-23 11:41:57.274 [http-nio-2438-exec-2] INFO  c.a.m.config.AiRequestLoggingConfig - [AI-HTTP] ========== 请求结束 ==========
+2026-06-23 11:41:57.275 [http-nio-2438-exec-2] WARN  o.s.a.r.a.SpringAiRetryAutoConfiguration - Retry error. Retry count: 1, Exception: HTTP 404 - <html><body><h1>Whitelabel Error Page</h1><p>This application has no explicit mapping for /error, so you are seeing this as a fallback.</p><div id='created'>Tue Jun 23 11:41:57 CST 2026</div><div>There was an unexpected error (type=Not Found, status=404).</div></body></html>
+org.springframework.ai.retry.NonTransientAiException: HTTP 404 - <html><body><h1>Whitelabel Error Page</h1><p>This application has no explicit mapping for /error, so you are seeing this as a fallback.</p><div id='created'>Tue Jun 23 11:41:57 CST 2026</div><div>There was an unexpected error (type=Not Found, status=404).</div></body></html>
+
+---
+
+目前我上传了一则新闻,分块还是2块,且2块均显示“未向量化”。向量化按钮在哪里?是否是因为失败了才显示“未向量化”?
+
+---
+
+1. 根据语义进行分片,而非chunk-size;
+2. 针对每一片,增加一个"向量化"主按钮让操作更明显;同时在文档处增加向量化按钮,给所有分片向量化;
+3. 最后看日志,分析为向量化原因
+
+---
+
+向量化报错:
+ ERROR c.a.m.c.exception.GlobalExceptionHandler - 系统异常
+org.springframework.web.method.annotation.MethodArgumentTypeMismatchException: Method parameter 'chunkId': Failed to convert value of type 'java.lang.String' to required type 'java.lang.Long'; For input string: "undefined"
+        at org.springframework.web.method.annotation.AbstractNamedValueMethodArgumentResolver.convertIfNecessary(AbstractNamedValueMethodArgumentResolver.java:301)
+        at org.springframework.web.method.annotation.AbstractNamedValueMethodArgumentResolver.resolveArgument(AbstractNamedValueMethodArgumentResolver.java:136)
+        at org.springframework.web.method.support.HandlerMethodArgumentResolverComposite.resolveArgument(HandlerMethodArgumentResolverComposite.java:122)
+        ...
+Caused by: java.lang.NumberFormatException: For input string: "undefined"
+        at java.base/java.lang.NumberFormatException.forInputString(NumberFormatException.java:67)
+        at java.base/java.lang.Long.parseLong(Long.java:711)
+        at java.base/java.lang.Long.valueOf(Long.java:1163)
+        at org.springframework.util.NumberUtils.parseNumber(NumberUtils.java:206)
+
+---
+
+给我把插入Milvus时的详细日志打出来,包括调用Milvus的协议、端口号等
+
+---
+
+分支1:@SuperMew目录是一个与RAG、向量化、知识库等相关的参考项目。请遍历该项目,告诉我这个项目主要干了哪些工作,在@docs下生成一个super-mew-readme.md,重点聚焦于其技术栈、Embedding的具体方法以及必需依赖等方面。
+
+---
+
+参考@SuperMew项目自身文档@SuperMew/README.md及我对其的补充文档@docs/super-mew-readme.md,实现本地Embedding(不再使用智谱Embedding),需要实现稠密向量 + 稀疏向量、三级分块、Leaf-only 向量化存储,尽量复用(复制)SuperMew项目中的代码。暂不用实现检索相关功能。有需要我决策的内容,给我提出。
+
+---
+
+使用slf4j,实现日志按天打印功能,并实现30天后自动清理
+
+---
+
+查看@backend/logs/agent-management.log中的最后一次启动日志,解决子进程端口占用不断重启的问题。
+
+---
+
+增加分块内容查看功能。
+
+---
+
+请告诉我分块的具体算法。为什么现在总是在句子中间分块?以及为什么分块完成后,每个L3级子块都没有了`,。;`等中文标点符号?
+
+---
+
+修改。我的要求:
+1. 所有L1加起来,文字内容与原文一致,不能少标点;
+2. 例如某L1划分为两个L2,那么两个L2加起来,文字内容与L1一致,不能少标点;
+3. 划分位置应在标点处;
+4. 尽量根据语义进行划分,例如一段话中,根据标点分为ABCD4个子句,AB说的一件事儿,CD说的一件事儿,那么不要把BC划分到一起。
+
+---
+
+我表达的有误,我允许overlap存在,但需要划分在合理的地方(标点处);假如某L1划分为两个L2,那么两个L2加起来,去掉overlap后,文字内容与L1一致,不能少标点。
+
+---
+
+请查看后端日志,又出现了数据库连接未断开的情况。设计机制,在后端进程被杀死的时候,清理数据库连接。
+
+---
+
+目前的语义分块原理是什么?请详细解释。
+
+---
+
+所以现在并未使用bert等框架进行句子的语义理解和分块是吗?
+
+---
+
+将当前的语义分块、向量化等实现方式、完成内容、相关说明等写入docs目录,你自己命名。
+
+---
+
+就算使用语义进行分块,我也允许L2和L3级别出现重叠,例如,一个承上启下的句子,就应该既划分到前一个块,又划分到后一个块中。更新算法和md文件。
+
+---
+
+又出现了,杀Spring boot进程后,Embedding Bridge进程未杀死。请先杀掉子进程,然后检查原因。
+
+---
+