milvus_client.py 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362
  1. """Milvus 访问层:无状态 Store + 短生命周期 gRPC 连接(避免长期持有失效 channel)。"""
  2. from __future__ import annotations
  3. import os
  4. from contextlib import contextmanager
  5. from dataclasses import dataclass
  6. from typing import Callable, Iterator, TypeVar
  7. from pymilvus import AnnSearchRequest, DataType, MilvusClient, RRFRanker, Function, FunctionType
  8. QUERY_MAX_LIMIT = 16384
  9. T = TypeVar("T")
  10. @dataclass(frozen=True)
  11. class MilvusSettings:
  12. host: str
  13. port: str
  14. collection_name: str
  15. uri: str
  16. timeout: float
  17. @classmethod
  18. def from_env(cls) -> "MilvusSettings":
  19. host = os.getenv("MILVUS_HOST", "localhost")
  20. port = os.getenv("MILVUS_PORT", "19530")
  21. collection = os.getenv("MILVUS_COLLECTION", "kb_documents")
  22. timeout = float(os.getenv("MILVUS_TIMEOUT", "30"))
  23. return cls(
  24. host=host,
  25. port=port,
  26. collection_name=collection,
  27. uri=f"http://{host}:{port}",
  28. timeout=timeout,
  29. )
  30. @contextmanager
  31. def milvus_client_session(settings: MilvusSettings | None = None) -> Iterator[MilvusClient]:
  32. """一次 RPC 会话:创建连接,用完后关闭,不缓存 gRPC channel。"""
  33. cfg = settings or MilvusSettings.from_env()
  34. client = MilvusClient(uri=cfg.uri, timeout=cfg.timeout)
  35. try:
  36. yield client
  37. finally:
  38. client.close()
  39. def _normalize_filter(filter_expr: str) -> str:
  40. return filter_expr.strip() if filter_expr.strip() else "id >= 0"
  41. class MilvusStore:
  42. """Milvus 集合读写;本身不持有连接,所有 IO 经 milvus_client_session。"""
  43. def __init__(self, settings: MilvusSettings | None = None):
  44. self._settings = settings or MilvusSettings.from_env()
  45. @property
  46. def collection_name(self) -> str:
  47. return self._settings.collection_name
  48. def _run(self, operation: Callable[[MilvusClient], T]) -> T:
  49. with milvus_client_session(self._settings) as client:
  50. return operation(client)
  51. @contextmanager
  52. def session(self) -> Iterator[MilvusClient]:
  53. """同一业务流(如整次上传)内复用一条连接,用毕即关。"""
  54. with milvus_client_session(self._settings) as client:
  55. yield client
  56. @staticmethod
  57. def ensure_collection(client: MilvusClient, collection_name: str, dense_dim: int) -> None:
  58. if client.has_collection(collection_name):
  59. return
  60. schema = client.create_schema(auto_id=True, enable_dynamic_field=True)
  61. schema.add_field("id", DataType.INT64, is_primary=True, auto_id=True)
  62. schema.add_field("dense_embedding", DataType.FLOAT_VECTOR, dim=dense_dim)
  63. schema.add_field("sparse_embedding", DataType.SPARSE_FLOAT_VECTOR)
  64. schema.add_field(
  65. "text",
  66. DataType.VARCHAR,
  67. max_length=65535,
  68. enable_analyzer=True,
  69. analyzer_params={"type": "standard"},
  70. enable_match=True,
  71. )
  72. schema.add_field("document_id", DataType.INT64)
  73. schema.add_field("filename", DataType.VARCHAR, max_length=255)
  74. schema.add_field("file_type", DataType.VARCHAR, max_length=50)
  75. schema.add_field("file_path", DataType.VARCHAR, max_length=1024)
  76. schema.add_field("page_number", DataType.INT64)
  77. schema.add_field("chunk_idx", DataType.INT64)
  78. schema.add_field("chunk_id", DataType.VARCHAR, max_length=512)
  79. schema.add_field("parent_chunk_id", DataType.VARCHAR, max_length=512)
  80. schema.add_field("root_chunk_id", DataType.VARCHAR, max_length=512)
  81. schema.add_field("chunk_level", DataType.INT64)
  82. bm25_function = Function(
  83. name="text_bm25_emb",
  84. function_type=FunctionType.BM25,
  85. input_field_names=["text"],
  86. output_field_names=["sparse_embedding"],
  87. )
  88. schema.add_function(bm25_function)
  89. index_params = client.prepare_index_params()
  90. index_params.add_index(
  91. field_name="dense_embedding",
  92. index_type="HNSW",
  93. metric_type="IP",
  94. params={"M": 16, "efConstruction": 256},
  95. )
  96. index_params.add_index(
  97. field_name="sparse_embedding",
  98. index_type="SPARSE_INVERTED_INDEX",
  99. metric_type="BM25",
  100. params={"drop_ratio_build": 0.2},
  101. )
  102. try:
  103. client.create_collection(
  104. collection_name=collection_name,
  105. schema=schema,
  106. index_params=index_params,
  107. )
  108. except Exception as e:
  109. # Milvus Lite on Windows 在 create_index 后重命名 manifest.json 时偶发
  110. # WinError 183,但集合与索引实际已创建成功,因此若集合已存在则忽略。
  111. if client.has_collection(collection_name):
  112. return
  113. raise
  114. def init_collection(self, dense_dim: int | None = None) -> None:
  115. if dense_dim is None:
  116. dense_dim = int(os.getenv("DENSE_EMBEDDING_DIM", "1024"))
  117. def _init(client: MilvusClient) -> None:
  118. self.ensure_collection(client, self.collection_name, dense_dim)
  119. self._run(_init)
  120. def insert(self, data: list[dict]):
  121. return self._run(lambda client: client.insert(self.collection_name, data))
  122. def delete_by_document(self, document_id: int) -> int:
  123. def _delete(client: MilvusClient) -> int:
  124. result = client.delete(
  125. collection_name=self.collection_name,
  126. filter=f"document_id == {document_id}",
  127. )
  128. return getattr(result, "delete_count", len(result)) if result else 0
  129. return self._run(_delete)
  130. def delete_by_ids(self, ids: list[int | str]) -> int:
  131. if not ids:
  132. return 0
  133. def _delete(client: MilvusClient) -> int:
  134. result = client.delete(collection_name=self.collection_name, ids=ids)
  135. return getattr(result, "delete_count", len(result)) if result else 0
  136. return self._run(_delete)
  137. def query(
  138. self,
  139. filter_expr: str = "",
  140. output_fields: list[str] | None = None,
  141. limit: int = 10000,
  142. offset: int = 0,
  143. ):
  144. expr = _normalize_filter(filter_expr)
  145. fields = output_fields or ["filename", "file_type"]
  146. def _query(client: MilvusClient):
  147. return client.query(
  148. collection_name=self.collection_name,
  149. filter=expr,
  150. output_fields=fields,
  151. limit=min(limit, QUERY_MAX_LIMIT),
  152. offset=offset,
  153. )
  154. return self._run(_query)
  155. def query_all(self, filter_expr: str = "", output_fields: list[str] | None = None) -> list:
  156. """分页拉取;单次 session 内完成,避免每页新建连接。"""
  157. fields = output_fields or ["filename", "file_type"]
  158. expr = _normalize_filter(filter_expr)
  159. def _query_all(client: MilvusClient) -> list:
  160. out: list = []
  161. offset = 0
  162. while True:
  163. batch = client.query(
  164. collection_name=self.collection_name,
  165. filter=expr,
  166. output_fields=fields,
  167. limit=QUERY_MAX_LIMIT,
  168. offset=offset,
  169. )
  170. if not batch:
  171. break
  172. out.extend(batch)
  173. if len(batch) < QUERY_MAX_LIMIT:
  174. break
  175. offset += len(batch)
  176. return out
  177. return self._run(_query_all)
  178. def get_chunks_by_ids(self, chunk_ids: list[str]) -> list[dict]:
  179. ids = [item for item in chunk_ids if item]
  180. if not ids:
  181. return []
  182. quoted_ids = ", ".join(f'"{item}"' for item in ids)
  183. return self.query(
  184. filter_expr=f"chunk_id in [{quoted_ids}]",
  185. output_fields=[
  186. "text",
  187. "filename",
  188. "file_type",
  189. "page_number",
  190. "chunk_id",
  191. "parent_chunk_id",
  192. "root_chunk_id",
  193. "chunk_level",
  194. "chunk_idx",
  195. ],
  196. limit=len(ids),
  197. )
  198. def hybrid_retrieve(
  199. self,
  200. dense_embedding: list[float],
  201. query: str,
  202. top_k: int = 5,
  203. rrf_k: int = 60,
  204. filter_expr: str = "",
  205. ) -> list[dict]:
  206. output_fields = [
  207. "text",
  208. "filename",
  209. "file_type",
  210. "page_number",
  211. "chunk_id",
  212. "parent_chunk_id",
  213. "root_chunk_id",
  214. "chunk_level",
  215. "chunk_idx",
  216. ]
  217. dense_search = AnnSearchRequest(
  218. data=[dense_embedding],
  219. anns_field="dense_embedding",
  220. param={"metric_type": "IP", "params": {"ef": 64}},
  221. limit=top_k * 2,
  222. expr=filter_expr,
  223. )
  224. sparse_search = AnnSearchRequest(
  225. data=[query],
  226. anns_field="sparse_embedding",
  227. param={"metric_type": "BM25", "params": {"drop_ratio_search": 0.2}},
  228. limit=top_k * 2,
  229. expr=filter_expr,
  230. )
  231. reranker = RRFRanker(k=rrf_k)
  232. def _search(client: MilvusClient):
  233. return client.hybrid_search(
  234. collection_name=self.collection_name,
  235. reqs=[dense_search, sparse_search],
  236. ranker=reranker,
  237. limit=top_k,
  238. output_fields=output_fields,
  239. )
  240. results = self._run(_search)
  241. formatted_results = []
  242. for hits in results:
  243. for hit in hits:
  244. formatted_results.append({
  245. "id": hit.get("id"),
  246. "text": hit.get("text", ""),
  247. "filename": hit.get("filename", ""),
  248. "file_type": hit.get("file_type", ""),
  249. "page_number": hit.get("page_number", 0),
  250. "chunk_id": hit.get("chunk_id", ""),
  251. "parent_chunk_id": hit.get("parent_chunk_id", ""),
  252. "root_chunk_id": hit.get("root_chunk_id", ""),
  253. "chunk_level": hit.get("chunk_level", 0),
  254. "chunk_idx": hit.get("chunk_idx", 0),
  255. "score": hit.get("distance", 0.0),
  256. })
  257. return formatted_results
  258. def dense_retrieve(
  259. self,
  260. dense_embedding: list[float],
  261. top_k: int = 5,
  262. filter_expr: str = "",
  263. ) -> list[dict]:
  264. def _search(client: MilvusClient):
  265. return client.search(
  266. collection_name=self.collection_name,
  267. data=[dense_embedding],
  268. anns_field="dense_embedding",
  269. search_params={"metric_type": "IP", "params": {"ef": 64}},
  270. limit=top_k,
  271. output_fields=[
  272. "text",
  273. "filename",
  274. "file_type",
  275. "page_number",
  276. "chunk_id",
  277. "parent_chunk_id",
  278. "root_chunk_id",
  279. "chunk_level",
  280. "chunk_idx",
  281. ],
  282. filter=filter_expr,
  283. )
  284. results = self._run(_search)
  285. formatted_results = []
  286. for hits in results:
  287. for hit in hits:
  288. formatted_results.append({
  289. "id": hit.get("id"),
  290. "text": hit.get("entity", {}).get("text", ""),
  291. "filename": hit.get("entity", {}).get("filename", ""),
  292. "file_type": hit.get("entity", {}).get("file_type", ""),
  293. "page_number": hit.get("entity", {}).get("page_number", 0),
  294. "chunk_id": hit.get("entity", {}).get("chunk_id", ""),
  295. "parent_chunk_id": hit.get("entity", {}).get("parent_chunk_id", ""),
  296. "root_chunk_id": hit.get("entity", {}).get("root_chunk_id", ""),
  297. "chunk_level": hit.get("entity", {}).get("chunk_level", 0),
  298. "chunk_idx": hit.get("entity", {}).get("chunk_idx", 0),
  299. "score": hit.get("distance", 0.0),
  300. })
  301. return formatted_results
  302. def drop_collection(self) -> None:
  303. def _drop(client: MilvusClient) -> None:
  304. if client.has_collection(self.collection_name):
  305. client.drop_collection(self.collection_name)
  306. self._run(_drop)
  307. # 兼容旧名;全项目共用同一无状态 Store 实例即可(不缓存连接)
  308. MilvusManager = MilvusStore
  309. _store: MilvusStore | None = None
  310. def get_milvus_store() -> MilvusStore:
  311. global _store
  312. if _store is None:
  313. _store = MilvusStore()
  314. return _store