diagnose_online.py 26 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668
  1. # -*- coding: utf-8 -*-
  2. """
  3. Production Diagnostic Script
  4. Deploy to server and run to trace the full RAG pipeline for any query.
  5. Usage:
  6. python diagnose_online.py "青霉素过敏处理"
  7. python diagnose_online.py "青霉素过敏处理" --top-k 30
  8. python diagnose_online.py "青霉素过敏处理" --call-api # also call the API
  9. """
  10. import sys
  11. import os
  12. import json
  13. import re
  14. import asyncio
  15. import argparse
  16. import math
  17. from pathlib import Path
  18. # ============================================================
  19. # Environment Setup
  20. # ============================================================
  21. def load_env():
  22. """Load .env file from project root."""
  23. candidates = [
  24. Path(__file__).resolve().parent / ".env",
  25. Path.cwd() / ".env",
  26. Path("/opt/pharmacopoeia-ai/.env"),
  27. ]
  28. for env_file in candidates:
  29. if env_file.exists():
  30. with open(env_file, encoding="utf-8") as f:
  31. for line in f:
  32. line = line.strip()
  33. if line and not line.startswith("#") and "=" in line:
  34. key, _, val = line.partition("=")
  35. os.environ.setdefault(key.strip(), val.strip())
  36. print(f"[ENV] Loaded from {env_file}")
  37. return True
  38. print("[ENV] WARNING: No .env file found, using env vars directly")
  39. return False
  40. load_env()
  41. # ============================================================
  42. # Intent Classification (copy from retriever.py)
  43. # ============================================================
  44. def classify_intent(query: str) -> str:
  45. q = query.strip()
  46. negation_patterns = [
  47. r"不是", r"没有", r"并非", r"不算", r"不属于",
  48. r"这不是", r"我没有", r"不包含", r"不涉及",
  49. ]
  50. if any(re.search(pat, q) for pat in negation_patterns):
  51. return "drug_query"
  52. usage_keywords = [
  53. "怎么吃", "吃多少", "怎么用", "一天几次", "多长时间",
  54. "能一起吃", "孕妇能用", "儿童用量", "哺乳期",
  55. "饭前还是饭后", "空腹", "过量", "漏服", "停药",
  56. "副作用多大", "伤肝吗", "伤肾吗", "安全吗",
  57. ]
  58. safety_sections = [
  59. "副作用", "不良反应", "禁忌", "注意事项",
  60. "能不能", "可以吗", "会不会",
  61. ]
  62. regulation_keywords = [
  63. "凡例", "通则规定", "制剂通则",
  64. "一般规定", "通用技术要求", "检验方法通则",
  65. ]
  66. exam_keywords = [
  67. "执业药师考试", "考点", "历年真题", "考试大纲",
  68. "高频考点", "报名时间",
  69. ]
  70. symptom_keywords = [
  71. "吃了什么药", "吃什么药", "该吃", "推荐用药", "推荐下用药",
  72. "买什么药", "推荐什么药", "用什么药", "用药建议",
  73. "发烧", "咳嗽", "感冒", "腹泻", "头疼", "头痛",
  74. "嗓子疼", "流鼻涕", "鼻塞", "肚子疼", "胃疼",
  75. "过敏", "皮肤痒", "失眠", "便秘", "牙疼",
  76. "体温", "多少度", "退烧", "止痛", "止泻",
  77. ]
  78. if any(kw in q for kw in usage_keywords):
  79. return "usage_guide"
  80. if any(kw in q for kw in safety_sections):
  81. return "usage_guide"
  82. if any(kw in q for kw in symptom_keywords):
  83. return "symptom_advice"
  84. if any(kw in q for kw in regulation_keywords):
  85. return "regulation"
  86. if any(kw in q for kw in exam_keywords):
  87. return "exam_tutor"
  88. return "drug_query"
  89. # ============================================================
  90. # Tokenization (copy from reranker.py)
  91. # ============================================================
  92. def tokenize_query(query: str) -> set:
  93. tokens = set()
  94. for n in range(2, 5):
  95. for i in range(len(query) - n + 1):
  96. seg = query[i:i + n]
  97. if all('一' <= c <= '鿿' for c in seg):
  98. tokens.add(seg)
  99. for word in re.findall(r'[a-zA-Z0-9]+', query):
  100. tokens.add(word.lower())
  101. return tokens
  102. def keyword_overlap_score(query_terms: set, content: str) -> float:
  103. if not query_terms:
  104. return 0.0
  105. matched = sum(1 for t in query_terms if t in content)
  106. return matched / len(query_terms)
  107. # ============================================================
  108. # Database Connection & Vector Search
  109. # ============================================================
  110. def get_db_url() -> str:
  111. host = os.environ.get("POSTGRES_HOST", "localhost")
  112. port = os.environ.get("POSTGRES_PORT", "5432")
  113. db = os.environ.get("POSTGRES_DB", "pharmacopoeia")
  114. user = os.environ.get("POSTGRES_USER", "postgres")
  115. password = os.environ.get("POSTGRES_PASSWORD", "postgres")
  116. return f"postgresql+asyncpg://{user}:{password}@{host}:{port}/{db}"
  117. async def get_query_embedding(query: str) -> list:
  118. """Call DashScope embedding API."""
  119. import httpx
  120. api_key = os.environ.get("QWEN_API_KEY", "")
  121. if not api_key:
  122. raise RuntimeError("QWEN_API_KEY not set")
  123. url = "https://dashscope.aliyuncs.com/api/v1/services/embeddings/text-embedding/text-embedding"
  124. async with httpx.AsyncClient(timeout=30) as client:
  125. resp = await client.post(
  126. url,
  127. headers={
  128. "Content-Type": "application/json",
  129. "Authorization": f"Bearer {api_key}",
  130. },
  131. json={
  132. "model": "text-embedding-v3",
  133. "input": {"texts": [query]},
  134. "parameters": {"text_type": "query"},
  135. },
  136. )
  137. data = resp.json()
  138. if data.get("code") and data.get("code") != "":
  139. raise RuntimeError(f"Embedding API error: {data.get('message')}")
  140. return data["output"]["embeddings"][0]["embedding"]
  141. async def vector_search(query_vec: list, top_k: int = 20) -> list:
  142. """Run pgvector cosine similarity search."""
  143. from sqlalchemy.ext.asyncio import create_async_engine
  144. from sqlalchemy import text
  145. db_url = get_db_url()
  146. vec_str = "[" + ",".join(str(v) for v in query_vec) + "]"
  147. engine = create_async_engine(db_url, pool_size=5, max_overflow=10, pool_pre_ping=True)
  148. try:
  149. async with engine.connect() as conn:
  150. result = await conn.execute(
  151. text("""
  152. SELECT content, source, drug_id, section,
  153. 1 - (vec <=> CAST(:qv AS vector)) AS similarity
  154. FROM drug_chunks
  155. WHERE vec IS NOT NULL
  156. ORDER BY vec <=> CAST(:qv AS vector)
  157. LIMIT :k
  158. """),
  159. {"qv": vec_str, "k": top_k},
  160. )
  161. rows = result.fetchall()
  162. docs = []
  163. for row in rows:
  164. docs.append({
  165. "content": row[0],
  166. "source": row[1],
  167. "drug_id": row[2],
  168. "section": row[3],
  169. "score": float(row[4]),
  170. })
  171. return docs
  172. finally:
  173. await engine.dispose()
  174. async def get_db_stats() -> dict:
  175. """Get statistics about the drug_chunks table."""
  176. from sqlalchemy.ext.asyncio import create_async_engine
  177. from sqlalchemy import text
  178. db_url = get_db_url()
  179. engine = create_async_engine(db_url)
  180. try:
  181. async with engine.connect() as conn:
  182. # Total chunks
  183. r = await conn.execute(text("SELECT COUNT(*) FROM drug_chunks WHERE vec IS NOT NULL"))
  184. total_chunks = r.fetchone()[0]
  185. # By source
  186. r = await conn.execute(text("""
  187. SELECT source, COUNT(*) as cnt
  188. FROM drug_chunks
  189. WHERE vec IS NOT NULL
  190. GROUP BY source
  191. ORDER BY cnt DESC
  192. LIMIT 20
  193. """))
  194. by_source = [(row[0], row[1]) for row in r.fetchall()]
  195. # By section
  196. r = await conn.execute(text("""
  197. SELECT section, COUNT(*) as cnt
  198. FROM drug_chunks
  199. WHERE vec IS NOT NULL
  200. GROUP BY section
  201. ORDER BY cnt DESC
  202. LIMIT 20
  203. """))
  204. by_section = [(row[0], row[1]) for row in r.fetchall()]
  205. # Drugs with penicillin in name
  206. r = await conn.execute(text("""
  207. SELECT DISTINCT d.name, d.category, d.source_volume
  208. FROM drugs d
  209. WHERE d.name LIKE '%青霉素%'
  210. """))
  211. penicillin_drugs = [(row[0], row[1], row[2]) for row in r.fetchall()]
  212. # Drugs with 清开灵 in name
  213. r = await conn.execute(text("""
  214. SELECT DISTINCT d.name, d.category, d.source_volume
  215. FROM drugs d
  216. WHERE d.name LIKE '%清开灵%'
  217. """))
  218. qkl_drugs = [(row[0], row[1], row[2]) for row in r.fetchall()]
  219. # Count drugs that have allergy in their sections
  220. r = await conn.execute(text("""
  221. SELECT COUNT(DISTINCT drug_id)
  222. FROM drug_chunks
  223. WHERE content LIKE '%过敏%' AND vec IS NOT NULL
  224. """))
  225. allergy_chunk_count = r.fetchone()[0]
  226. # Count total unique drugs
  227. r = await conn.execute(text("SELECT COUNT(*) FROM drugs WHERE is_active = TRUE"))
  228. total_drugs = r.fetchone()[0]
  229. return {
  230. "total_chunks": total_chunks,
  231. "total_drugs": total_drugs,
  232. "allergy_chunk_count": allergy_chunk_count,
  233. "by_source": by_source,
  234. "by_section": by_section,
  235. "penicillin_drugs": penicillin_drugs,
  236. "qkl_drugs": qkl_drugs,
  237. }
  238. finally:
  239. await engine.dispose()
  240. # ============================================================
  241. # Reranker Simulation
  242. # ============================================================
  243. def simulate_reranker(query: str, documents: list, top_k: int = 5) -> list:
  244. """Simulate the reranker behavior on retrieval results."""
  245. query_terms = tokenize_query(query)
  246. MIN_THRESHOLD = 0.3
  247. # Step 1: Filter low similarity
  248. filtered = [d for d in documents if d.get("score", 0) >= MIN_THRESHOLD]
  249. # Step 2: Keyword bonus
  250. for doc in filtered:
  251. content = doc.get("content", "")
  252. bonus = keyword_overlap_score(query_terms, content)
  253. doc["keyword_score"] = bonus
  254. doc["fused_score"] = doc.get("score", 0) + bonus * 0.3
  255. # Step 3: Sort by fused score
  256. sorted_docs = sorted(filtered, key=lambda d: d.get("fused_score", 0), reverse=True)
  257. # Step 4: Dedup
  258. deduped = []
  259. seen_texts = []
  260. for doc in sorted_docs:
  261. content = doc.get("content", "")
  262. # Simple dedup: check first 200 chars
  263. sample = set(content[:200])
  264. is_dup = False
  265. for seen in seen_texts[-5:]:
  266. seen_sample = set(seen[:200])
  267. intersection = len(sample & seen_sample)
  268. union = len(sample | seen_sample)
  269. if union > 0 and intersection / union > 0.8:
  270. is_dup = True
  271. break
  272. if not is_dup:
  273. deduped.append(doc)
  274. seen_texts.append(content)
  275. return deduped[:top_k]
  276. # ============================================================
  277. # Extract drug name from chunk content
  278. # ============================================================
  279. def extract_drug_name(content: str) -> str:
  280. m = re.match(r'【(.+?) - ', content)
  281. return m.group(1) if m else "unknown"
  282. # ============================================================
  283. # API Caller
  284. # ============================================================
  285. async def call_production_api(query: str, token: str, api_base: str = "https://pharmacopoeia.kailin.com.cn"):
  286. """Call the production chat API and capture full SSE response."""
  287. import httpx
  288. if not token:
  289. print("\n[API] ERROR: No token provided (use --token).")
  290. return None
  291. print(f"\n[API] Calling {api_base}/api/v1/chat/stream ...")
  292. print(f" Query: {query}")
  293. events = {"intent": None, "status": [], "content": [], "meta": None}
  294. async with httpx.AsyncClient(timeout=120) as client:
  295. async with client.stream(
  296. "POST",
  297. f"{api_base}/api/v1/chat/stream",
  298. headers={
  299. "Content-Type": "application/json",
  300. "Authorization": f"Bearer {token}",
  301. },
  302. json={"message": query, "conversation_id": f"diag-{os.urandom(4).hex()}"},
  303. ) as stream:
  304. current_event = None
  305. async for line in stream.aiter_lines():
  306. if line.startswith("event: "):
  307. current_event = line[7:].strip()
  308. elif line.startswith("data: "):
  309. data = line[6:]
  310. if current_event == "intent":
  311. events["intent"] = data
  312. print(f" [SSE] intent: {data}")
  313. elif current_event == "status":
  314. events["status"].append(data)
  315. print(f" [SSE] status: {data}")
  316. elif current_event == "content":
  317. if data != "[DONE]":
  318. events["content"].append(data)
  319. elif current_event == "meta":
  320. try:
  321. events["meta"] = json.loads(data)
  322. print(f" [SSE] meta: sources={len(events['meta'].get('sources', []))} items")
  323. except json.JSONDecodeError:
  324. pass
  325. answer = "".join(events["content"])
  326. print(f"\n Answer length: {len(answer)} chars")
  327. return {
  328. "intent": events["intent"],
  329. "sources": events["meta"].get("sources", []) if events["meta"] else [],
  330. "answer_preview": answer[:500] + ("..." if len(answer) > 500 else ""),
  331. "status": events["status"],
  332. }
  333. # ============================================================
  334. # Main Diagnostic
  335. # ============================================================
  336. async def run_diagnosis(query: str, top_k: int = 20, call_api: bool = False,
  337. api_base: str = "https://pharmacopoeia.kailin.com.cn",
  338. token: str = ""):
  339. print("=" * 80)
  340. print(f" RAG Pipeline Diagnosis: '{query}'")
  341. print("=" * 80)
  342. # ===== Phase 1: Intent Classification =====
  343. print(f"\n{'='*80}")
  344. print(f" PHASE 1: Intent Classification")
  345. print(f"{'='*80}")
  346. intent = classify_intent(query)
  347. print(f" Query: '{query}'")
  348. print(f" Intent: '{intent}'")
  349. # Show why
  350. symptom_keywords = [
  351. "发烧", "咳嗽", "感冒", "腹泻", "头疼", "头痛",
  352. "嗓子疼", "流鼻涕", "鼻塞", "肚子疼", "胃疼",
  353. "过敏", "皮肤痒", "失眠", "便秘", "牙疼",
  354. ]
  355. matched = [kw for kw in symptom_keywords if kw in query]
  356. if matched:
  357. print(f" Matched symptom keywords: {matched}")
  358. print(f" >> This triggers 'symptom_advice' template -> LLM is told to recommend drugs")
  359. print(f"\n PROMPT TEMPLATE (intent={intent}):")
  360. # Inline prompt templates
  361. PROMPT_MAP = {
  362. "symptom_advice": "SYMPTOM_ADVICE: 病情评估 -> 用药方案(推荐药品) -> 注意事项 -> 就医指征",
  363. "drug_query": "DRUG_QUERY: 结论 -> 详细说明 -> 注意事项 -> 来源明细",
  364. "usage_guide": "USAGE_GUIDE: 结论 -> 用法用量 -> 禁忌 -> 不良反应 -> 注意事项",
  365. "regulation": "REGULATION: 摘要 -> 原文引用 -> 条款出处 -> 关联条款",
  366. "exam_tutor": "EXAM_TUTOR: 考点定位 -> 知识要点 -> 记忆技巧 -> 考试频率",
  367. "no_docs": "NO_DOCS: 结论 -> 详细说明(通用知识) -> 来源说明",
  368. }
  369. template = PROMPT_MAP.get(intent, "UNKNOWN")
  370. print(f" {template}")
  371. # ===== Phase 2: Database Statistics =====
  372. print(f"\n{'='*80}")
  373. print(f" PHASE 2: Database Overview")
  374. print(f"{'='*80}")
  375. try:
  376. stats = await get_db_stats()
  377. print(f" Total active drugs: {stats['total_drugs']}")
  378. print(f" Total chunks (with vec): {stats['total_chunks']}")
  379. print(f" Chunks containing '过敏': {stats['allergy_chunk_count']} "
  380. f"({stats['allergy_chunk_count']/max(stats['total_chunks'],1)*100:.1f}%)")
  381. print(f"\n Penicillin-related drugs in DB:")
  382. for name, cat, vol in stats['penicillin_drugs']:
  383. print(f" - {name} ({cat}, {vol})")
  384. print(f"\n Qingkailing-related drugs in DB:")
  385. for name, cat, vol in stats['qkl_drugs']:
  386. print(f" - {name} ({cat}, {vol})")
  387. print(f"\n Chunks by source (top 10):")
  388. for src, cnt in stats['by_source'][:10]:
  389. print(f" {src}: {cnt} chunks")
  390. print(f"\n Chunks by section (top 15):")
  391. for sec, cnt in stats['by_section'][:15]:
  392. print(f" {sec}: {cnt} chunks")
  393. except Exception as e:
  394. print(f" DB STATS FAILED: {e}")
  395. stats = None
  396. # ===== Phase 3: Vector Search =====
  397. print(f"\n{'='*80}")
  398. print(f" PHASE 3: Vector Search (pgvector cosine similarity, top-{top_k})")
  399. print(f"{'='*80}")
  400. try:
  401. print(f" Getting embedding for query...")
  402. query_vec = await get_query_embedding(query)
  403. print(f" Embedding dim: {len(query_vec)}")
  404. print(f" Searching drug_chunks...")
  405. docs = await vector_search(query_vec, top_k=top_k)
  406. print(f" Retrieved: {len(docs)} documents")
  407. if docs:
  408. print(f"\n Similarity score range: {docs[0]['score']:.4f} ~ {docs[-1]['score']:.4f}")
  409. above_threshold = sum(1 for d in docs if d['score'] >= 0.3)
  410. print(f" Above reranker threshold (0.3): {above_threshold}/{len(docs)}")
  411. # Show all results
  412. print(f"\n --- VECTOR SEARCH RESULTS ---")
  413. for i, doc in enumerate(docs):
  414. drug_name = extract_drug_name(doc['content'])
  415. content_preview = doc['content'][:120].replace('\n', ' ')
  416. flag = ""
  417. if drug_name and "青霉素" in drug_name:
  418. flag = " <<< PENICILLIN MATCH"
  419. elif drug_name and "清开灵" in drug_name:
  420. flag = " <<< QINGKAILING MATCH"
  421. print(f"\n [{i+1}] score={doc['score']:.4f} | drug='{drug_name}' | section='{doc['section']}'{flag}")
  422. print(f" {content_preview}...")
  423. except Exception as e:
  424. print(f" VECTOR SEARCH FAILED: {e}")
  425. import traceback
  426. traceback.print_exc()
  427. docs = []
  428. # ===== Phase 4: Reranker Simulation =====
  429. print(f"\n{'='*80}")
  430. print(f" PHASE 4: Reranker Simulation (keyword overlap + dedup)")
  431. print(f"{'='*80}")
  432. query_terms = tokenize_query(query)
  433. print(f" Query tokens ({len(query_terms)}): {sorted(query_terms)}")
  434. if docs:
  435. reranked = simulate_reranker(query, docs, top_k=5)
  436. print(f"\n After filtering (score>=0.3), keyword bonus, dedup: {len(reranked)} docs")
  437. print(f"\n --- RERANKED TOP-5 ---")
  438. for i, doc in enumerate(reranked):
  439. drug_name = extract_drug_name(doc['content'])
  440. content_preview = doc['content'][:150].replace('\n', ' ')
  441. print(f"\n [{i+1}] fused_score={doc['fused_score']:.4f} "
  442. f"(vec={doc['score']:.4f} + kw_bonus={doc['keyword_score']:.2f}*0.3)")
  443. print(f" drug='{drug_name}' | section='{doc['section']}' | source='{doc.get('source','')}'")
  444. print(f" {content_preview}")
  445. # Find Qingkailing results
  446. qkl_in_results = [d for d in docs if "清开灵" in d.get("content", "")]
  447. qkl_in_reranked = [d for d in reranked if "清开灵" in d.get("content", "")]
  448. print(f"\n Qingkailing in top-{top_k} vector results: {len(qkl_in_results)}")
  449. print(f" Qingkailing in top-5 reranked: {len(qkl_in_reranked)}")
  450. if qkl_in_reranked:
  451. print(f"\n *** QINGKAILING MADE IT TO TOP-5! ***")
  452. for d in qkl_in_reranked:
  453. print(f" drug='{extract_drug_name(d['content'])}' "
  454. f"score={d['score']:.4f} kw_bonus={d['keyword_score']:.2f}")
  455. # Show all Qingkailing in vector results
  456. if qkl_in_results:
  457. print(f"\n All Qingkailing entries in vector results:")
  458. for d in qkl_in_results:
  459. drug_name = extract_drug_name(d['content'])
  460. preview = d['content'][:100].replace('\n', ' ')
  461. print(f" [{drug_name}/{d['section']}] score={d['score']:.4f}: {preview}...")
  462. # ===== Phase 5: API Call (optional) =====
  463. if call_api:
  464. print(f"\n{'='*80}")
  465. print(f" PHASE 5: Production API Call")
  466. print(f"{'='*80}")
  467. try:
  468. api_result = await call_production_api(query, token, api_base)
  469. if api_result:
  470. print(f"\n API Intent: {api_result['intent']}")
  471. print(f" API Sources ({len(api_result['sources'])}):")
  472. for s in api_result['sources']:
  473. print(f" - {s.get('name', '?')} | section={s.get('section', '?')} "
  474. f"| score={s.get('score', 0):.4f} | source={s.get('source', '?')}")
  475. print(f"\n Answer preview:")
  476. print(f" {api_result['answer_preview']}")
  477. except Exception as e:
  478. print(f" API CALL FAILED: {e}")
  479. # ===== Phase 6: Summary =====
  480. print(f"\n{'='*80}")
  481. print(f" DIAGNOSIS SUMMARY")
  482. print(f"{'='*80}")
  483. issues = []
  484. # Issue 1: Intent
  485. if intent == "symptom_advice" and "过敏" in query:
  486. issues.append({
  487. "severity": "CRITICAL",
  488. "category": "Intent Classification",
  489. "detail": f"Query '{query}' classified as 'symptom_advice'. "
  490. f"The keyword '过敏' triggers symptom-based drug recommendation, "
  491. f"but this query is about managing a known drug allergy, not seeking "
  492. f"drugs for allergy symptoms.",
  493. "fix": "Add known-allergy detection: r'.+过敏(处理|怎么办|急救|替代)' -> usage_guide"
  494. })
  495. # Issue 2: Vector search data imbalance
  496. if stats:
  497. allergy_pct = stats['allergy_chunk_count'] / max(stats['total_chunks'], 1) * 100
  498. if allergy_pct > 30:
  499. issues.append({
  500. "severity": "HIGH",
  501. "category": "Data Imbalance",
  502. "detail": f"{allergy_pct:.0f}% of chunks contain '过敏'. "
  503. f"Any allergy-related query will match a huge number of "
  504. f"unrelated drug entries.",
  505. "fix": "Consider section-specific indexing or adding drug-name boost "
  506. "in vector search scoring."
  507. })
  508. if stats['qkl_drugs'] and not stats['penicillin_drugs']:
  509. issues.append({
  510. "severity": "HIGH",
  511. "category": "Missing Data",
  512. "detail": f"Qingkailing entries found ({len(stats['qkl_drugs'])}), "
  513. f"but no penicillin entries in database. "
  514. f"System cannot return relevant results for penicillin queries.",
  515. "fix": "Ensure chemical drug data (volume 2) is imported."
  516. })
  517. # Issue 3: Reranker weakness
  518. if docs:
  519. top_scores = [d['score'] for d in docs[:5]]
  520. score_spread = top_scores[0] - top_scores[-1] if len(top_scores) > 1 else 0
  521. if score_spread < 0.1:
  522. issues.append({
  523. "severity": "MEDIUM",
  524. "category": "Weak Reranker",
  525. "detail": f"Top-5 vector scores too close (spread={score_spread:.4f}). "
  526. f"Keyword-based reranker cannot effectively distinguish relevance.",
  527. "fix": "Implement Cross-Encoder reranker (BGE-Reranker-v2-m3)."
  528. })
  529. # Issue 4: Always present
  530. issues.append({
  531. "severity": "MEDIUM",
  532. "category": "Prompt Template",
  533. "detail": "SYMPTOM_ADVICE template forces LLM to recommend drugs "
  534. "('用药方案: 方案一, 方案二...'). When query is about "
  535. "allergy management, this produces incorrect responses.",
  536. "fix": "Add constraint in SYMPTOM_ADVICE: if user asks about known drug "
  537. "allergy, provide management advice, not drug recommendations."
  538. })
  539. for issue in issues:
  540. print(f"\n [{issue['severity']}] {issue['category']}")
  541. print(f" {issue['detail']}")
  542. print(f" Fix: {issue['fix']}")
  543. print(f"\n{'='*80}")
  544. print(f" Diagnosis complete. {len(issues)} issues found.")
  545. print(f"{'='*80}")
  546. # ============================================================
  547. # CLI Entry Point
  548. # ============================================================
  549. if __name__ == "__main__":
  550. parser = argparse.ArgumentParser(
  551. description="Production RAG pipeline diagnostics"
  552. )
  553. parser.add_argument(
  554. "query", nargs="?", default="青霉素过敏处理",
  555. help="Query to diagnose (default: 青霉素过敏处理)"
  556. )
  557. parser.add_argument(
  558. "--top-k", type=int, default=20,
  559. help="Number of vector search results to retrieve (default: 20)"
  560. )
  561. parser.add_argument(
  562. "--call-api", action="store_true",
  563. help="Also call the production API endpoint"
  564. )
  565. parser.add_argument(
  566. "--api-base", default="https://pharmacopoeia.kailin.com.cn",
  567. help="API base URL (default: https://pharmacopoeia.kailin.com.cn)"
  568. )
  569. parser.add_argument(
  570. "--token", default="",
  571. help="JWT token for API authentication (required when --call-api)"
  572. )
  573. args = parser.parse_args()
  574. if args.call_api and not args.token:
  575. print("ERROR: --token is required when using --call-api")
  576. import sys
  577. sys.exit(1)
  578. asyncio.run(run_diagnosis(
  579. query=args.query,
  580. top_k=args.top_k,
  581. call_api=args.call_api,
  582. api_base=args.api_base,
  583. token=args.token,
  584. ))