pdf2020_ingest.py 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327
  1. """
  2. 2020 年版药典 PDF 导入脚本
  3. 解析 pharmacopoeia_2020_volume1_toc.pdf → 提取药品条目 → 向量化 → 写入 PostgreSQL
  4. """
  5. import json, os, sys, re, hashlib, asyncio
  6. from pathlib import Path
  7. from collections import OrderedDict
  8. import httpx
  9. import fitz # pymupdf
  10. from sqlalchemy.ext.asyncio import create_async_engine
  11. from sqlalchemy import text
  12. # ============================================
  13. # 配置
  14. # ============================================
  15. PDF_PATH = os.environ.get("PDF_2020_PATH", os.path.join(
  16. os.path.dirname(__file__), "..", "data", "pharmacopoeia_2020_volume1_toc.pdf"))
  17. PG_HOST = os.environ.get("POSTGRES_HOST", "localhost")
  18. PG_PORT = os.environ.get("POSTGRES_PORT", "5432")
  19. PG_DB = os.environ.get("POSTGRES_DB", "pharmacopoeia")
  20. PG_USER = os.environ.get("POSTGRES_USER", "postgres")
  21. PG_PASSWORD = os.environ.get("POSTGRES_PASSWORD", "postgres")
  22. DB_URL = f"postgresql+asyncpg://{PG_USER}:{PG_PASSWORD}@{PG_HOST}:{PG_PORT}/{PG_DB}"
  23. QWEN_API_KEY = os.environ.get("QWEN_API_KEY", "")
  24. EMBEDDING_URL = "https://dashscope.aliyuncs.com/api/v1/services/embeddings/text-embedding/text-embedding"
  25. EMBEDDING_MODEL = "text-embedding-v3"
  26. BATCH_SIZE = 10
  27. # 2020 药典 section 标题识别
  28. SECTION_HEADERS = OrderedDict([
  29. ("性状", "性状"), ("鉴别", "鉴别"), ("检查", "检查"),
  30. ("含量测定", "含量测定"), ("浸出物", "浸出物"),
  31. ("性味与归经", "性味与归经"), ("性味", "性味与归经"),
  32. ("功能与主治", "功能主治"), ("功能", "功能主治"), ("主治", "功能主治"),
  33. ("用法与用量", "用法用量"), ("用法", "用法用量"), ("用量", "用法用量"),
  34. ("注意", "注意事项"), ("注意事项", "注意事项"),
  35. ("规格", "规格"), ("贮藏", "贮藏"), ("类别", "类别"),
  36. ("制剂", "制剂"), ("附注", "附注"), ("禁忌", "禁忌"),
  37. ("不良反应", "不良反应"),
  38. ("处方", "处方"), ("制法", "制法"),
  39. ("包装", "包装"), ("有效期", "有效期"),
  40. ("执行标准", "执行标准"), ("批准文号", "批准文号"),
  41. ])
  42. def load_env():
  43. env_file = Path(__file__).resolve().parent.parent / ".env"
  44. if env_file.exists():
  45. for line in open(env_file, encoding="utf-8"):
  46. line = line.strip()
  47. if line and not line.startswith("#") and "=" in line:
  48. key, _, val = line.partition("=")
  49. os.environ.setdefault(key.strip(), val.strip())
  50. def extract_drug_entries(pdf_path: str) -> list[dict]:
  51. """从 PDF 中提取药品条目"""
  52. doc = fitz.open(pdf_path)
  53. print(f"📖 PDF: {doc.page_count} 页")
  54. # 收集所有页面的文本
  55. all_text = ""
  56. for i in range(doc.page_count):
  57. text = doc[i].get_text()
  58. all_text += text + "\n"
  59. # 清理:去掉页眉页码、多余空白
  60. all_text = re.sub(r'\n{3,}', '\n\n', all_text)
  61. all_text = re.sub(r'^\s*\d+\s*$', '', all_text, flags=re.MULTILINE)
  62. # 找到正文开始位置(跳过前言/TOC)
  63. # 正文以药名开头,通常格式为 "药名" 后紧跟 "【性状】" 等 section
  64. # 扫描找到第一个药品条目
  65. lines = all_text.split('\n')
  66. # 策略:扫描【性状】标记,向前找药名
  67. entries = []
  68. current_name = None
  69. current_sections = OrderedDict()
  70. current_section = "正文"
  71. current_text = []
  72. in_entry = False
  73. for line in lines:
  74. line = line.strip()
  75. if not line:
  76. continue
  77. # 跳过纯页码和标题行
  78. if re.match(r'^\d{1,4}$', line):
  79. continue
  80. if line.startswith("中国药典") or line.startswith("ISBN"):
  81. continue
  82. if "图书在版" in line:
  83. continue
  84. # 检测 section 标题
  85. section_found = None
  86. section_content = ""
  87. # 匹配 【xxx】 格式
  88. m = re.match(r'^【(.+?)】\s*(.*)', line)
  89. if m:
  90. sec_name = m.group(1)
  91. for key, std_name in SECTION_HEADERS.items():
  92. if key in sec_name:
  93. section_found = std_name
  94. section_content = m.group(2)
  95. break
  96. if section_found:
  97. if in_entry and current_name:
  98. # 保存上一个 section
  99. if current_text:
  100. current_sections[current_section] = '\n'.join(current_text).strip()
  101. current_text = []
  102. current_section = section_found
  103. if section_content:
  104. current_text.append(section_content)
  105. elif not in_entry and current_name:
  106. # 第一个 section,标志着药品条目开始
  107. in_entry = True
  108. current_sections = OrderedDict()
  109. current_section = section_found
  110. current_text = [section_content] if section_content else []
  111. continue
  112. # 可能是药名行(短行,没有 section 标记,以中文开头)
  113. # 药名通常在 section 之前的一两行
  114. if not in_entry and re.match(r'^[一-鿿]{2,20}$', line):
  115. # 可能是新药名
  116. if current_name and current_sections:
  117. # 保存上一个药品
  118. if current_text:
  119. current_sections[current_section] = '\n'.join(current_text).strip()
  120. entries.append({
  121. "name": current_name,
  122. "sections": dict(current_sections),
  123. })
  124. current_text = []
  125. current_sections = OrderedDict()
  126. current_name = line
  127. in_entry = False
  128. current_section = "正文"
  129. current_text = []
  130. continue
  131. # 正文内容行
  132. if in_entry and current_name:
  133. current_text.append(line)
  134. # 最后一个药品
  135. if current_name and current_sections:
  136. if current_text:
  137. current_sections[current_section] = '\n'.join(current_text).strip()
  138. entries.append({
  139. "name": current_name,
  140. "sections": dict(current_sections),
  141. })
  142. doc.close()
  143. # 过滤无效条目(至少要有 2 个 section 或内容 > 100 字)
  144. valid = []
  145. for e in entries:
  146. content_len = sum(len(v) for v in e["sections"].values())
  147. if len(e["sections"]) >= 2 or content_len > 100:
  148. # 生成 drug_id
  149. hash_suffix = hashlib.md5(e["name"].encode()).hexdigest()[:6].upper()
  150. e["drug_id"] = f"Z2020-{hash_suffix}"
  151. e["category"] = "中药"
  152. e["subcategory"] = ""
  153. e["name_en"] = ""
  154. e["pinyin"] = ""
  155. e["source"] = {
  156. "version": "2020年版",
  157. "volume": "一部",
  158. "page": "",
  159. }
  160. valid.append(e)
  161. return valid
  162. async def get_embeddings(texts: list[str], text_type: str = "document") -> list[list[float]]:
  163. async with httpx.AsyncClient(timeout=60) as client:
  164. resp = await client.post(
  165. EMBEDDING_URL,
  166. headers={
  167. "Content-Type": "application/json",
  168. "Authorization": f"Bearer {QWEN_API_KEY}",
  169. },
  170. json={
  171. "model": EMBEDDING_MODEL,
  172. "input": {"texts": texts},
  173. "parameters": {"text_type": text_type},
  174. },
  175. )
  176. data = resp.json()
  177. if data.get("code") and data.get("code") != "" and data.get("code") is not None:
  178. raise RuntimeError(f"Embedding error: {data.get('message', data)}")
  179. return [item["embedding"] for item in data["output"]["embeddings"]]
  180. async def ingest_entries(entries: list[dict]):
  181. """向量化 + 入库"""
  182. engine = create_async_engine(DB_URL)
  183. chunks = []
  184. chunk_meta = []
  185. for entry in entries:
  186. source = f"{entry['source']['version']} {entry['source']['volume']}"
  187. for section_key, section_text in entry["sections"].items():
  188. if not section_text or len(section_text.strip()) < 5:
  189. continue
  190. content = f"【{entry['name']} - {section_key}】\n{section_text}\n\n来源:{source}"
  191. chunks.append(content)
  192. chunk_meta.append({
  193. "drug_id": entry["drug_id"],
  194. "section": section_key,
  195. "content": content,
  196. "source": source,
  197. })
  198. print(f" ✂️ {len(chunks)} chunks,向量化中...")
  199. all_vectors = []
  200. for i in range(0, len(chunks), BATCH_SIZE):
  201. batch = chunks[i:i + BATCH_SIZE]
  202. vecs = await get_embeddings(batch, text_type="document")
  203. all_vectors.extend(vecs)
  204. n = min(i + BATCH_SIZE, len(chunks))
  205. print(f" 向量化: {n}/{len(chunks)}")
  206. print(f" ✅ 向量化完成, 维度={len(all_vectors[0]) if all_vectors else 'N/A'}")
  207. # 写入 drug_chunks
  208. chunk_count = 0
  209. async with engine.begin() as conn:
  210. for idx, (meta, vec) in enumerate(zip(chunk_meta, all_vectors)):
  211. vec_str = f"[{','.join(str(v) for v in vec)}]"
  212. await conn.execute(
  213. text("""
  214. INSERT INTO drug_chunks (drug_id, section, content, source, chunk_index, embedding, vec)
  215. VALUES (:drug_id, :section, :content, :source, :chunk_index, :embedding, :vec)
  216. ON CONFLICT DO NOTHING
  217. """),
  218. {
  219. "drug_id": meta["drug_id"],
  220. "section": meta["section"],
  221. "content": meta["content"],
  222. "source": meta["source"],
  223. "chunk_index": idx,
  224. "embedding": json.dumps(vec),
  225. "vec": vec_str,
  226. },
  227. )
  228. chunk_count += 1
  229. # 写入 drugs 表
  230. drug_count = 0
  231. async with engine.begin() as conn:
  232. for entry in entries:
  233. await conn.execute(
  234. text("""
  235. INSERT INTO drugs (drug_id, name, name_en, pinyin, category, subcategory,
  236. sections, source_version, source_volume, source_page, is_active)
  237. VALUES (:drug_id, :name, :name_en, :pinyin, :category, :subcategory,
  238. :sections, :source_version, :source_volume, :source_page, TRUE)
  239. ON CONFLICT (drug_id) DO UPDATE SET
  240. sections = EXCLUDED.sections,
  241. updated_at = NOW()
  242. """),
  243. {
  244. "drug_id": entry["drug_id"],
  245. "name": entry["name"],
  246. "name_en": entry.get("name_en", ""),
  247. "pinyin": entry.get("pinyin", ""),
  248. "category": entry.get("category", ""),
  249. "subcategory": entry.get("subcategory", ""),
  250. "sections": json.dumps(entry.get("sections", {}), ensure_ascii=False),
  251. "source_version": entry["source"]["version"],
  252. "source_volume": entry["source"]["volume"],
  253. "source_page": entry["source"].get("page", ""),
  254. },
  255. )
  256. drug_count += 1
  257. await engine.dispose()
  258. print(f"\n🎉 2020 药典入库完成!药品 {drug_count} 个,chunk {chunk_count} 条")
  259. async def main():
  260. load_env()
  261. global QWEN_API_KEY
  262. QWEN_API_KEY = os.environ.get("QWEN_API_KEY", "")
  263. if not QWEN_API_KEY:
  264. print("❌ 未设置 QWEN_API_KEY")
  265. sys.exit(1)
  266. print("=" * 60)
  267. print("📖 解析 2020 年版药典 PDF...")
  268. entries = extract_drug_entries(PDF_PATH)
  269. print(f" 提取药品条目: {len(entries)}")
  270. print("=" * 60)
  271. if not entries:
  272. print("❌ 未提取到有效条目,请检查 PDF 解析逻辑")
  273. return
  274. # 打印前 5 个条目作为样品
  275. print("\n📋 前 5 个条目预览:")
  276. for e in entries[:5]:
  277. secs = list(e["sections"].keys())
  278. total_len = sum(len(v) for v in e["sections"].values())
  279. print(f" {e['name']} | {len(secs)} sections ({total_len} 字) | {secs}")
  280. print(f"\n🚀 开始向量化入库...")
  281. await ingest_entries(entries)
  282. if __name__ == "__main__":
  283. asyncio.run(main())