PharmaLitQA:一个生物医药文献 RAG 智能问答系统的设计与实现

从 0 到 1 搭一个垂直领域的检索增强问答系统:多路召回 + 重排序 + 大模型生成,配合 PubMed 文献自动同步。本文按「技术栈 → 它是什么 → 有哪些功能 → 业务流程 → 每个功能怎么实现」的顺序展开。

2026/6/4
15 分钟阅读
目录

一、技术栈

整个项目是典型的「前后端分离 + AI 中台」结构。

后端(Python 3.10+)

技术作用
FastAPI异步 Web 框架,对外提供 REST API
ChromaDB轻量级向量数据库,做语义检索
Sentence-Transformers本地文本嵌入(BGE 中文模型)+ Cross-Encoder 重排序
rank-bm25 / 自研 BM25关键词检索,与向量检索互补
APScheduler定时任务,自动同步 PubMed 文献
Qwen(阿里云 DashScope)主对话大模型,生成最终答案
DeepSeek备选大模型,主模型失败/限流时自动接管
Pydantic / pydantic-settings配置管理与请求/响应数据校验

前端(Node.js 18+)

技术作用
Nuxt.js 3 + Vue 3 + TypeScriptSSR 框架与类型安全
Element Plus企业级 UI 组件库
Tailwind CSS原子化样式
markdown-it把大模型返回的 Markdown 渲染成 HTML

一张图看懂架构

浏览器(3000) → Nuxt 前端 → FastAPI 后端(8000)
                                 │
            ┌────────────┬───────┴────────┬──────────────┐
            ▼            ▼                ▼              ▼
        ChromaDB     本地 BGE          Qwen / DeepSeek   PubMed API
       (向量库)      (嵌入/重排)        (答案生成)        (文献来源)

二、它是做什么的

PharmaLitQA 是一个面向生物医药 / 生信领域的文献智能问答系统。

研究人员面对的痛点是:PubMed 上每天新增海量文献,靠关键词检索既费时又容易漏。直接问通用大模型,又会遇到"幻觉"——它可能编造一个看起来很专业、实则不存在的结论。

这个系统的解法是 RAG(检索增强生成):先从一个可信的文献知识库里检索出真正相关的片段,再让大模型基于这些片段作答,并附上文献来源(PMID + PubMed 链接)。这样答案既专业,又可溯源、可核查,大幅降低幻觉。

一句话:"带着文献依据回答生物医药问题的 AI 助手"。


三、有哪些功能

围绕"问答"和"知识库管理"两条主线:

问答侧

  • 🔍 智能问答:输入中文/英文问题,返回带文献引用的专业答案
  • 🌐 跨语言检索:英文问题自动翻译为中文领域术语再检索
  • 📎 答案溯源:每条答案附带来源文献片段、PMID 与 PubMed 原文链接

知识库侧

  • 📚 知识库浏览:查看库中全部文献
  • 手动录入文献:按 PMID + 标题 + 摘要添加
  • 🗑️ 删除/清空:按 PMID 删除单篇或一键清空
  • 🔄 PubMed 自动同步:定时(默认每天凌晨 2 点)拉取最新文献入库
  • 同步调度管理:查看同步状态、手动触发、修改定时规则
  • 🔎 PubMed 条件检索:按关键词 + 日期范围在线检索 PubMed
  • 💡 MeSH 术语联想:输入时联想标准医学主题词,提升检索准确度

模型可用性

  • 双模型容灾:Qwen 主、DeepSeek 备,自动故障切换
  • 💰 本地嵌入:嵌入模型跑在本机,永久免费、离线可用、不耗 API 额度

四、业务流程

4.1 问答主流程(核心)

用户从提问到拿到答案,后端经历 6 步:

用户问题
   │
   ├─① 语言判断与翻译  ──→ 非中文则译为中文领域术语
   │
   ├─② 多路召回         ──→ 向量检索(语义) + BM25(关键词),合并去重,召回 ~20 篇
   │
   ├─③ 重排序           ──→ Cross-Encoder 对候选深度打分,精排出 Top-5
   │
   ├─④ 构造上下文       ──→ 把 Top-5 片段拼成带 PMID 的提示词
   │
   ├─⑤ 大模型生成       ──→ Qwen 作答(失败自动切 DeepSeek)
   │
   └─⑥ 组装结果         ──→ 答案 + 来源列表(PMID/片段/相关分) 返回前端

为什么要"召回 20 → 精排 5"两段式?因为向量检索快但粗(适合从全库快速捞候选),Cross-Encoder 准但慢(适合在小候选集上精挑)。两者结合兼顾速度与精度。

4.2 文献入库流程

文献(标题+摘要)
   │
   ├─ 分块:短摘要整段成块;长摘要按句子边界滑窗切分(带重叠)
   ├─ 嵌入:本地 BGE 把每块转成 1024 维向量
   └─ 入库:向量 + 文本 + PMID 元数据 写入 ChromaDB;重置 BM25 索引

4.3 PubMed 自动同步流程

APScheduler 定时触发(每天 2:00)
   │
   ├─ ESearch:按生物医药关键词 + 时间窗检索,拿到 PMID 列表
   ├─ EFetch :批量拉取文献标题、摘要、作者、期刊等详情
   ├─ 去重   :跳过库中已存在的 PMID
   └─ 入库   :走 4.2 的嵌入入库流程

五、每个功能的技术实现

下面逐个拆解关键功能背后的代码实现。

5.1 智能问答(RAG 编排)

核心在 rag/rag_service.pyquery(),它是上面 4.1 流程的代码化身:

def query(self, question: str) -> Dict[str, Any]:
    # ① 翻译(仅非中文)
    translated = get_translator().translate_if_needed(question)
    # ② 多路召回 ~20 篇
    candidates = self._multi_retrieve(translated, top_k=20)
    # ③ Cross-Encoder 精排 Top-5
    reranked = self._rerank(translated, candidates, top_k=5)
    # ④ 拼上下文(带 PMID)
    context = "\n\n".join(f"片段{i}(PMID: {r['pmid']}):{r['text']}"
                          for i, r in enumerate(reranked, 1))
    # ⑤ 大模型生成
    answer = self.qwen_client.ask_with_context(question, context, pmid=...)
    # ⑥ 组装来源
    return {"answer": answer, "source": [...]}

设计要点:检索用的是翻译后的文本(提升召回),但喂给大模型作答时用用户原始问题(保证回答语言/语气贴合用户)。

5.2 多路召回(Vector + BM25)

_multi_retrieve() 同时跑两路检索再用 PMID 做并集合并

  • 向量检索:本地 BGE 把 query 编码成向量,在 ChromaDB 里按 L2 距离找最近邻。擅长语义相似("OS"≈"总生存期")。
  • BM25 检索:经典关键词算法,擅长精确匹配(基因名、缩写如 "KRAS G12C")。实现见 bm25_retriever.py,核心打分公式:
score += idf * (tf * (k1 + 1)) / (tf + k1 * (1 - b + b * doc_len / avgdl))

两路互补:向量召回怕生僻专有名词,BM25 召回怕同义改写,合在一起召回率更高。BM25 索引在每次查询时按知识库最新状态惰性重建,保证增删文献后立即生效。

5.3 重排序(Cross-Encoder 精排)

多路召回的 20 篇质量参差,用 cross_encoder_reranker.pyCross-Encoderms-marco-MiniLM-L-6-v2)做精排:

pairs = [(query, doc["text"]) for doc in documents]
scores = self.model.predict(pairs)          # query-doc 联合编码打分
docs.sort(key=lambda d: d["rerank_score"], reverse=True)
return docs[:top_k]

与"双塔"向量检索不同,Cross-Encoder 把 query 和 doc 拼在一起送进 Transformer,做 token 级交互注意力,相关性判断更准。代价是慢,所以只在 20 篇小候选集上用。

优雅降级:模型加载失败时自动回退到 SimpleReranker(向量距离 + 关键词匹配的加权打分),保证服务不中断。

5.4 本地嵌入(BGE)

rag/embeddings.py 用本地 BAAI/bge-large-zh-v1.5(1024 维)。两个工程细节:

# 1) BGE 查询侧需加检索指令,文档侧不加
BGE_QUERY_INSTRUCTION = "为这个句子生成表示以用于检索相关文章:"
def embed_query(self, query):
    text = self.instruction + query if self.use_query_instruction else query
    return self.model.encode(text, normalize_embeddings=True).tolist()
# 2) 向量归一化,让 L2 距离与余弦相似度等价排序

选本地模型而非云端 Embedding API 的原因:免费、离线、不受 API 额度限制——这对一个要持续给全库文献做嵌入的系统很关键。模型缓存在 ~/.cache/huggingface/,全局共享、只下一次。

5.5 跨语言翻译(三层混合策略)

rag/translator.pyHybridTranslator 不是简单调翻译 API,而是三层瀑布:

  1. 静态词典O(1),0 网络开销):内置数百条高频生物医学术语(癌种、靶点、基因),命中直接返回。
  2. LLM 兜底(~200ms):词典覆盖不到的,调大模型翻译。
  3. MeSH 查询扩展:调 NCBI MeSH 接口把术语规范化、扩展同义词,提升 PubMed 检索召回。
def _llm_fallback(self, text):
    if not self.enable_llm or not self._llm_api_key:
        return text                      # 没配 LLM 也能退回词典结果
    return self.llm_translator.translate(text)

持久化缓存translator_cache.json),同样的词不重复翻译。即使 LLM 那层失败,也会优雅退回词典 + MeSH 结果,不阻断主流程。

5.6 双模型容灾(Qwen 主 + DeepSeek 备)

rag/qwen_client.py 把"主备切换"封装在一个 call() 里,对上层完全透明:

def call(self, prompt, ...):
    try:
        return self._chat(self.client, self.model, ...)      # ① 先试 Qwen
    except Exception as e:
        logger.warning(f"Qwen 调用失败:{e}")
        if self.fallback_client:                             # ② 自动切 DeepSeek
            return self._chat(self.fallback_client, self.fallback_model, ...)
        raise

只要 Qwen 报任何错(免费额度耗尽 403、限流、网络抖动、模型名错误),立刻用 DeepSeek 重试,前端无感知。两个模型都走 OpenAI 兼容协议,所以同一套 OpenAI SDK 即可调用,只是 base_urlmodel 不同。

5.7 文献分块入库

rag_service.add_literature() + _chunk_abstract()。早期版本按句号切片,导致同一句里的关键数据(如"OS 37.5 个月")和上下文被切散,检索命中的片段缺数据,模型只能答"无相关信息"。改进后的策略:

def _chunk_abstract(abstract, max_chars=500, overlap=100):
    if len(abstract) <= max_chars:
        return [abstract]                       # 短摘要整段,保留完整上下文
    # 长摘要:按句子边界聚合成块,块间保留 overlap 重叠
    ...

短摘要不切,长摘要按句子边界做带重叠的滑窗——既不超模型长度上限,又不丢上下文。配合 BGE 嵌入,问答准确度明显提升。

5.8 PubMed 同步与调度

  • pubmed_sync.py:封装 NCBI E-utilities。ESearch 拿 PMID 列表,EFetch 批量取详情,内置生物医药关键词集做领域过滤。
  • sync_scheduler.py:用 APScheduler 注册 cron 任务(默认 0 2 * * *)。同步时先 get_all_documents() 取已有 PMID 做去重,只对新文献嵌入入库,避免重复与浪费。
  • 对外暴露 POST /api/sync/now(手动触发)、GET /api/sync/status(查状态)、POST /api/sync/schedule(改 cron)三个接口,调度策略可运行时调整。

5.9 MeSH 术语联想

GET /api/mesh/suggestmain.py):用户在前端输入时,实时调 NCBI 的 esearch + esummary 接口,把输入词映射到标准 MeSH 主题词返回下拉建议;无输入时给一批热门术语兜底,网络失败时返回空列表而非报错——不让外部依赖影响前端体验

5.10 前端问答页

frontend/pages/index.vue:Element Plus 输入框 + 提交按钮,调 POST /api/ask。拿到结果后:

  • markdown-it 把答案 Markdown 渲染成 HTML(大模型常用 Markdown 排版);
  • 来源用可折叠面板(el-collapse)展示,每条带 PMID 和跳转 PubMed 的"查看原文"链接;
  • 支持 Ctrl/Cmd + Enter 快捷提交,loading 态防重复提交。

六、小结

PharmaLitQA 的价值不在某一个"炫技点",而在于把一条完整、可靠、可溯源的 RAG 链路工程化落地:

  • 检索质量:向量 + BM25 多路召回,再加 Cross-Encoder 精排,召回与精度兼顾;
  • 可用性:本地嵌入永久免费、Qwen/DeepSeek 双模型容灾、翻译三层降级——每个外部依赖都有兜底;
  • 数据新鲜度:APScheduler 定时同步 PubMed,知识库持续更新;
  • 可信度:答案永远附带文献来源,把"AI 幻觉"约束在"有据可查"的范围内。

这套架构稍作替换(换领域语料、换嵌入/对话模型),就能迁移到法律、金融、政务等任何需要"专业 + 可溯源"问答的垂直场景。

项目地址

github:https://github.com/oinsist/PharmaLitQA
gitee:https://gitee.com/o_insist/PharmaLitQA