knowledge_sync.py 7.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195
  1. """知识库同步定时任务: 三源(article/microbiome/dan_knowledge)幂等同步到 ChromaDB
  2. 治理改造(2026-10-19):
  3. - article 由「只增量追加」改为「全量重建」:旧实现永不删除 article 旧块,
  4. 导致下架/删除文章残留在向量库、HNSW 索引无限膨胀(曾达 11.8GB)。
  5. - 三源 metadata 键统一(id/source/post_id/title/category/tags/source_url/pub_date/chunk_index/chunk_total),
  6. 消除旧实现 retriever.initialize 的 metadata.type=article 与同步任务 metadata.source=article 双份数据。
  7. - 显式传入 Chroma doc id(source_itemId_chunkIdx),写入幂等,可安全重试。
  8. - 每源独立 try/except,一处失败不影响其他源。
  9. """
  10. import asyncio
  11. import datetime
  12. import json
  13. import logging
  14. import os
  15. from langchain_chroma import Chroma
  16. from langchain_core.documents import Document
  17. from app.config import settings
  18. from app.rag.embeddings import get_embeddings
  19. from app.rag.loader import KnowledgeLoader
  20. from app.rag.splitter import get_knowledge_splitter
  21. logger = logging.getLogger(__name__)
  22. SYNC_STATE_FILE = os.path.join(settings.chroma_db_path, ".sync_state")
  23. BATCH_SIZE = 100 # 每批写入的块数
  24. MAX_WRITE_RETRY = 3 # 单批写入失败重试次数(配合 embedding 429 退避)
  25. def _load_sync_state() -> dict:
  26. try:
  27. if os.path.exists(SYNC_STATE_FILE):
  28. with open(SYNC_STATE_FILE) as f:
  29. return json.load(f)
  30. except Exception:
  31. pass
  32. return {"last_sync": "2000-01-01T00:00:00"}
  33. def _save_sync_state(state: dict):
  34. os.makedirs(os.path.dirname(SYNC_STATE_FILE), exist_ok=True)
  35. with open(SYNC_STATE_FILE, "w") as f:
  36. json.dump(state, f)
  37. def _delete_by_prefix(vectorstore, prefix: str):
  38. """删除指定 source 的所有旧块。
  39. 修复: 原实现 vectorstore.get() 会拉取整个 collection (数百万条 embedding)
  40. 到内存, 导致 gunicorn worker OOM 反复重启。改为按 metadata where 过滤
  41. 只取目标 source 的 ids 再删除, 内存占用与数据总量无关。
  42. """
  43. try:
  44. collection = vectorstore._collection
  45. existing = collection.get(where={"source": prefix}, include=[])
  46. ids = existing.get("ids", []) or []
  47. if ids:
  48. collection.delete(ids=ids)
  49. logger.info("已删除 source=%s 的 %d 个旧块", prefix, len(ids))
  50. else:
  51. logger.info("无 source=%s 旧块可删除", prefix)
  52. except Exception as e:
  53. logger.warning("删除 source=%s 旧块失败: %s", prefix, e)
  54. def _build_chunks(splitter, items: list[dict], source: str) -> tuple[list[Document], list[str]]:
  55. """将条目分块,返回 (chunks, doc_ids)。
  56. doc_id 形如 source_itemId_chunkIdx,作为 Chroma 文档 ID 显式传入,
  57. 保证同一知识条目的块可被幂等覆盖(而非每次生成随机 UUID 无限累积)。
  58. 兼容旧 metadata 字段映射(sourceUrl/pubDate)。
  59. """
  60. chunks: list[Document] = []
  61. ids: list[str] = []
  62. for item in items:
  63. item_id = item.get("id")
  64. title = item.get("title", "")
  65. content = item.get("content", "")
  66. if item_id is None or not content:
  67. continue
  68. # 若 content 已含 title 前缀则不重复拼接
  69. full_text = f"{title}\n\n{content}" if title and title not in content else content
  70. split_texts = splitter.split_text(full_text)
  71. for i, text in enumerate(split_texts):
  72. doc_id = f"{source}_{item_id}_{i}"
  73. metadata = {
  74. "id": doc_id,
  75. "source": source,
  76. "post_id": str(item_id),
  77. "title": title,
  78. "category": item.get("category", ""),
  79. "tags": item.get("tags", ""),
  80. "source_url": item.get("source_url", "") or item.get("sourceUrl", ""),
  81. "pub_date": item.get("pub_date", "") or item.get("pubDate", ""),
  82. "chunk_index": i,
  83. "chunk_total": len(split_texts),
  84. }
  85. chunks.append(Document(page_content=text, metadata=metadata))
  86. ids.append(doc_id)
  87. return chunks, ids
  88. async def _add_batch_with_retry(vectorstore, chunks: list, ids: list, label: str, start: int):
  89. """单批写入,失败指数退避重试(embedding 网关限流时自动恢复)。"""
  90. delay = 2.0
  91. for attempt in range(MAX_WRITE_RETRY + 1):
  92. try:
  93. await vectorstore.aadd_documents(chunks, ids=ids)
  94. return
  95. except Exception as e:
  96. if attempt == MAX_WRITE_RETRY:
  97. raise
  98. logger.warning("向量化写入失败[%s] %d-%d,%.0fs 后重试 (%d/%d): %s",
  99. label, start, start + len(chunks), delay, attempt + 1, MAX_WRITE_RETRY, e)
  100. await asyncio.sleep(delay)
  101. delay = min(delay * 2, 30.0)
  102. async def _write_chunks(vectorstore, chunks: list, ids: list, label: str):
  103. """分批写入并输出进度日志。"""
  104. total = len(chunks)
  105. if total == 0:
  106. logger.info("知识库同步[%s]: 无内容可写入", label)
  107. return
  108. for i in range(0, total, BATCH_SIZE):
  109. batch_chunks = chunks[i:i + BATCH_SIZE]
  110. batch_ids = ids[i:i + BATCH_SIZE]
  111. await _add_batch_with_retry(vectorstore, batch_chunks, batch_ids, label, i)
  112. done = min(i + BATCH_SIZE, total)
  113. logger.info("向量化写入进度[%s]: %d/%d", label, done, total)
  114. async def _sync_source(vectorstore, splitter, source: str, fetch_func) -> int:
  115. """全量重建一个 source:先删旧块,再全量拉取重建,返回写入块数。"""
  116. try:
  117. _delete_by_prefix(vectorstore, source)
  118. items = await fetch_func()
  119. chunks, ids = _build_chunks(splitter, items, source)
  120. await _write_chunks(vectorstore, chunks, ids, source)
  121. logger.info("知识库同步[%s]: %d 篇 -> %d 块", source, len(items), len(chunks))
  122. return len(chunks)
  123. except Exception as e:
  124. logger.error("知识库同步[%s] 失败: %s", source, e)
  125. return 0
  126. def _format_article_item(a: dict) -> dict:
  127. """兼容 Java 文章结构,统一映射为题名/摘要/正文/标签/时间。"""
  128. title = a.get("title", "")
  129. return {
  130. "id": a["id"],
  131. "title": title,
  132. "content": f"{a.get('summary', '')}\n\n{a.get('content', '')}".strip(),
  133. "tags": a.get("tags", ""),
  134. "pub_date": a.get("updatedAt", ""),
  135. }
  136. async def sync_knowledge_base():
  137. """同步三个数据源: article/microbiome/dan_knowledge 全量重建到 cfc_knowledge。
  138. 每源独立失败隔离;全部成功后更新 .sync_state 记录 last_sync。
  139. """
  140. loader = KnowledgeLoader()
  141. splitter = get_knowledge_splitter()
  142. embeddings = get_embeddings()
  143. vectorstore = Chroma(
  144. collection_name="cfc_knowledge",
  145. embedding_function=embeddings,
  146. persist_directory=settings.chroma_db_path,
  147. )
  148. state = _load_sync_state()
  149. stats: dict = {}
  150. async def fetch_articles():
  151. articles = await loader.load_all_articles()
  152. return [_format_article_item(a) for a in articles if a.get("id") is not None]
  153. stats["article"] = await _sync_source(vectorstore, splitter, "article", fetch_articles)
  154. stats["microbiome"] = await _sync_source(
  155. vectorstore, splitter, "microbiome", loader.java.get_microbiome_articles)
  156. stats["dan_knowledge"] = await _sync_source(
  157. vectorstore, splitter, "dan_knowledge", loader.java.get_dan_knowledge_base)
  158. if not any(stats.values()):
  159. logger.warning("知识库同步: 全部数据源失败,不更新 last_sync")
  160. return
  161. state["last_sync"] = datetime.datetime.now().isoformat()
  162. _save_sync_state(state)
  163. logger.info("知识库同步完成: 各源块数 %s", stats)