service.py 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395
  1. """知识库应用服务。
  2. 它把管理端的“上传 → 索引 → 检索测试 → 发布”串成完整流程,并把
  3. Milvus 语义检索、BM25 关键词检索和 BGE 重排封装在一个稳定入口中。
  4. Agent 只能检索已经发布的知识,草稿和仅完成索引的文档不会进入正式回答。
  5. """
  6. import logging
  7. from collections.abc import Callable
  8. from dataclasses import replace
  9. from datetime import datetime
  10. from typing import Any
  11. from zbt.core.errors import AppError
  12. from zbt.core.identifiers import new_ulid
  13. from zbt.domains.knowledge.chunking import DocumentChunker
  14. from zbt.domains.knowledge.index import KnowledgeIndex
  15. from zbt.domains.knowledge.models import (
  16. KnowledgeChunk,
  17. KnowledgeDocument,
  18. KnowledgeSearchTest,
  19. )
  20. from zbt.domains.knowledge.repository import KnowledgeRepository
  21. from zbt.domains.knowledge.retrieval import (
  22. KnowledgeReranker,
  23. rank_bm25,
  24. reciprocal_rank_fusion,
  25. )
  26. logger = logging.getLogger(__name__)
  27. class KnowledgeService:
  28. """协调知识文档状态、索引和检索的应用服务。"""
  29. def __init__(
  30. self,
  31. repository: KnowledgeRepository,
  32. clock: Callable[[], datetime],
  33. index: KnowledgeIndex | None = None,
  34. chunker: DocumentChunker | None = None,
  35. reranker: KnowledgeReranker | None = None,
  36. candidate_multiplier: int = 4,
  37. ) -> None:
  38. self._repository = repository
  39. self._clock = clock
  40. self._index = index
  41. self._chunker = chunker or DocumentChunker()
  42. self._reranker = reranker
  43. self._candidate_multiplier = max(candidate_multiplier, 1)
  44. def create_document(
  45. self,
  46. *,
  47. title: str,
  48. document_type: str,
  49. source_name: str,
  50. product_code: str | None,
  51. content: str,
  52. created_by: str,
  53. ) -> dict[str, Any]:
  54. """创建知识草稿。
  55. 这里只把原始文档保存到 MySQL;文档尚未切分,也不会被 Agent 检索到。
  56. """
  57. now = self._clock()
  58. document = KnowledgeDocument(
  59. id=new_ulid(),
  60. title=title.strip(),
  61. document_type=document_type,
  62. source_name=source_name.strip(),
  63. product_code=product_code.strip() if product_code else None,
  64. content=content.strip(),
  65. status="DRAFT",
  66. version_no=1,
  67. chunk_count=0,
  68. created_by=created_by,
  69. created_at=now,
  70. updated_at=now,
  71. )
  72. self._repository.save_document(document)
  73. return self._serialize_document(document)
  74. def list_documents(self) -> dict[str, Any]:
  75. documents = sorted(
  76. self._repository.list_documents(),
  77. key=lambda item: item.created_at,
  78. reverse=True,
  79. )
  80. items = [self._serialize_document(document) for document in documents]
  81. return {"items": items, "total": len(items)}
  82. def index_document(self, document_id: str) -> dict[str, Any]:
  83. """切分文档并替换其在 Milvus 中的全部向量片段。
  84. 使用“整篇替换”可以避免文档重新索引后残留旧版本片段。
  85. """
  86. document = self._repository.get_document(document_id)
  87. if document is None:
  88. raise AppError("KNOWLEDGE_DOCUMENT_NOT_FOUND", "未找到知识文档", 404)
  89. if self._index is None:
  90. raise AppError(
  91. "KNOWLEDGE_INDEX_UNAVAILABLE",
  92. "知识索引服务尚未就绪",
  93. 503,
  94. retryable=True,
  95. )
  96. chunks = self._split_document(document)
  97. self._index.replace_document(document.id, chunks)
  98. now = self._clock()
  99. indexed = replace(
  100. document,
  101. status="INDEXED",
  102. chunk_count=len(chunks),
  103. indexed_at=now,
  104. published_at=None,
  105. updated_at=now,
  106. )
  107. self._repository.save_document(indexed)
  108. return self._serialize_document(indexed)
  109. def publish_document(self, document_id: str) -> dict[str, Any]:
  110. """发布已经验证过的知识。
  111. 发布前必须存在本次索引之后产生的有效检索测试记录,防止“写入成功但
  112. 实际搜不到”的知识直接被 Agent 使用。
  113. """
  114. document = self._repository.get_document(document_id)
  115. if document is None:
  116. raise AppError("KNOWLEDGE_DOCUMENT_NOT_FOUND", "未找到知识文档", 404)
  117. if document.status != "INDEXED":
  118. raise AppError(
  119. "KNOWLEDGE_DOCUMENT_NOT_INDEXED",
  120. "知识文档完成索引和检索测试后才能发布",
  121. 409,
  122. )
  123. effective_tests = [
  124. search_test
  125. for search_test in self._repository.list_search_tests(document_id)
  126. if search_test.passed
  127. and (
  128. document.indexed_at is None
  129. or search_test.created_at >= document.indexed_at
  130. )
  131. ]
  132. if not effective_tests:
  133. raise AppError(
  134. "KNOWLEDGE_SEARCH_TEST_REQUIRED",
  135. "知识文档至少完成一次有结果的检索测试后才能发布",
  136. 409,
  137. )
  138. now = self._clock()
  139. published = replace(
  140. document,
  141. status="PUBLISHED",
  142. published_at=now,
  143. updated_at=now,
  144. )
  145. self._repository.save_document(published)
  146. return self._serialize_document(published)
  147. def disable_document(self, document_id: str) -> dict[str, Any]:
  148. document = self._repository.get_document(document_id)
  149. if document is None:
  150. raise AppError("KNOWLEDGE_DOCUMENT_NOT_FOUND", "未找到知识文档", 404)
  151. if document.status == "INACTIVE":
  152. return self._serialize_document(document)
  153. if document.status != "PUBLISHED":
  154. raise AppError(
  155. "KNOWLEDGE_DOCUMENT_NOT_PUBLISHED",
  156. "只有已发布知识才能停用",
  157. 409,
  158. )
  159. now = self._clock()
  160. disabled = replace(document, status="INACTIVE", updated_at=now)
  161. self._repository.save_document(disabled)
  162. return self._serialize_document(disabled)
  163. def search(
  164. self,
  165. query: str,
  166. *,
  167. limit: int = 5,
  168. product_code: str | None = None,
  169. document_types: tuple[str, ...] = (),
  170. ) -> dict[str, Any]:
  171. """检索所有符合过滤条件的已发布文档。
  172. 这是 Agent 工具实际调用的入口,状态过滤在服务端完成,模型无法绕过。
  173. """
  174. if self._index is None:
  175. raise AppError(
  176. "KNOWLEDGE_INDEX_UNAVAILABLE",
  177. "知识索引服务尚未就绪",
  178. 503,
  179. retryable=True,
  180. )
  181. published_documents = [
  182. document
  183. for document in self._repository.list_documents()
  184. if document.status == "PUBLISHED"
  185. and (product_code is None or document.product_code == product_code)
  186. and (
  187. not document_types
  188. or document.document_type in set(document_types)
  189. )
  190. ]
  191. return self._search_documents(query, published_documents, limit=limit)
  192. def test_search(
  193. self,
  194. document_id: str,
  195. query: str,
  196. *,
  197. limit: int = 5,
  198. tested_by: str = "SYSTEM",
  199. ) -> dict[str, Any]:
  200. """只针对指定文档执行检索,并持久化本次测试结果。
  201. 该记录既供管理端查看,也是 ``publish_document`` 的发布门禁依据。
  202. """
  203. document = self._repository.get_document(document_id)
  204. if document is None:
  205. raise AppError("KNOWLEDGE_DOCUMENT_NOT_FOUND", "未找到知识文档", 404)
  206. if document.status not in {"INDEXED", "PUBLISHED"}:
  207. raise AppError(
  208. "KNOWLEDGE_DOCUMENT_NOT_INDEXED",
  209. "知识文档完成索引后才能测试检索",
  210. 409,
  211. )
  212. result = self._search_documents(query, [document], limit=limit)
  213. now = self._clock()
  214. search_test = KnowledgeSearchTest(
  215. id=new_ulid(),
  216. document_id=document_id,
  217. query=query.strip(),
  218. hit_count=int(result["total"]),
  219. top_score=(
  220. float(result["items"][0]["score"]) if result["items"] else None
  221. ),
  222. passed=bool(result["items"]),
  223. tested_by=tested_by,
  224. created_at=now,
  225. )
  226. self._repository.save_search_test(search_test)
  227. return {
  228. "document_id": document_id,
  229. "test_record": self._serialize_search_test(search_test),
  230. **result,
  231. }
  232. def list_search_tests(self, document_id: str) -> dict[str, Any]:
  233. if self._repository.get_document(document_id) is None:
  234. raise AppError("KNOWLEDGE_DOCUMENT_NOT_FOUND", "未找到知识文档", 404)
  235. search_tests = sorted(
  236. self._repository.list_search_tests(document_id),
  237. key=lambda item: item.created_at,
  238. reverse=True,
  239. )
  240. items = [self._serialize_search_test(item) for item in search_tests]
  241. return {"document_id": document_id, "items": items, "total": len(items)}
  242. def _search_documents(
  243. self,
  244. query: str,
  245. documents: list[KnowledgeDocument],
  246. *,
  247. limit: int,
  248. ) -> dict[str, Any]:
  249. """执行混合检索主链路:向量召回 → BM25 召回 → RRF → 可选重排。"""
  250. if self._index is None:
  251. raise AppError(
  252. "KNOWLEDGE_INDEX_UNAVAILABLE",
  253. "知识索引服务尚未就绪",
  254. 503,
  255. retryable=True,
  256. )
  257. document_ids = {document.id for document in documents}
  258. candidate_limit = limit * self._candidate_multiplier
  259. # 候选数量先放大,给融合和重排阶段留下足够的选择空间。
  260. dense_hits = self._index.search(
  261. query.strip(),
  262. document_ids=document_ids,
  263. limit=candidate_limit,
  264. )
  265. lexical_hits = rank_bm25(
  266. query,
  267. (
  268. chunk
  269. for document in documents
  270. for chunk in self._split_document(document)
  271. ),
  272. limit=candidate_limit,
  273. )
  274. candidates = reciprocal_rank_fusion(
  275. [dense_hits, lexical_hits],
  276. limit=candidate_limit,
  277. )
  278. hits = candidates[:limit]
  279. if self._reranker is not None:
  280. try:
  281. hits = self._reranker.rerank(query, candidates, limit=limit)
  282. except Exception:
  283. # 语义重排只用于提升排序效果,不应成为知识问答的单点故障。
  284. # 失败时继续使用 Milvus + BM25 的融合结果,并保留完整异常日志。
  285. logger.exception(
  286. "Knowledge reranker failed; falling back to fused retrieval results"
  287. )
  288. items = [
  289. {
  290. "chunk_id": hit.chunk_id,
  291. "document_id": hit.document_id,
  292. "document_version": hit.document_version,
  293. "ordinal": hit.ordinal,
  294. "title": hit.title,
  295. "content": hit.content,
  296. "document_type": hit.document_type,
  297. "source_name": hit.source_name,
  298. "product_code": hit.product_code,
  299. "score": hit.score,
  300. }
  301. for hit in hits
  302. ]
  303. return {"items": items, "total": len(items)}
  304. def _split_document(self, document: KnowledgeDocument) -> list[KnowledgeChunk]:
  305. """把文档切成带来源、版本和顺序号的可追溯知识片段。"""
  306. paragraphs = self._chunker.split(document.content)
  307. if not paragraphs:
  308. raise AppError("KNOWLEDGE_DOCUMENT_EMPTY", "知识文档没有可索引内容", 422)
  309. return [
  310. KnowledgeChunk(
  311. id=f"{document.id}:{document.version_no}:{ordinal}",
  312. document_id=document.id,
  313. document_version=document.version_no,
  314. ordinal=ordinal,
  315. title=document.title,
  316. content=content,
  317. document_type=document.document_type,
  318. source_name=document.source_name,
  319. product_code=document.product_code,
  320. )
  321. for ordinal, content in enumerate(paragraphs, start=1)
  322. ]
  323. def _serialize_document(self, document: KnowledgeDocument) -> dict[str, Any]:
  324. search_tests = sorted(
  325. self._repository.list_search_tests(document.id),
  326. key=lambda item: item.created_at,
  327. reverse=True,
  328. )
  329. latest_test = search_tests[0] if search_tests else None
  330. return {
  331. "document_id": document.id,
  332. "title": document.title,
  333. "document_type": document.document_type,
  334. "source_name": document.source_name,
  335. "product_code": document.product_code,
  336. "status": document.status,
  337. "version_no": document.version_no,
  338. "chunk_count": document.chunk_count,
  339. "created_by": document.created_by,
  340. "created_at": document.created_at,
  341. "updated_at": document.updated_at,
  342. "indexed_at": document.indexed_at,
  343. "published_at": document.published_at,
  344. "search_test_count": len(search_tests),
  345. "last_search_test_at": (
  346. latest_test.created_at if latest_test is not None else None
  347. ),
  348. "last_search_test_passed": (
  349. latest_test.passed if latest_test is not None else None
  350. ),
  351. "excerpt": document.content[:160],
  352. }
  353. @staticmethod
  354. def _serialize_search_test(search_test: KnowledgeSearchTest) -> dict[str, Any]:
  355. return {
  356. "test_id": search_test.id,
  357. "document_id": search_test.document_id,
  358. "query": search_test.query,
  359. "hit_count": search_test.hit_count,
  360. "top_score": search_test.top_score,
  361. "passed": search_test.passed,
  362. "tested_by": search_test.tested_by,
  363. "created_at": search_test.created_at,
  364. }