#!/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] class RetrieveRequest(BaseModel): query: str = Field(min_length=1) top_k: int = Field(default=5, ge=1, le=100) mode: str = "hybrid" filter_expr: str = "" class EmbedRequest(BaseModel): texts: List[str] = Field(min_length=1, max_length=500) # --------------------------------------------------------------------------- # 预导入含 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("/embed") def embed(req: EmbedRequest): try: from backend.indexing.embedding import embedding_service return {"vectors": embedding_service.get_embeddings(req.texts)} except Exception as exc: raise HTTPException(status_code=500, detail=f"embedding failed: {exc}") from exc # --------------------------------------------------------------------------- # 文档分块 + 向量化 # --------------------------------------------------------------------------- @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 c["category_id"] = req.category_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}") # --------------------------------------------------------------------------- # 主入口 # --------------------------------------------------------------------------- @app.post("/retrieve") def retrieve(req: RetrieveRequest): try: from backend.indexing.embedding import embedding_service from backend.indexing.milvus_client import get_milvus_store mode = req.mode.strip().lower() if mode not in ("dense", "hybrid"): raise HTTPException(status_code=400, detail="mode must be dense or hybrid") dense = embedding_service.get_embeddings([req.query])[0] store = get_milvus_store() if mode == "dense": hits = store.dense_retrieve(dense, req.top_k, req.filter_expr) else: hits = store.hybrid_retrieve(dense, req.query, req.top_k, filter_expr=req.filter_expr) return {"mode": mode, "top_k": req.top_k, "hits": hits} except HTTPException: raise except Exception as e: logger.error("[retrieve] failed: %s", e, exc_info=True) raise HTTPException(status_code=500, detail=f"retrieval failed: {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")