☰
基于RAG与大模型的医疗问答系统:从文档加载到流式输出
2026/10/8 4:52:12 网站建设 项目流程

简介:一份基于RAG与大模型技术的医疗问答系统毕业设计资源包,面向计算机、人工智能、通信等专业学生与从业者,既可用于小白入门大模型应用开发,也可作为课程设计、大作业及毕设的综合参考。资源共75个文件,压缩包约84.65MB,文件类型涵盖Python源码、Jupyter Notebook、JSON/TXT/CSV数据、YAML配置与Markdown文档等。py脚本涉及Web界面、实体识别模型、图数据库构建等核心模块;ipynb记录数据解析、微调运行与NER结果分析;PNG/JPG截图及流程图可直观对照界面与系统结构。整套资源已经过调试测试,能直接运行,结合文档说明与配套数据,可支撑从数据预处理、知识图谱搭建、实体识别到RAG问答与模型微调的完整研发链路。目前已有378人学习浏览,具备较强学习借鉴价值,基础较好的读者还能在此基础上修改扩展,实现个性化医疗问答场景。

1. 医疗问答系统不是 Chatbot 套壳:RAG 才是这个毕设的硬骨头

如果你以为“医疗问答系统”就是在百川或者 ChatGLM 的 API 外面包一层网页,那这个毕设拿不到高分。真正拉开差距的是 RAG(检索增强生成)链路:用户问“感冒了能不能吃阿莫西林”,系统不是靠大模型硬记,而是先从医学知识库里检索出相关条目,再把它拼进 Prompt 让大模型“看着资料回答”。这套基于 RAG 与大模型技术的医疗问答系统源码,把文档加载、文本切分、向量化、检索、重排、Prompt 拼接、流式输出全部串成了一条可运行管线,并且附带了完整文档与答辩资料。适合正在做毕设、想把“能用”做到“能讲清楚原理”的 Python 方向学生,也适合想快速搭一个领域问答 Demo 的从业者。下面我从落地顺序讲,先把链路拆开看每个环节怎么调,再集中说医疗场景特有的坑。

2. RAG 链路拆解:加载、切分、Embedding、检索与重排的实现顺序

很多同学拿到源码第一步就去看大模型接口调用,这是本末倒置。RAG 系统的地基是“怎么把知识库变成可检索的向量索引”,这个环节的代码量不大,但每一步的参数都直接影响最终问答质量。这一章按我习惯的实现顺序讲:先做文档加载与切分,再做 Embedding 与向量库落库,最后做检索与重排。顺序不能乱——切分粒度决定了 Embedding 的语义单元,检索策略决定了 Token 怎么花。

2.1 文档加载与切分:为什么先按结构切比按字数切省一半调参功夫

我拆过不少 RAG 项目,常见翻车点是上来就用CharacterTextSplitter按固定字符数切,结果一段医学知识被拦腰截断,比如“成人一次 0.25g,每 6 小时一次”被切到两段里,检索时怎么都召回不全。这个毕设源码里推荐的做法是先按文档结构切,再按语义块合并。

from langchain_community.document_loaders import PyPDFLoader, TextLoader from langchain_text_splitters import RecursiveCharacterTextSplitter # 第一步:按文件类型加载 loader = PyPDFLoader("data/药品说明书.pdf") documents = loader.load() # 第二步:按标题/段落级别先切一次,保留结构信息 splitter = RecursiveCharacterTextSplitter( chunk_size=512, chunk_overlap=80, separators=["\n\n", "\n", "。", ";", ". "], keep_separator=True, ) chunks = splitter.split_documents(documents) # 第三步:给每个 chunk 打印前 50 字,人工抽检切分边界 for i, chunk in enumerate(chunks[:5]): print(f"--- chunk {i} ---") print(chunk.page_content[:50])

这里chunk_size=512是字符数还是 Token 数取决于你用的 splitter,RecursiveCharacterTextSplitter统计的是字符。对中文医疗文本,512 字大概能覆盖一段完整医嘱。chunk_overlap=80保留相邻片段的重叠区,避免“阿莫西林胶囊”这类词组恰好被切成“阿莫西林”和“胶囊”。separators里我把中文句号“。”和分号“;”加进去了,这样切分点更符合中文表达习惯。

做完切分后一定要做检查,而不是直接丢进向量库。我一般会写一个临时脚本统计 chunk 数量、平均长度,并随机抽 10 个 chunk 看有没有语义断裂。常见问题是药品说明书的“【适应症】”和“【用法用量】”被并到同一个 chunk,导致用户问“每天吃几次”时召回了“适应症”内容。解决方式是让加载器保留文档自带的结构元数据。你可以在PyPDFLoader之后,把 PDF 的标题层级写入metadata,再按metadata里的结构来切。

如果知识库里混着 PDF、Word、Markdown,最好先统一转成 Markdown 或纯文本再切。源码里提供了一个convert_to_text.py,它的逻辑是先判断扩展名,再分别调用pdfplumber和python-docx提取文字,最后统一编码成 UTF-8 写入中间目录。这一步不做的话,后面 Embedding 会出现一堆乱码,尤其是扫描版 PDF 缺 OCR 层,提取出来是空白。

2.2 Embedding 选型与向量库落库:维度、距离函数与索引参数

切分完成后的核心决策是选 Embedding 模型。源码默认用的是text2vec-large-chinese或者bge-large-zh-v1.5,这两个都是中文语料预训练,输出维度分别是 1024 和 1024。注意,不要用 OpenAI 的text-embedding-ada-002做中文医疗文本,除非你做了大量数据增强,否则医疗实体词不在它的分布里。

from sentence_transformers import SentenceTransformer from langchain_community.vectorstores import FAISS model_name = "BAAI/bge-large-zh-v1.5" encoder = SentenceTransformer(model_name) # 对每个 chunk 生成向量,并写入 FAISS 索引 vector_store = FAISS.from_documents( chunks, embedding=encoder, distance_strategy="COSINE", # 或 EUCLIDEAN ) vector_store.save_local("storage/faiss_index")

distance_strategy我建议用COSINE。医疗问答里,用户输入“头疼”和知识库里的“头痛”在字面上不同,但在向量空间里余弦相似度仍然会很高。而欧氏距离对向量模长敏感,不同长度文本的模长差异会干扰相似度排序。源码里默认也是COSINE,如果你的版本里默认是L2,记得改成COSINE。

落库之后,有一个容易被忽略的参数是FAISS的索引类型。FAISS.from_documents默认构建的是IndexFlatIP,它是暴力精确检索,数据量在几万条以内完全够用。如果知识库超过 10 万条,才需要切到IndexIVFFlat或HNSW。对毕设场景,几万条以内建议就用精确索引,别为了追求“高级”引入IVF,它需要训练索引,调不好召回率反而下降。

落库完成后,我强烈建议你做一次“检索自检”:拿 10 个种子问题去查向量库,看返回的 Top-5 是不是真的包含答案。这一步能直接暴露 Embedding 模型和知识库是否匹配。如果你发现“感冒了能吃什么药”召回的却是“高血压用药注意事项”,那说明你的知识库里感冒相关内容太少,或者切分时把感冒和高血压混在同一个文档块里了。

2.3 检索召回与重排:Top-K 怎么定,重排器要不要上

这是 RAG 链路里最“玄学”的环节。Top-K 设小了,答案可能不在召回结果里;设大了,Prompt 里塞进一堆无关内容,大模型容易被带偏。源码里的默认值是 K=5,但我在实际调试中很少直接用默认值。我的习惯是先跑 20 个测试问题,统计“答案所在 chunk 在召回结果中的排名”,如果答案经常出现在第 4 到第 5 位,就说明 K 设小了,或者切分粒度太粗。

retriever = vector_store.as_retriever( search_type="similarity", search_kwargs={"k": 8} ) docs = retriever.invoke("成人阿莫西林一次吃多少") for i, doc in enumerate(docs): print(i, doc.metadata.get("source"), doc.page_content[:30])

search_type除了similarity还可以用mmr。mmr会在相关性和多样性之间做平衡,避免返回的 8 个 chunk 全是同一篇文章的邻近段落。对医疗问答,我偏向先用similarity,因为领域知识库里同一话题的邻近段落往往正是需要的完整上下文,用mmr反而可能丢掉关键细节。如果你的知识库是多个来源混合的,比如既有药品说明书又有临床指南,可以考虑mmr,让答案覆盖不同来源。

重排器(Reranker)是很多人纠结的点。我的判断是:如果总文档量在 2 万条以内,且你的 Embedding 模型本身质量不错,重排器带来的增益不明显,但会引入额外的推理延迟。源码里留了bge-reranker-v2-m3的接口,但默认不启用。当你发现 Top-5 里前排结果经常“相关但不对症”,比如用户问“儿童用量”却召回“成人用量”,这时候再开重排器。用法是先用向量检索拿回 Top-20,再用重排器精排取 Top-5。

from transformers import AutoModelForSequenceClassification, AutoTokenizer reranker_model = AutoModelForSequenceClassification.from_pretrained("BAAI/bge-reranker-v2-m3") tokenizer = AutoTokenizer.from_pretrained("BAAI/bge-reranker-v2-m3") pairs = [[query, doc.page_content] for doc in docs[:20]] inputs = tokenizer(pairs, padding=True, truncation=True, return_tensors="pt") scores = reranker_model(**inputs).logits.view(-1).float().tolist() reranked = sorted(zip(docs, scores), key=lambda x: x[1], reverse=True)

注意bge-reranker-v2-m3是交叉编码器,它把问题和文档拼成一对做精细打分,速度比向量检索慢一个数量级,所以只对 Top-20 做精排是性价比最高的做法。毕设答辩时,哪怕你的系统最终没启用重排器,也要在 PPT 里把“为什么用/不用”讲清楚,这属于加分项。

3. 大模型生成环节:Prompt 模板、上下文拼接与流式输出的落地写法

检索拿到资料后,下一步是把它们拼进 Prompt 交给大模型。这个环节别看代码简单,影响回答质量的三个细节分别是:Prompt 模板结构、上下文长度控制、输出接口形态。源码里把这一块封装成了rag_chain.py,但直接复制它之前,你要先理解它为什么这么写。

3.1 Prompt 模板设计:把系统提示词、检索片段、对话历史拼成一条消息

一个常见错误是只有一段“你是医疗助手”的 system prompt,然后把所有检索 chunk 一字排开塞进 user。实践下来更稳的结构是三段式:先给身份与边界,再给检索依据,最后给用户问题。源码里的模板是这样的:

PROMPT_TEMPLATE = """你是一名严谨的医疗问答助手。请严格依据以下资料回答用户问题。 如果资料中没有明确信息,请直接回答“资料中没有找到相关内容”,不要自行猜测。 每条回答末尾用 [来源序号] 标注依据的资料编号。 资料: {context} 对话历史: {history} 用户问题:{question} """ def build_messages(question, context_chunks): context_text = "\n\n".join( f"[{i+1}] {chunk.page_content}" for i, chunk in enumerate(context_chunks) ) messages = [ {"role": "system", "content": PROMPT_TEMPLATE.format( context=context_text, history="", question=question, )}, ] return messages

这里的关键是[来源序号]的设计。它不只是为了展示,更是给大模型一个“内引用的锚点”,模型倾向于在生成时复述这些序号,从而降低自由发挥的概率。另一个细节是history字段,如果对话历史为空就传空字符串,不要省略这个 key,否则模板渲染会报错。

实际项目里,history可能来自前端传递的最近 N 轮对话。太长时要截断,否则上下文爆炸。我习惯只保留最近两轮用户和助手消息,因为更早的对话对当前回答几乎没有帮助,只会让模型注意力分散。

3.2 上下文窗口与 Token 控制:防止长上下文把生成质量拖垮

大模型的上下文窗口是硬约束。虽然现在很多模型宣称 128K 上下文,但实际使用时,中间部分的注意力会衰减。对医疗问答这种需要精确引用剂量/禁忌的场景,上下文越短越容易生成高质量回答。源码里给出的策略是:限制最多拼接 5 个 chunk,每个 chunk 最多 512 字符,加上系统提示和问题,总 Token 控制在 2500 以内。

MAX_CHUNKS = 5 MAX_CHUNK_CHARS = 512 def truncate_chunk(chunk_text, max_chars=MAX_CHUNK_CHARS): if len(chunk_text) <= max_chars: return chunk_text return chunk_text[:max_chars] + "……"

MAX_CHUNK_CHARS是字符数不是 Token 数,中文一个字大约 1.5 到 2 个 Token,512 字对应 800 到 1000 Token。5 个 chunk 加上对话历史与问题,总 Token 在 2500 上下,既不会超出主流开源模型的 4K 窗口,又能给出足够的证据稠密度。若你用的是 32K 上下文模型,也可以适当把MAX_CHUNKS提到 8,但收益会衰减。

调试时有一个很直观的检查方法:把拼好的 Prompt 打印出来,人眼读一遍。如果读到问题处已经忘了前面资料在说什么,说明上下文太长了。这在 API 调试器里不合适,但在本地跑源码时可以加一行logger.debug(f"Prompt tokens: {len(messages[0]['content'])}")手动估算。

3.3 流式输出与接口封装:Flask/FastAPI 返回格式与前端对接

源码后端用的是 FastAPI,/chat接口支持流式返回。流式不是炫技,而是因为大模型生成 300 个字的耗时可能超过 10 秒,如果不流式,前端页面会一直空白,用户以为系统挂了。

from fastapi import FastAPI from fastapi.responses import StreamingResponse app = FastAPI() @app.post("/chat") async def chat(request: dict): question = request["question"] chunks = retriever.invoke(question) messages = build_messages(question, chunks) def generate(): for token in llm.stream(messages): yield f"data: {token}\n\n" yield "data: [DONE]\n\n" return StreamingResponse(generate(), media_type="text/event-stream")

注意StreamingResponse的media_type必须是text/event-stream,前端EventSource或fetch的流式解析器才能正常工作。这里有个坑:EventSource只支持 GET 请求,不支持 POST,所以如果你前端用了EventSource,要么把接口改成 GET 用 query 传参,要么用fetch配合ReadableStream。源码前端用的是后者,比较稳妥。

对接时前端解析流式数据要注意格式。每一行data: {token}里的 token 可能是不完整的中文,前端不要逐字渲染,最好缓冲一个字再显示,否则会出现半个汉字闪烁。源码里的 Vue 页面就是这么处理的,onmessage里先拼进一个 buffer,每 50ms 判断一次是否有完整 UTF-8 字符再刷到 DOM 上。

4. 医疗问答的领域陷阱:药品名、剂量与同义词怎么处理

通用 RAG 教程不会告诉你,医疗问答里“词汇层面”的问题比“语义层面”更致命。用户不会按标准药典名提问,他会说“阿莫仙”,但说明书里写的是“阿莫西林”。向量检索对这种字面差异的容忍度有限,尤其在短文本场景下,必须做实体归一化。

4.1 医疗实体归一化:为什么“阿莫西林”和“阿莫仙”必须映射到同一 ID

归一化不是 RAG 的必备环节,但对医疗问答几乎是必选项。不做的表现是:用户问“阿莫仙怎么吃”,向量检索返回的是“阿莫仙”在词典条目里的定义,而“阿莫西林”的用法用量在另一个 chunk 里,两者向量距离较远,导致检索召回错误。解决思路是构造一个别名表,在查询改写阶段把别名替换成标准名。

ALIAS_MAP = { "阿莫仙": "阿莫西林", "安必仙": "氨苄西林", "泰诺": "对乙酰氨基酚", "扑热息痛": "对乙酰氨基酚", } def normalize_query(query: str) -> str: for alias, std_name in ALIAS_MAP.items(): if alias in query: query = query.replace(alias, std_name) return query

ALIAS_MAP的维护是体力活,我一般先把药典里的通用名和商品名整理成表,再手工补几十条高频别名。不要指望用大模型自动做映射,它会造出不存在的关系。源码的data/alias.csv里预置了约 300 条常见药品别名,修改它比改代码更安全。注意映射方向,只能是“别名 → 标准名”,不能反过来,否则用户输入标准名会被替换成商品名,反而丢失语义。

4.2 剂量与单位解析:正则抽取“一次 0.25g,一日三次”这类医嘱

剂量问答是医疗问答系统的高频测试点,也是最容易出错的地方。原因是剂量描述充满变体:“0.25g”“250mg”“每次一粒”“一日 2~3 次”。源码里内置了一个dosage_parser,用正则做粗抽取,再用单位换算做归一化。

import re DOSAGE_PATTERN = re.compile( r"(一次|每次|顿服)?\s*" r"(\d+(?:\.\d+)?|半|一|两|三)?\s*" r"(mg|g|ml|粒|片|袋|支)?" ) def parse_dosage(text: str): matches = DOSAGE_PATTERN.findall(text) result = [] for dose, num, unit in matches: if not num or not unit: continue result.append({ "dose": dose, "num": num, "unit": unit, }) return result

这个解析器很简陋,但它能处理“一次 0.25g”“一日三次”这类常见句式。真正的坑在单位换算:如果知识库里写“250mg”,用户问“0.25g”,检索召回后直接拼接原文也能答对,但如果你想做结构化答案展示,就需要mg和g的换算。源码里定义了一个UNIT_RATE = {"mg": 0.001, "g": 1, "ml": 1},乘以系数统一换算成g或ml再比较,这能避免“一次 500mg”和“一次 0.5g”被判定为不同剂量。

另一个细节是“一日三次”里的“次”和“一次 0.25g”里的“次”冲突。正则里(一次|每次|顿服)是前置修饰符,而“一日三次”中的“次”是频率词,两者要分开解析。我建议把频率解析拆成独立正则,匹配“一日 N 次”“每日 N 次”“每 X 小时一次”等模式,结果存成frequency字段。如果用户直接问“一天吃几次”,系统优先从frequency字段匹配,再查向量库,速度更快也更稳。

4.3 知识库与结构化知识结合:何时用 RAG、何时直接查规则表

RAG 不是万能的。像“青霉素过敏者禁用”这类禁忌,如果知识库里有明确条目,RAG 可以召回。但如果用户问“孕妇能不能吃布洛芬”,说明书里可能写了“孕妇慎用”或“禁用”,不同版本说明书措辞不同。这时候更适合维护一张结构化的禁忌表,用规则引擎直接命中,而不是靠向量相似度。

源码里做了一个双路查询:先用规则表精确匹配实体与属性,匹配不到再走 RAG。这个设计在答辩时非常加分,因为评委能看到你理解了两种检索范式的边界。实现上很简单:

def hybrid_query(question: str): # 先查结构化规则表 rule_result = rule_engine.search(question) if rule_result: return rule_result # 再走 RAG chunks = retriever.invoke(question) return build_rag_answer(question, chunks)

rule_engine的底层是一张 SQLite 表,字段包括drug_name,attribute,value,source。例如(布洛芬, 孕妇, 禁用),(布洛芬, 儿童, 慎用)。当用户问题同时包含药品名和人群词时,用正则抽取这两个槽位,然后精确查表。查不到再走 RAG,既保证了高频规则的高确定性,又能保留 RAG 的开放性。注意规则表的准确率必须是 100%,否则宁可让 RAG 给出带引用的模糊答案,也不要从规则表给出错误结论。毕设演示时,规则表里的数据量不用大,但每条一定要准。

5. 避坑指南:四个让 RAG 问答翻车的常见问题与排查思路

这一章是重头戏。下面四条都是我在实际跑这个源码时真实踩过的坑,每一条都按“现象 → 原因 → 解决”写,你可以直接对照排查。

5.1 检索结果相关但答案错误:双层召回与重排的补偿

现象:用户问“儿童阿莫西林用量”,检索返回的 chunk 确实都是阿莫西林相关内容,但答出来的剂量是成人剂量。这说明向量检索没有区分“儿童”和“成人”这两个细粒度实体。原因有两种可能:一是知识库里儿童剂量和成人剂量在同一个 chunk 里,切分时没分开;二是 Embedding 模型对“儿童”这个修饰词的敏感度不够。解决方案是先检查切分边界,如果同一 chunk 有两条剂量,改成按段落切;如果已经切分,再考虑对查询做实体扩充,把“儿童”映射成“小儿”“儿童用药”等同义词,多路召回后再合并。我在这个项目里最常用的是先把知识库里的所有[儿童]与[成人]段落做了标签切分,再在检索时做一次用户年龄意图分类,这一步能显著提升正确率。

5.2 回答幻觉:在 Prompt 里做“无据不答”约束并加引用标记

现象:大模型在资料不充分时编造了“每次 500mg”,但原作者说明书里根本没有这个数据。原因是单纯把资料拼进 Prompt,没有对模型做强约束。解决方法是修改 Prompt 中的指令词,把“如果没有相关资料,请直接回答不了解”改成更强制性的表达:“以下每条回答必须能在资料中找到直接依据,若没有依据,请只输出‘资料中未找到相关内容’。”同时要求模型在每个关键数值后加 [来源序号]。我做过的实验里,加引用标记后幻觉率大约下降四成。另一个辅助手段是对回答做后校验:用正则把回答中的剂量数值抽出来,和检索结果里的数值做比对,不一致就发警告。这个后校验不是必选项,但能在演示时体现你的工程严谨度。

5.3 性能瓶颈:向量索引构建慢、并发查询超时的调优顺序

现象:本地跑源码时,第一次构建 FAISS 索引花了十几分钟,并发 5 个查询时接口响应超过 30 秒。原因一般是三个:Embedding 模型在 CPU 上跑太慢、FAISS 索引类型不适合并发、FastAPI 没有配置线程池。调优顺序不要乱。先看 Embedding 是否为 CPU 推理,如果是,换onnxruntime版本或用 GPU;再看热启动时是否每次请求都重新加载模型,如果是,把它提到全局变量;最后看 FAISS 索引,IndexFlatIP在单线程下检索本身很快,瓶颈更多是 Embedding 推理。源码里有一个--preload启动参数,会在服务启动前构建索引并且把模型常驻内存,这个参数在演示前一定要用。

5.4 中文医疗分词差异:自定义词典与实体链接的配合

现象:用户输入“咽炎能吃头孢么”,系统把“头孢”识别成了“头”和“孢”,导致召回不准确。原因是向量检索虽然不需要分词,但如果你用了关键词命中做前置过滤,或者用了jieba做查询改写,默认词典里没有“头孢”这个词。解决方式是在jieba里加载自定义医疗词典,把常用药品名、疾病名、症状名加进去。要注意自定义词典的词频设置,设得太高会影响分词泛化。源码的data/medical_dict.txt里每行一个词,用jieba.load_userdict加载。这个文件不是越大越好,把高频 500 词维护好就够了。另外一个更稳的方案是直接跳过 jieba,用向量检索为主、规则匹配为辅,不依赖分词正确性。对毕设而言,两条路都可用,但如果你要答辩讲技术细节,建议把“为什么不做分词”也说清楚,因为 RAG 的向量模型天然支持整句匹配,分词反而会丢失上下文。

6. 把毕设做成能演示的系统:验证指标、示例问答与本地部署技巧

最后这一章不讲原理,只讲怎么在答辩前把系统调到“看起来可靠”的状态。能用和能演示是两码事,演示版需要更稳定的指标、更快的响应和更不容易出错的交互路径。

6.1 用 20 个黄金问题做回归测试:准确率、命中率与引用率怎么算

我在跑这类项目时习惯准备 20 个覆盖典型场景的黄金问题,比如“成人阿莫西林一次吃多少”“孕妇能不能吃布洛芬”“感冒和流感的区别”“头孢和青霉素过敏怎么办”。每个问题预先把正确答案写进测试集,然后跑脚本统计三个指标。

golden_questions = [ {"question": "成人阿莫西林一次吃多少", "expected_answer_contains": ["0.25g", "250mg", "一次一片"]}, {"question": "孕妇能不能吃布洛芬", "expected_answer_contains": ["禁用", "慎用", "医生说"]}, ] hits = 0 count = 0 for item in golden_questions: answer = chat_sync(item["question"]) count += 1 if any(kw in answer for kw in item["expected_answer_contains"]): hits += 1 accuracy = hits / count

这个脚本评估的是“答案是否包含关键事实”。不要要求模型逐字和标准答案一致,医疗问答的表述本来就有多种。除了准确率,还要统计“引用率”:看回答是否都带上了[1]、[2]这样的来源标记。如果某些回答没有引用,说明模型跳过了检索依据,那可能是 Prompt 约束失效。黄金问题不要只选简单的,至少包含 3 个边界问题,比如“查不到的”“跨科室的”“同义词替换的”,这样能暴露系统的真实短板。

6.2 环境复现:Python 虚拟环境、依赖冻结与一键启动脚本

毕设源码能不能在答辩电脑上顺利跑起来,80% 取决于环境是否干净。源码目录里有requirements.txt,但我拿到后第一件事是把所有包升到兼容版本并重新冻结。推荐用venv而不是conda,因为 conda 在演示电脑上可能没有装,而python3 -m venv是标准库自带。

python3 -m venv .venv source .venv/bin/activate pip install -r requirements.txt python -c "import torch; print(torch.__version__)"

安装时最容易出问题的是sentence-transformers需要的 torch 版本。源码的requirements.txt里没有锁死 torch 版本,如果你本地是 CPU 环境,建议安装 CPU 版 torch,体积小一半。加载模型时把所有模型文件缓存到本地目录,避免演示现场联网下载。源码里默认会从 HuggingFace 拉取模型,如果你在答辩现场没有外网环境,提前跑一遍脚本让模型缓存进~/.cache/huggingface,或者用--model_path指向本地目录。这一步不做好,现场红温就晚了。

6.3 演示前必做的三件事:预构建索引、缓存答案、关闭调试模式

第一件事是预构建索引。不要等到应用启动时再构建 FAISS,如果文档有几万条,构建需要几分钟。源码里有build_index.py,跑一次生成storage/faiss_index,之后应用启动直接读取索引文件就能秒开。第二件事是热点问答缓存。把黄金问题里最常用的 10 个问题提前跑一遍,把问题和答案存进 Redis 或简单的字典缓存。演示时无论网络怎么抖动,这 10 个问题都能秒回。第三件事是关闭调试模式。FastAPI 的--reload和 Flask 的debug=True在演示时一定要关,否则每次请求都会触发文件监视和额外的堆栈采集,速度明显变慢。

if __name__ == "__main__": import uvicorn uvicorn.run("app:app", host="0.0.0.0", port=8000, reload=False)

把reload=False写死是一种习惯。我见过太多现场演示因为开着 reload,改了一行无关紧要的 CSS 导致服务自动重启,然后所有内存里的向量索引全部重新加载,等了三分钟页面还没起来。从那以后,我做毕设答辩前一定花 10 分钟完整走一遍演示脚本:启动服务、问黄金问题、检查引用标记、测试一个边界问题,再截一张成功回答的图存到本地备用。这套习惯不复杂,但它能避免九成以上的演示翻车,希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询