pdf2020_ingest.py 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355
  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. from processors.chunker import DrugChunker
  13. # ============================================
  14. # 配置
  15. # ============================================
  16. PDF_PATH = os.environ.get("PDF_2020_PATH", os.path.join(
  17. os.path.dirname(__file__), "..", "data", "pharmacopoeia_2020_volume1_toc.pdf"))
  18. PG_HOST = os.environ.get("POSTGRES_HOST", "localhost")
  19. PG_PORT = os.environ.get("POSTGRES_PORT", "5432")
  20. PG_DB = os.environ.get("POSTGRES_DB", "pharmacopoeia")
  21. PG_USER = os.environ.get("POSTGRES_USER", "postgres")
  22. PG_PASSWORD = os.environ.get("POSTGRES_PASSWORD", "postgres")
  23. DB_URL = f"postgresql+asyncpg://{PG_USER}:{PG_PASSWORD}@{PG_HOST}:{PG_PORT}/{PG_DB}"
  24. QWEN_API_KEY = os.environ.get("QWEN_API_KEY", "")
  25. EMBEDDING_URL = "https://dashscope.aliyuncs.com/api/v1/services/embeddings/text-embedding/text-embedding"
  26. EMBEDDING_MODEL = "text-embedding-v3"
  27. BATCH_SIZE = 10
  28. # 2020 药典 section 标题识别
  29. SECTION_HEADERS = OrderedDict([
  30. ("性状", "性状"), ("鉴别", "鉴别"), ("检查", "检查"),
  31. ("含量测定", "含量测定"), ("浸出物", "浸出物"),
  32. ("性味与归经", "性味与归经"), ("性味", "性味与归经"),
  33. ("功能与主治", "功能主治"), ("功能", "功能主治"), ("主治", "功能主治"),
  34. ("用法与用量", "用法用量"), ("用法", "用法用量"), ("用量", "用法用量"),
  35. ("注意", "注意事项"), ("注意事项", "注意事项"),
  36. ("规格", "规格"), ("贮藏", "贮藏"), ("类别", "类别"),
  37. ("制剂", "制剂"), ("附注", "附注"), ("禁忌", "禁忌"),
  38. ("不良反应", "不良反应"),
  39. ("处方", "处方"), ("制法", "制法"),
  40. ("包装", "包装"), ("有效期", "有效期"),
  41. ("执行标准", "执行标准"), ("批准文号", "批准文号"),
  42. ])
  43. def load_env():
  44. env_file = Path(__file__).resolve().parent.parent / ".env"
  45. if env_file.exists():
  46. for line in open(env_file, encoding="utf-8"):
  47. line = line.strip()
  48. if line and not line.startswith("#") and "=" in line:
  49. key, _, val = line.partition("=")
  50. os.environ.setdefault(key.strip(), val.strip())
  51. def is_cjk_name(s):
  52. """药名检测:2-10 字符,CJK 占 70% 以上,允许破折号/连接符/括号"""
  53. if not s or len(s) < 2 or len(s) > 10:
  54. return False
  55. # Skip pure digits/punctuation
  56. if re.match(r'^[\d\s\.\,\;\:\!\?\(\)\[\]\{\}<>@#$%^&*+=|\\/\"\']+$', s):
  57. return False
  58. cjk = sum(1 for c in s if ord(c) >= 0x4E00 and ord(c) <= 0x9FFF)
  59. return cjk >= 2 and cjk >= len(s) * 0.7
  60. def is_pinyin_line(s):
  61. """拼音行:纯拉丁字母空格,首字母大写"""
  62. if not s or len(s) < 3 or len(s) > 80:
  63. return False
  64. alpha_spaces = sum(1 for c in s if c.isalpha() or c.isspace())
  65. return alpha_spaces == len(s) and any(c.isalpha() for c in s) and s[0].isupper()
  66. def extract_drug_entries(pdf_path: str) -> list[dict]:
  67. """解析策略:扫描【性状】锚点,向前找药名。
  68. 药名特征:2-6 纯 CJK + 下一行是拼音(首字母大写)或全大写拉丁名。
  69. 只有通过拼音/拉丁名确认的才作为药名候选。
  70. """
  71. doc = fitz.open(pdf_path)
  72. print(f"PDF: {doc.page_count} pages")
  73. # 跳过前言(前 50 页)
  74. all_text = ""
  75. for i in range(32, doc.page_count):
  76. all_text += doc[i].get_text() + "\n"
  77. doc.close()
  78. lines = all_text.split('\n')
  79. clean_lines = []
  80. for line in lines:
  81. s = line.strip()
  82. if not s: continue
  83. if s.startswith("中国药典") and len(s) < 30: continue
  84. if "ISBN" in s or "CIP" in s: continue
  85. if re.match(r'^[・•·]\s*\d+\s*[・•·]$', s): continue
  86. clean_lines.append(s)
  87. # 第一遍:标记所有"已确认的药名"
  88. # 方式1:CJK + 下一行是拼音或拉丁名(药材和饮片)
  89. # 方式2:CJK + 下一行是【处方】或【制法】(成方制剂,无拼音)
  90. confirmed_names = set()
  91. for i in range(len(clean_lines) - 2):
  92. s = clean_lines[i]
  93. nxt = clean_lines[i + 1]
  94. if is_cjk_name(s):
  95. if is_pinyin_line(nxt) or (nxt.isupper() and len(nxt) > 5):
  96. confirmed_names.add(s)
  97. elif nxt.startswith('【处方】') or nxt.startswith('【制法】'):
  98. confirmed_names.add(s)
  99. print(f" 确认药名: {len(confirmed_names)} 个")
  100. # 第二遍:以已确认药名为锚点,提取药品条目
  101. entries = []
  102. current_drug = None
  103. current_sections = OrderedDict()
  104. current_section = "正文"
  105. current_text = []
  106. in_drug = False
  107. for s in clean_lines:
  108. # 检测 section
  109. m = re.match(r'^【(.+?)】\s*(.*)', s)
  110. if m:
  111. sec_raw = m.group(1)
  112. sec_content = m.group(2)
  113. section_found = None
  114. for key, std_name in SECTION_HEADERS.items():
  115. if key in sec_raw:
  116. section_found = std_name
  117. break
  118. if section_found and in_drug:
  119. if current_text:
  120. current_sections[current_section] = '\n'.join(current_text).strip()
  121. current_text = []
  122. current_section = section_found
  123. if sec_content:
  124. current_text.append(sec_content)
  125. continue
  126. # 检测已确认的药名
  127. if s in confirmed_names:
  128. # 保存上一个药品
  129. if in_drug and current_drug and current_sections:
  130. if current_text:
  131. current_sections[current_section] = '\n'.join(current_text).strip()
  132. entries.append({"name": current_drug, "sections": dict(current_sections)})
  133. # 开始新药品
  134. current_drug = s
  135. in_drug = True
  136. current_sections = OrderedDict()
  137. current_section = "正文"
  138. current_text = []
  139. # 描述段落(药名和【性状】之间的文本)归入"正文"
  140. continue
  141. # 跳过拼音行和拉丁名行
  142. if in_drug and not current_sections and (is_pinyin_line(s) or (s.isupper() and len(s) > 5)):
  143. continue
  144. # 正文内容
  145. if in_drug:
  146. current_text.append(s)
  147. # 最后一个
  148. if in_drug and current_drug and current_sections:
  149. if current_text:
  150. current_sections[current_section] = '\n'.join(current_text).strip()
  151. entries.append({"name": current_drug, "sections": dict(current_sections)})
  152. print(f" 初步提取: {len(entries)} 个条目")
  153. # 去重:同一药名保留内容最多的条目
  154. best = {}
  155. for e in entries:
  156. content_len = sum(len(v) for v in e["sections"].values())
  157. if len(e["sections"]) >= 1 and content_len > 50:
  158. name = e["name"]
  159. if name not in best or content_len > sum(len(v) for v in best[name]["sections"].values()):
  160. hash_suffix = hashlib.md5(name.encode()).hexdigest()[:6].upper()
  161. e["drug_id"] = f"Z2020-{hash_suffix}"
  162. e["category"] = "中药"
  163. e["subcategory"] = ""
  164. e["name_en"] = ""
  165. e["pinyin"] = ""
  166. e["source"] = {"version": "2020年版", "volume": "一部", "page": ""}
  167. best[name] = e
  168. return list(best.values())
  169. async def get_embeddings(texts: list[str], text_type: str = "document") -> list[list[float]]:
  170. async with httpx.AsyncClient(timeout=60) as client:
  171. resp = await client.post(
  172. EMBEDDING_URL,
  173. headers={
  174. "Content-Type": "application/json",
  175. "Authorization": f"Bearer {QWEN_API_KEY}",
  176. },
  177. json={
  178. "model": EMBEDDING_MODEL,
  179. "input": {"texts": texts},
  180. "parameters": {"text_type": text_type},
  181. },
  182. )
  183. data = resp.json()
  184. if data.get("code") and data.get("code") != "" and data.get("code") is not None:
  185. raise RuntimeError(f"Embedding error: {data.get('message', data)}")
  186. return [item["embedding"] for item in data["output"]["embeddings"]]
  187. async def ingest_entries(entries: list[dict]):
  188. """向量化 + 入库。长 section 用 DrugChunker 切分。"""
  189. engine = create_async_engine(DB_URL)
  190. chunker = DrugChunker(chunk_size=1500, chunk_overlap=200, min_chunk_size=200)
  191. chunks = []
  192. chunk_meta = []
  193. for entry in entries:
  194. source = f"{entry['source']['version']} {entry['source']['volume']}"
  195. drug_name = entry["name"]
  196. for section_key, section_text in entry["sections"].items():
  197. if not section_text or len(section_text.strip()) < 5:
  198. continue
  199. if len(section_text) <= 1500:
  200. content = f"【{drug_name} - {section_key}】\n{section_text}\n\n来源:{source}"
  201. chunks.append(content)
  202. chunk_meta.append({
  203. "drug_id": entry["drug_id"],
  204. "section": section_key,
  205. "content": content,
  206. "source": source,
  207. })
  208. else:
  209. source_dict = {"version": entry["source"]["version"],
  210. "volume": entry["source"]["volume"],
  211. "page": entry["source"].get("page", "")}
  212. sub_chunks = chunker._split_long_section(section_text, drug_name, section_key, source_dict)
  213. for sub in sub_chunks:
  214. full_content = sub.content + f"\n\n来源:{source}"
  215. chunks.append(full_content)
  216. chunk_meta.append({
  217. "drug_id": entry["drug_id"],
  218. "section": section_key,
  219. "content": full_content,
  220. "source": source,
  221. })
  222. print(f" {len(chunks)} chunks,向量化中...")
  223. all_vectors = []
  224. for i in range(0, len(chunks), BATCH_SIZE):
  225. batch = chunks[i:i + BATCH_SIZE]
  226. vecs = await get_embeddings(batch, text_type="document")
  227. all_vectors.extend(vecs)
  228. n = min(i + BATCH_SIZE, len(chunks))
  229. print(f" 向量化: {n}/{len(chunks)}")
  230. print(f" [OK] 向量化完成, 维度={len(all_vectors[0]) if all_vectors else 'N/A'}")
  231. # 写入 drug_chunks
  232. chunk_count = 0
  233. async with engine.begin() as conn:
  234. for idx, (meta, vec) in enumerate(zip(chunk_meta, all_vectors)):
  235. vec_str = f"[{','.join(str(v) for v in vec)}]"
  236. await conn.execute(
  237. text("""
  238. INSERT INTO drug_chunks (drug_id, section, content, source, chunk_index, embedding, vec)
  239. VALUES (:drug_id, :section, :content, :source, :chunk_index, :embedding, :vec)
  240. ON CONFLICT DO NOTHING
  241. """),
  242. {
  243. "drug_id": meta["drug_id"],
  244. "section": meta["section"],
  245. "content": meta["content"],
  246. "source": meta["source"],
  247. "chunk_index": idx,
  248. "embedding": json.dumps(vec),
  249. "vec": vec_str,
  250. },
  251. )
  252. chunk_count += 1
  253. # 写入 drugs 表
  254. drug_count = 0
  255. async with engine.begin() as conn:
  256. for entry in entries:
  257. await conn.execute(
  258. text("""
  259. INSERT INTO drugs (drug_id, name, name_en, pinyin, category, subcategory,
  260. sections, source_version, source_volume, source_page, is_active)
  261. VALUES (:drug_id, :name, :name_en, :pinyin, :category, :subcategory,
  262. :sections, :source_version, :source_volume, :source_page, TRUE)
  263. ON CONFLICT (drug_id) DO UPDATE SET
  264. sections = EXCLUDED.sections,
  265. updated_at = NOW()
  266. """),
  267. {
  268. "drug_id": entry["drug_id"],
  269. "name": entry["name"],
  270. "name_en": entry.get("name_en", ""),
  271. "pinyin": entry.get("pinyin", ""),
  272. "category": entry.get("category", ""),
  273. "subcategory": entry.get("subcategory", ""),
  274. "sections": json.dumps(entry.get("sections", {}), ensure_ascii=False),
  275. "source_version": entry["source"]["version"],
  276. "source_volume": entry["source"]["volume"],
  277. "source_page": entry["source"].get("page", ""),
  278. },
  279. )
  280. drug_count += 1
  281. await engine.dispose()
  282. print(f"\n[DONE] 2020 药典入库完成!药品 {drug_count} 个,chunk {chunk_count} 条")
  283. async def main():
  284. load_env()
  285. global QWEN_API_KEY
  286. QWEN_API_KEY = os.environ.get("QWEN_API_KEY", "")
  287. if not QWEN_API_KEY:
  288. print("❌ 未设置 QWEN_API_KEY")
  289. sys.exit(1)
  290. print("=" * 60)
  291. print("解析 2020 年版药典 PDF...")
  292. entries = extract_drug_entries(PDF_PATH)
  293. print(f" 提取药品条目: {len(entries)}")
  294. print("=" * 60)
  295. if not entries:
  296. print("❌ 未提取到有效条目,请检查 PDF 解析逻辑")
  297. return
  298. # 打印前 5 个条目作为样品
  299. print("\n前 5 个条目预览:")
  300. for e in entries[:5]:
  301. secs = list(e["sections"].keys())
  302. total_len = sum(len(v) for v in e["sections"].values())
  303. print(f" {e['name']} | {len(secs)} sections ({total_len} 字) | {secs}")
  304. print(f"\n[>] 开始向量化入库...")
  305. await ingest_entries(entries)
  306. if __name__ == "__main__":
  307. asyncio.run(main())