这个项目起因是我自己查资料时的麻烦。默沙东手册内容很全,但得用对关键词才搜得到,我用大白话问经常搜不出来;直接丢给大模型问,它又会编剂量和诊断结论,医疗场景这样肯定不行。所以想做一个先查资料、再回答的东西,答案尽量有原文撑着。
功能上主要几块。检索是核心,走了 BM25 加语义两条路,因为医学里药名、缩写、数值区间这类内容必须精确匹配,纯向量检索容易漏。多轮对话做了持久化,历史存在 SQLite 里,服务重启不丢;像"那饮食上呢"这种省略式追问,会先结合上文补成完整问题再去检索。前端是流式输出,边生成边显示,答完给几条相关追问。再往后发现光能查还不够:用户问一句,系统就只按这一句去查,查什么、查几次都是写死的。所以又加了一层 Agent,把决定权交给模型自己——什么时候检索、要不要读全文、信息不够时先反问用户,一共给了它 8 个工具,除了检索和读全文,还能查天气、拿定位、记住用户的病史和用药。顺手加了识图,化验单或者检查报告拍下来传上去,会自动把指标读出来,生成一份带风险等级和就诊建议的评估报告。另外把能力封成了一套 MCP 服务,其他 AI 客户端可以直接调用。
流程是:用户提问 → 判意图(医疗咨询、急症、闲聊分开处理)→ 取会话历史 → 追问改写 → 双通道检索 → 排序过滤 → 大模型生成 → 流式返回 → 结果落库缓存。这是流水线的走法;接上 Agent 之后,中间的检索改由模型按需触发,其余环节不变。
知识库这块单独做了处理。爬下来的文档要先清洗,归一化全半角、去掉引用标记和爬虫造成的重复行;分块按段落切,小标题并进正文,相邻片段之间保留约 60 字的上下文重叠,避免一句话正好被切在边界上,检索时缺了上文。
整体分了几层:数据层做文档清洗和分块,SQLite 存会话和缓存;检索层是 BM25 加 BGE 语义双通道,用 RRF 融合排序;对话层管意图分类、追问改写和会话记忆;服务层用 FastAPI 加 SSE 做流式;最外面套了一层 MCP 协议,方便被别的 AI 工具调用。技术栈是 Python、FastAPI、PyTorch(sentence-transformers)、BM25、SQLite。
后来又补了一层 Agent,夹在对话层和生成之间。做法是用 Function Calling 起一个工具调用循环,模型每轮自己决定调哪个工具,工具结果回填后继续决策,直到它认为可以作答。8 个工具里除了检索、读全文、看目录,还有查天气、拿定位、读写用户画像和反问用户。循环做了三重保护:步数上限、单步超时、工具异常降级,防止模型反复调同一个工具或者中途卡死。识图是在这基础上加的——先把图片交给视觉模型提取成文字,再用这段文字去检索,最后把原图和检索结果一起给报告模型。
代码和测试都是我一个人写的。做了个 20 条的小评测集,10 条多轮追问加 10 条单轮对照,多轮那部分改造前 10 条里只有 1 条能找对内容,改完都过了。另外写了 139 个单元测试,覆盖检索阈值、文档清洗与分块、会话存储、追问改写、Agent 工具循环和报告生成这几块。知识库清洗前后的对比:重复内容原本占 26.4%,现在基本清干净,平均片段长度从 544 字降到 454 字。
踩的坑里有几个印象比较深。
一个是多轮追问查不到东西。问完"高血压要注意什么",接着问"那饮食上呢",答得完全不相干。翻日志才发现检索那一步只用用户原话,对话历史只喂给了生成模型,等于检索根本不知道上文在聊什么。后来在检索前加了一步查询改写,先让模型把追问补成完整问题,同时做了启发式判断,只有短句或者带指代词的时候才触发,正常提问不会多走一次模型。
另一个是我自己改出来的。加查询改写的时候插错了位置,把一个普通函数写成了生成器函数,接口直接返回 500,日志里报 'generator' object is not subscriptable。定位之后补了个回归测试,专门断言那个函数不能是生成器。
加 Agent 的时候也踩了一个:推理模型不支持强制工具调用那个参数,传过去直接报 400,改成提示词约束加代码兜底——首轮要是没去检索,就补一句要求它先查资料再答,只补一次,免得跟模型来回拉扯。
还有两个是数据和评分上的问题。抓下来的文档里每个列表项都重复输出了两遍,重复内容一度占 26.4%,会直接影响 BM25 的词频和排序,后来在清洗阶段合并相邻重复行解决。另外原来的相关性判断用的是 RRF 融合分,但 RRF 是排名分不是相似度,阈值卡在 0.04 经常误判,后来换成语义余弦相似度,用实测数据重新标了阈值