diagnose.py 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452
  1. # -*- coding: utf-8 -*-
  2. """
  3. Diagnostic script: Analyze why "penicillin allergy" query returns info about Qingkailing injection.
  4. Traces the full RAG pipeline:
  5. User query -> Intent classification -> Vector retrieval -> Reranking -> LLM generation
  6. """
  7. import sys
  8. import os
  9. import json
  10. import re
  11. import asyncio
  12. import argparse
  13. from pathlib import Path
  14. sys.path.insert(0, str(Path(__file__).resolve().parent / "backend-python"))
  15. sys.path.insert(0, str(Path(__file__).resolve().parent / "data-pipeline"))
  16. def classify_intent(query: str) -> str:
  17. q = query.strip()
  18. negation_patterns = [
  19. r"不是", r"没有", r"并非", r"不算", r"不属于",
  20. r"这不是", r"我没有", r"不包含", r"不涉及",
  21. ]
  22. has_negation = any(re.search(pat, q) for pat in negation_patterns)
  23. if has_negation:
  24. return "drug_query"
  25. usage_keywords = [
  26. "怎么吃", "吃多少", "怎么用", "一天几次", "多长时间",
  27. "能一起吃", "孕妇能用", "儿童用量", "哺乳期",
  28. "饭前还是饭后", "空腹", "过量", "漏服", "停药",
  29. "副作用多大", "伤肝吗", "伤肾吗", "安全吗",
  30. ]
  31. safety_sections = [
  32. "副作用", "不良反应", "禁忌", "注意事项",
  33. "能不能", "可以吗", "会不会",
  34. ]
  35. regulation_keywords = [
  36. "凡例", "通则规定", "制剂通则",
  37. "一般规定", "通用技术要求", "检验方法通则",
  38. ]
  39. exam_keywords = [
  40. "执业药师考试", "考点", "历年真题", "考试大纲",
  41. "高频考点", "报名时间",
  42. ]
  43. symptom_keywords = [
  44. "吃了什么药", "吃什么药", "该吃", "推荐用药", "推荐下用药",
  45. "买什么药", "推荐什么药", "用什么药", "用药建议",
  46. "发烧", "咳嗽", "感冒", "腹泻", "头疼", "头痛",
  47. "嗓子疼", "流鼻涕", "鼻塞", "肚子疼", "胃疼",
  48. "过敏", "皮肤痒", "失眠", "便秘", "牙疼",
  49. "体温", "多少度", "退烧", "止痛", "止泻",
  50. ]
  51. if any(kw in q for kw in usage_keywords):
  52. return "usage_guide"
  53. if any(kw in q for kw in safety_sections):
  54. return "usage_guide"
  55. if any(kw in q for kw in symptom_keywords):
  56. return "symptom_advice"
  57. if any(kw in q for kw in regulation_keywords):
  58. return "regulation"
  59. if any(kw in q for kw in exam_keywords):
  60. return "exam_tutor"
  61. return "drug_query"
  62. def tokenize_query(query: str) -> set:
  63. tokens = set()
  64. for n in range(2, 5):
  65. for i in range(len(query) - n + 1):
  66. seg = query[i:i + n]
  67. if all('一' <= c <= '鿿' for c in seg):
  68. tokens.add(seg)
  69. for word in re.findall(r'[a-zA-Z0-9]+', query):
  70. tokens.add(word.lower())
  71. return tokens
  72. def keyword_overlap_score(query_terms: set, content: str) -> float:
  73. if not query_terms:
  74. return 0.0
  75. matched = sum(1 for t in query_terms if t in content)
  76. return matched / len(query_terms)
  77. def analyze_data_files():
  78. data_dir = Path(__file__).resolve().parent / "data-pipeline" / "data"
  79. results = {
  80. "penicillin_drugs": [],
  81. "qingkailing_drugs": [],
  82. "penicillin_allergy_mentions": [],
  83. "allergy_related_drugs": [],
  84. "all_drugs_with_allergy_sections": [],
  85. }
  86. for json_file in sorted(data_dir.glob("*.json")):
  87. try:
  88. with open(json_file, "r", encoding="utf-8") as f:
  89. data = json.load(f)
  90. except Exception:
  91. continue
  92. if not isinstance(data, list):
  93. continue
  94. for item in data:
  95. if not isinstance(item, dict):
  96. continue
  97. name = item.get("name", "")
  98. sections = item.get("sections", {})
  99. if "青霉素" in name:
  100. results["penicillin_drugs"].append({
  101. "file": json_file.name, "name": name,
  102. "sections": list(sections.keys()),
  103. "allergy_info": any("过敏" in str(v) for v in sections.values()),
  104. })
  105. if "清开灵" in name:
  106. results["qingkailing_drugs"].append({
  107. "file": json_file.name, "name": name,
  108. "sections": list(sections.keys()),
  109. "allergy_info": any("过敏" in str(v) for v in sections.values()),
  110. })
  111. for sk, sv in sections.items():
  112. if "青霉素" in str(sv) and "过敏" in str(sv):
  113. results["penicillin_allergy_mentions"].append({
  114. "file": json_file.name, "name": name, "section": sk,
  115. })
  116. for sk, sv in sections.items():
  117. if sk in ("禁忌", "不良反应", "注意事项") and "过敏" in str(sv):
  118. if name not in [x["name"] for x in results["all_drugs_with_allergy_sections"]]:
  119. results["all_drugs_with_allergy_sections"].append({
  120. "file": json_file.name, "name": name,
  121. })
  122. return results
  123. def analyze_chunk_examples():
  124. """Simulate what chunks look like in the database for key drugs."""
  125. data_dir = Path(__file__).resolve().parent / "data-pipeline" / "data"
  126. examples = []
  127. for json_file in sorted(data_dir.glob("*.json")):
  128. try:
  129. with open(json_file, "r", encoding="utf-8") as f:
  130. data = json.load(f)
  131. except Exception:
  132. continue
  133. if not isinstance(data, list):
  134. continue
  135. for item in data:
  136. if not isinstance(item, dict):
  137. continue
  138. name = item.get("name", "")
  139. if "青霉素" in name or "清开灵" in name:
  140. sections = item.get("sections", {})
  141. source = item.get("source", {})
  142. source_str = f"{source.get('version', '')} {source.get('volume', '')}"
  143. for sk, sv in sections.items():
  144. if not sv or len(sv.strip()) < 10:
  145. continue
  146. chunk = f"[{name} - {sk}]\n{sv}\n\nSource: {source_str}"
  147. examples.append({
  148. "drug": name, "section": sk,
  149. "chunk_preview": chunk[:300] + "..." if len(chunk) > 300 else chunk,
  150. })
  151. return examples
  152. async def get_embedding(query: str) -> list:
  153. """Call DashScope text-embedding-v3 API to get embedding vector."""
  154. import httpx
  155. api_key = os.environ.get("QWEN_API_KEY", "")
  156. if not api_key:
  157. env_file = Path(__file__).resolve().parent / ".env"
  158. if env_file.exists():
  159. for line in open(env_file, encoding="utf-8"):
  160. line = line.strip()
  161. if line and not line.startswith("#") and "=" in line:
  162. key, _, val = line.partition("=")
  163. os.environ.setdefault(key.strip(), val.strip())
  164. api_key = os.environ.get("QWEN_API_KEY", "")
  165. if not api_key:
  166. return []
  167. EMBEDDING_URL = "https://dashscope.aliyuncs.com/api/v1/services/embeddings/text-embedding/text-embedding"
  168. async with httpx.AsyncClient(timeout=30) as client:
  169. resp = await client.post(
  170. EMBEDDING_URL,
  171. headers={
  172. "Content-Type": "application/json",
  173. "Authorization": f"Bearer {api_key}",
  174. },
  175. json={
  176. "model": "text-embedding-v3",
  177. "input": {"texts": [query]},
  178. "parameters": {"text_type": "query"},
  179. },
  180. )
  181. data = resp.json()
  182. if data.get("code") and data.get("code") != "":
  183. raise RuntimeError(f"Embedding error: {data.get('message')}")
  184. return data["output"]["embeddings"][0]["embedding"]
  185. async def analyze_embedding_space():
  186. queries = [
  187. "青霉素过敏处理",
  188. "青霉素过敏",
  189. "过敏反应处理",
  190. "清开灵注射液不良反应",
  191. "清开灵注射液禁忌",
  192. "清开灵注射液过敏",
  193. "阿莫西林过敏",
  194. "头孢克肟过敏",
  195. ]
  196. print("\n" + "=" * 70)
  197. print("[Embedding Space Analysis]")
  198. print("=" * 70)
  199. api_key = os.environ.get("QWEN_API_KEY", "")
  200. if not api_key:
  201. env_file = Path(__file__).resolve().parent / ".env"
  202. if env_file.exists():
  203. for line in open(env_file, encoding="utf-8"):
  204. line = line.strip()
  205. if line and not line.startswith("#") and "=" in line:
  206. key, _, val = line.partition("=")
  207. os.environ.setdefault(key.strip(), val.strip())
  208. api_key = os.environ.get("QWEN_API_KEY", "")
  209. if not api_key:
  210. print(" SKIP: QWEN_API_KEY not set")
  211. return {}
  212. import math
  213. embeddings = {}
  214. for q in queries:
  215. try:
  216. vec = await get_embedding(q)
  217. embeddings[q] = vec
  218. print(f" OK: {q} (dim={len(vec)})")
  219. except Exception as e:
  220. print(f" FAIL: {q}: {e}")
  221. if not embeddings:
  222. return {}
  223. def cosine_sim(a, b):
  224. dot = sum(x * y for x, y in zip(a, b))
  225. na = math.sqrt(sum(x * x for x in a))
  226. nb = math.sqrt(sum(y * y for y in b))
  227. return dot / (na * nb) if na and nb else 0
  228. print("\nCosine Similarity Matrix:")
  229. header = [q[:10] for q in queries if q in embeddings]
  230. print(f"{'':22s}", end="")
  231. for h in header:
  232. print(f"{h:>10s}", end="")
  233. print()
  234. for q1 in queries:
  235. if q1 not in embeddings:
  236. continue
  237. print(f"{q1[:22]:22s}", end="")
  238. for q2 in queries:
  239. if q2 not in embeddings:
  240. continue
  241. sim = cosine_sim(embeddings[q1], embeddings[q2])
  242. print(f"{sim:10.4f}", end="")
  243. print()
  244. target = "青霉素过敏处理"
  245. if target in embeddings:
  246. print(f"\nSimilarity from '{target}' to others:")
  247. others = sorted(
  248. [(q, cosine_sim(embeddings[target], embeddings[q]))
  249. for q in embeddings if q != target],
  250. key=lambda x: -x[1]
  251. )
  252. for q, sim in others:
  253. bar = "=" * int(sim * 30)
  254. print(f" {q[:30]:30s}: {sim:.4f} {bar}")
  255. return embeddings
  256. def main():
  257. print("=" * 70)
  258. print("DIAGNOSIS: Why 'penicillin allergy' returns Qingkailing injection info")
  259. print("=" * 70)
  260. # === Step 1: Intent Classification ===
  261. query = "青霉素过敏处理"
  262. intent = classify_intent(query)
  263. print(f"\n[Step 1] Intent Classification:")
  264. print(f" Query: '{query}'")
  265. print(f" Intent: '{intent}'")
  266. print(f" Matched keyword '过敏' in symptom_keywords")
  267. print(f" >> PROBLEM: Query about managing a known allergy is routed to")
  268. print(f" 'symptom_advice' (symptom-based drug recommendation).")
  269. print(f" This tells LLM to recommend drugs, not manage an allergy.")
  270. # Also test alternative queries
  271. alt_queries = ["青霉素过敏了怎么办", "青霉素过敏急救", "对青霉素过敏", "青霉素过敏替代药"]
  272. print(f"\n Alternative query intents:")
  273. for q in alt_queries:
  274. print(f" '{q}' -> '{classify_intent(q)}'")
  275. # === Step 2: Query Tokenization (for reranker) ===
  276. tokens = tokenize_query(query)
  277. print(f"\n[Step 2] Reranker Tokenization:")
  278. print(f" Tokens ({len(tokens)}): {sorted(tokens)}")
  279. print(f" >> PROBLEM: '过敏' is one of the most common terms in drug")
  280. print(f" adverse reaction sections. Any chunk containing '过敏'")
  281. print(f" gets a keyword score boost in reranking.")
  282. print(f" '青霉素' (2-gram) only appears in penicillin-related chunks.")
  283. # === Step 3: Data file analysis ===
  284. print(f"\n[Step 3] Data File Analysis:")
  285. data_results = analyze_data_files()
  286. for category, items in data_results.items():
  287. if items:
  288. print(f"\n [{category}] ({len(items)} entries):")
  289. for item in items[:15]:
  290. if isinstance(item, dict):
  291. name = item.get("name", "?")
  292. fname = item.get("file", "?")
  293. print(f" - {name} (in {fname})", end="")
  294. if "section" in item:
  295. print(f" [section: {item['section']}]")
  296. elif "sections" in item:
  297. print(f" [sections: {item.get('sections', [])}]")
  298. else:
  299. print()
  300. if not data_results.get("qingkailing_drugs"):
  301. print(f"\n >> KEY FINDING: No Qingkailing injection content in local data files!")
  302. print(f" Qingkailing only appears in drug_index.json (catalog index).")
  303. print(f" If the production API returns Qingkailing info, it means:")
  304. print(f" 1) Full pharmacopoeia DOCX data (2280+ entries) was ingested")
  305. print(f" 2) Qingkailing chunks were matched by vector similarity")
  306. # === Step 4: Chunk examples ===
  307. chunks = analyze_chunk_examples()
  308. if chunks:
  309. print(f"\n[Step 4] Example Chunks from Data Files ({len(chunks)} found):")
  310. for c in chunks[:10]:
  311. print(f"\n --- {c['drug']} / {c['section']} ---")
  312. print(f" {c['chunk_preview']}")
  313. # === Step 5: Reranker simulation ===
  314. print(f"\n[Step 5] Reranker Simulation (query='{query}'):")
  315. # Simulate keyword overlap for sample chunks
  316. sample_contents = [
  317. ("阿莫西林-禁忌", "对青霉素类药物过敏者或青霉素皮肤试验阳性患者禁用。"),
  318. ("青霉素钠-不良反应", "过敏反应较常见,包括荨麻疹等各类皮疹、白细胞减少、间质性肾炎、哮喘发作等;偶见过敏性休克。"),
  319. ("清开灵注射液-不良反应", "偶有过敏反应,可见皮疹、瘙痒等。本品可能引起严重过敏反应,用药期间应注意观察。"),
  320. ("感冒用药指引-常用药物", "1. 对乙酰氨基酚:成人一次0.3-0.6g..."),
  321. ("过敏用药指引-用药原则", "首选第二代抗组胺药...过敏性休克需立即注射肾上腺素并送急救。"),
  322. ]
  323. for name, content in sample_contents:
  324. score = keyword_overlap_score(tokens, content)
  325. print(f" {name}: keyword_score={score:.2f} (content: {content[:60]}...)")
  326. # === Step 6: Embedding analysis ===
  327. print(f"\n[Step 6] Embedding Space Analysis:")
  328. print(f" Run with --embedding flag to call DashScope API for vector comparison.")
  329. # === DIAGNOSIS SUMMARY ===
  330. print("\n" + "=" * 70)
  331. print("ROOT CAUSE ANALYSIS")
  332. print("=" * 70)
  333. print("""
  334. ROOT CAUSE 1 [MOST CRITICAL]: Intent Classification Error
  335. - "青霉素过敏处理" is classified as "symptom_advice" because
  336. "过敏" appears in symptom_keywords.
  337. - symptom_advice uses a prompt template that tells LLM to
  338. recommend drugs for symptoms.
  339. - The correct classification should be "usage_guide" (safety
  340. consultation) which focuses on contraindications and precautions.
  341. - "过敏" was intentionally removed from safety_sections in the
  342. code, but the symptom_keywords trigger takes priority anyway.
  343. ROOT CAUSE 2: Vector Embedding Semantic Confusion
  344. - text-embedding-v3 is a general-purpose model, not fine-tuned
  345. for pharmaceutical domain.
  346. - Query "penicillin allergy management" and chunks like
  347. "Qingkailing injection - adverse reactions: may cause allergies"
  348. are semantically similar in the embedding space because both
  349. involve "drug + allergy/adverse reaction" domain.
  350. ROOT CAUSE 3: Data Imbalance + Chunking Strategy
  351. - TCM (一部) has 2280+ drugs, chemical drugs (二部) far fewer.
  352. - Each drug's sections (adverse reactions, contraindications,
  353. precautions) are vectorized independently.
  354. - Sections mentioning "allergy" cluster in embedding space,
  355. so any allergy query matches many unrelated drugs.
  356. ROOT CAUSE 4: Weak Reranker
  357. - Current reranker only does keyword overlap + dedup.
  358. - Cross-Encoder reranking (BGE-Reranker-v2-m3) is NOT implemented
  359. (the _load_model method is empty).
  360. - As a result, top-20 vector results cannot be semantically
  361. re-ranked for true relevance.
  362. ROOT CAUSE 5: LLM Web Search Amplification
  363. - enable_web_search=True may introduce external info about
  364. drugs not directly relevant to the user's query.
  365. """)
  366. print("=" * 70)
  367. print("RECOMMENDED FIXES (in priority order)")
  368. print("=" * 70)
  369. print("""
  370. 1. [INTENT] Add negation and known-allergy detection:
  371. - "XX过敏" with no symptom description -> usage_guide or drug_query
  372. - Add pattern: r'.+过敏(处理|怎么办|急救|替代)' -> usage_guide
  373. - Distinguish "我过敏了该吃什么药" vs "我对青霉素过敏怎么办"
  374. 2. [RETRIEVAL] Add query expansion:
  375. - "青霉素过敏处理" -> add "青霉素 禁忌 替代药物 交叉过敏"
  376. - Or add pre-filtering: if query mentions a specific drug name,
  377. boost chunks where drug_name matches.
  378. 3. [RERANKER] Implement Cross-Encoder:
  379. - Load BGE-Reranker-v2-m3 model in Reranker._load_model()
  380. - Use it for semantic re-ranking of top-20 results to top-5
  381. 4. [PROMPT] Fix SYMPTOM_ADVICE template:
  382. - Add constraint: "If user asks about a KNOWN drug allergy,
  383. provide allergy management and alternative drug info,
  384. NOT recommend new drugs for treating the allergy symptom."
  385. 5. [DATA] Add drug_name weight:
  386. - In vector search, add a metadata filter or hybrid scoring
  387. that boosts chunks whose drug_name contains query terms.
  388. 6. [EVAL] Add test cases:
  389. - "青霉素过敏处理" should return content about penicillin
  390. alternatives (cephalosporins, macrolides, etc.), NOT
  391. random TCM drugs that mention "allergy" in their sections.
  392. """)
  393. if __name__ == "__main__":
  394. main()