简介在垂直领域问答系统中如何兼顾答案的准确性与覆盖面知识图谱与大模型的融合提供了一条可行路径。知识图谱通过实体、关系与属性的结构化建模将中医体质、食材、穴位等概念互联使查询具备确定性和可解释性而大模型则能基于上下文生成自然、连贯的表述。两者的结合让系统既能在图谱命中的场景下给出有据可查的结论又能对长尾问题灵活兜底。基于Python后端与Neo4j图数据库构建的问答链路配合纯HTML前端可落地实现一套双模式问答应用。本文从知识图谱建模、实体识别、Cypher查询、大模型Prompt设计到前后端联调完整复盘了开发过程中的关键决策与踩坑经验为构建中医药养生领域的智能问答系统提供工程参考。 你有没有遇到过这种情况一个中医养生问题问大模型它说得头头是道但你照着做又总觉得哪儿不对翻公众号、查百科信息又散得抓不住重点。我在做这个中医养生问答系统之前把市面上的养生问答工具都试了一圈结论是纯靠大模型它爱“编”纯靠人工整理又覆盖不了多少问题。所以这个项目从一开始就定了一条路——后端用 Python前端用 HTML把基于知识图谱的确定性问答和基于大模型的生成式问答拼到一起让两条链路各干各擅长的事。这篇文章就是整个系统的完整复盘从知识图谱建模、Python 后端链路到纯 HTML 前端的实现再到双模式融合时踩过的坑全写出来给正在做垂直领域问答系统的同学一个可落地的参考。1. 中医养生问答为什么要“知识图谱大模型”双轨并行1.1 大模型单打独斗的老大难问题养生知识也敢“一本正经地胡编”先说我最开始用纯大模型做养生问答的体验。你问“脾胃虚寒的人适合吃什么”它的回答乍看非常完整会提到山药、生姜、红枣会建议你少吃生冷还会贴心地给出一周食谱。但你再追问“脾胃虚寒和脾胃湿困的区别”它可能就开始含糊了甚至会把“湿困”和“湿热”混在一起讲。这不是提示词写得不好而是大模型的本质决定的——它在做概率生成不是在做知识检索。养生这个领域有个很麻烦的特点同一个证型在不同体质、不同季节、甚至不同地域下的结论可能完全不同。大模型如果缺乏一个结构化的知识底座它只能靠训练语料里“最可能出现的接法”来回答这就很容易出现信息过时、概念混淆、因果倒置的问题。更重要的是养生类问题涉及人的健康用户对“依据”的需求非常强。你给一个答案用户很可能追问“为什么”这时候大模型能给出理由但那套理由未必经得起推敲。1.2 知识图谱存在的意义确定性答案和可解释路径知识图谱的价值恰好补上这个短板。它把“体质”“食材”“穴位”“症状”“养生方法”这些概念做成节点把“宜食”“忌食”“主治”“对应体质”做成关系。用户问“气血两虚怎么调理”系统可以先解析出“气血两虚”这个体质实体然后沿着图谱查找它对应的调理原则、推荐食材、相关穴位整个过程是确定性的、可回溯的。比如图谱里存了这么一条路径(体质:气血两虚)-[:宜食]-(食材:桂圆)那么这个结论不是模型“猜”出来的而是数据里明确写出来的。再加上 Cypher 查询天然支持多跳我们可以回答“气血两虚的人有哪些食材既补气又补血”“这类体质要避免哪些属性的食物”之类需要跨实体组合的问题。这些查询在传统关系数据库里要写一堆 JOIN在知识图谱里一条多跳路径就出来了。所以知识图谱的核心价值不是“快”而是可解释和强约束。它能保证答案里的关键事实是从结构化知识里来的不是模型临时生成出来的。1.3 双轨模式的用户价值一个答案负责准一个答案负责全那是不是只用知识图谱就够了也不是。知识图谱的覆盖面取决于你预先建模的数据量而养生问题天然是长尾的。用户可能问“最近总熬夜眼睛干涩喝什么茶能缓解”这个问题里“熬夜”不是标准的证型“眼睛干涩”和某个食材的关系图谱里可能也没存。这时候如果只走图谱就必然落到“查无此答”体验非常差。所以我把系统做成双轨图谱问答负责稳和准大模型问答负责全和活。当一个查询能命中图谱里的实体和关系就优先走图谱链路返回结构化的知识依据如果图谱链路查不到或者用户的问题明显是开放式、需要在多个知识点之间做综合判断就走大模型链路。这样用户既能看到有据可查的答案核心也能获得大模型补充的上下文解释。后面我会详细讲这套路由是怎么设计的。2. 中医养生知识图谱本体设计、数据抽取与 Neo4j 落地2.1 先定实体和关系这是图谱的地基图谱建模是整件事里最不能省步骤的一环。我最初犯过一个错误急着先搭后端再随手建几个节点验证流程。结果数据一多关系就乱套了——“山药”既是食材又是药材那它到底归哪个类型“气虚质”是一种体质但它同时对应一堆症状这些症状本身要不要单独建模这些问题一开始不定义清楚后面的查询和扩展都会很痛苦。我最终定义的本体结构如下实体类型说明示例Constitution 体质中医体质分类气虚质、血瘀质、湿热质Organ 脏腑中医脏腑概念脾、胃、肝、肾、心Symptom 症状常见主观症状食欲不振、失眠、乏力、口苦Acupoint 穴位经络穴位足三里、关元、三阴交Food 食材可食用或药食同源山药、生姜、枸杞、薏米Herb 药材中药材黄芪、当归、茯苓Method 养生方法非药物疗法艾灸、泡脚、八段锦Season 季节季节属性春、夏、秋、冬关系类型我控制得很克制只保留了养生问答里最高频的几种(Constitution)-[:宜食]-(Food)这种体质适合吃什么(Constitution)-[:忌食]-(Food)这种体质不建议吃什么(Symptom)-[:对应体质]-(Constitution)症状可能指向哪种体质倾向(Food)-[:性味]-(属性节点)食材的寒热温凉平属性(Food)-[:归经]-(Organ)食材入哪条经/哪个脏腑(Acupoint)-[:主治]-(Symptom)穴位改善什么症状(Method)-[:适合]-(Constitution)养生方法适合的体质(Season)-[:影响]-(Organ)季节对脏腑的影响关系需要注意的是“性味”不建议直接做成节点的属性而是单独做成一个属性节点如“温”“寒”“平”这样可以方便地回答“哪些食材是温性的”“寒性体质的禁忌食材有哪些”这类按属性筛选的问题。2.2 用 Python 清洗数据并批量写入图谱本体定好之后下一步是数据来源。我没有自己去编数据而是从公开的中医养生资料、百科词条、药膳食谱网站里整理了一批基础数据。这里必须要提醒一句网上抓下来的数据质量参差不齐千万别直接入库。比如“山药”在某些资料里写的性味是“甘、平”另外一些资料写的是“甘、温”如果不做归一化同一实体就会出现互相矛盾的属性。我的清洗流程大概是这样的先对食材、药材做名称归一化把“淮山”“山药”“薯蓣”归到同一个标准实体“山药”对体质名称做统一表达“气虚体质”和“气虚质”合并对关系类型做约束校验比如“忌食”关系里的食材不能同时出现在同一体质的“宜食”里除非有特殊说明否则视为脏数据最后通过人工抽查的方式定期校验一批关键关系。写入 Neo4j 我用的是py2neo库的批量接口。一次性逐条create节点会非常慢而且会在中途创建很多重复节点正确做法是先合并实体再建立关系。以下是一个简化的批量写入示例from py2neo import Graph, Node, Relationship graph Graph(bolt://localhost:7687, auth(neo4j, password)) def upsert_entity(label, name, **props): # 使用 MERGE 避免重复创建同一实体 cypher fMERGE (n:{label} {{name: $name}}) SET n $props RETURN n return graph.run(cypher, namename, propsprops).evaluate() def create_relation(src_label, src_name, rel_type, dst_label, dst_name): # 先确保两端实体存在再建立关系 src upsert_entity(src_label, src_name) dst upsert_entity(dst_label, dst_name) rel Relationship(src, rel_type, dst) graph.create(rel) # 示例写入“气血两虚宜食桂圆” create_relation(Constitution, 气血两虚, 宜食, Food, 桂圆)实际工程中我不会用这么简单的函数一条条调而是会先把数据整理成批量列表用UNWIND语法一次性写入。原因很简单Python 到 Neo4j 的往返开销很大一条条写入十万条关系的话性能完全没法看。批量写的方式大致是这样UNWIND $rows AS row MATCH (a:Constitution {name: row.src}) MATCH (b:Food {name: row.dst}) MERGE (a)-[:宜食]-(b)这比逐条执行快一个数量级也方便在导入脚本里做异常收集。2.3 从图谱到可查询的 Cypher先想好问题再设计模型建模和数据入库之后真正的考验是查询。我建议在设计本体的时候就先把“这个系统需要回答哪些类型的问题”列出来再反推图谱里需要哪些实体和关系。这个思路非常重要否则很容易出现“建了一堆节点用户问的问题全查不出来”的窘境。我这个系统里固定了以下几类查询模板问题类型用户提问示例查询路径体质调理气血两虚怎么调理(Constitution)-[:宜食]-(Food)(Constitution)-[:忌食]-(Food)症状食疗失眠吃什么好(Symptom)-[:对应体质]-(Constitution)-[:宜食]-(Food)穴位按摩按哪个穴位能缓解头痛(Acupoint)-[:主治]-(Symptom)食材功效薏米是凉性还是温性(Food)-[:性味]-(Attr)季节养生秋天适合养什么脏腑(Season)-[:影响]-(Organ)以“症状食疗”为例用户说“我最近失眠多梦”系统解析出“失眠”这个症状实体然后执行这样的 CypherMATCH (s:Symptom {name: $symptom})-[:对应体质]-(c:Constitution) MATCH (c)-[:宜食]-(f:Food) OPTIONAL MATCH (c)-[:忌食]-(bad:Food) RETURN c.name AS constitution, collect(DISTINCT f.name) AS recommended_foods, collect(DISTINCT bad.name) AS avoid_foods LIMIT 10这里用OPTIONAL MATCH是为了避免某些体质没有“忌食”数据时推荐列表也被一并过滤掉。这个细节我在初版踩过坑后面专门写一节详细说。3. Python 后端从实体识别到大模型调用的完整链路3.1 技术选型FastAPI py2neo 为什么比 Flask 更合适后端框架我选了 FastAPI而不是更常见的 Flask。一个很现实的原因是这个系统除了要处理图谱查询还要调大模型接口中间涉及到不少耗时操作。FastAPI 从底子上就是异步的配合async/await处理外部 HTTP 调用非常自然。Flask 本身是同步框架虽然也能用线程池绕过去但写起来远不如 FastAPI 简洁。FastAPI 另一个优势是自动生成 OpenAPI 文档。前端联调的时候可以直接打开/docs页面看接口参数省了很多口口相传的时间。项目结构我大致是这样组织的tcm_qa/ ├── main.py # FastAPI 应用入口 ├── kg/ │ ├── graph_client.py # Neo4j 连接与查询封装 │ ├── query_templates.py # Cypher 模板 ├── nlp/ │ ├── entity_recognizer.py # 实体识别模块 │ ├── intent_classifier.py # 意图分类模块 ├── llm/ │ ├── llm_client.py # 大模型 API 封装 │ ├── prompts.py # Prompt 模板 ├── frontend/ │ ├── index.html │ ├── style.css │ └── app.js3.2 实体抽取词典和规则比训练模型更划算很多人在做垂直领域问答时会陷入一个误区一上来就想训练一个命名实体识别模型。但对一个养生问答系统来说实体范围是封闭的——你图谱里有什么实体用户问题里能出现的相关实体就是有限的。与其花大力气训练模型不如先把词典匹配和规则匹配做好。我用了jieba分词并加载了一份自定义中医词典。词典里把“脾胃虚寒”“气血两虚”“肝肾阴虚”这类常见复合词整词收入避免被通用词典错误切分。举个实际的例子不加自定义词典时jieba会把“脾胃虚寒”切成“脾”“胃”“虚寒”之后做实体匹配就会很尴尬——“脾胃虚寒”在图谱里是一个标准体质节点拆成“脾”“胃”“虚寒”之后就完全匹配不上了。加载自定义词典后“脾胃虚寒”能作为一个完整的 token 被切出来实体匹配的成功率立刻上来了。除了分词我还做了一层简单的规则对用户输入按照“实体 意图词”的模式来识别。比如“失眠”“睡不着”“入睡困难”都属于“失眠”这个症状实体的同义表达。我维护了一份同义词映射表把口语化的表达都归一化到标准实体名上SYNONYMS { 睡不着: 失眠, 入睡困难: 失眠, 多梦: 失眠, 拉肚子: 腹泻, 大便稀: 腹泻, 没力气: 乏力, 浑身没劲: 乏力, }这一步不用做得太复杂先把高频口语映射覆盖到剩下的由大模型链路兜底。3.3 基于模板的 Cypher 查询生成与答案构造实体识别完成之后下一步是把“实体 意图”映射到具体的 Cypher 模板。我在query_templates.py里维护了一个模板映射表INTENT_TEMPLATES { (Symptom, 调理): symptom_to_recipe, (Constitution, 宜食): constitution_food, (Acupoint, 主治): acupoint_symptom, (Food, 性味): food_property, }每个模板对应前面表格里提到的一条 Cypher 语句。执行查询后返回结果后端需要做一次结果组装把它变成前端友好的 JSON。比如“症状食疗”查询返回的结果里可能同一个食材既在“宜食”里又出现在“忌食”里不同体质来源这种情况就要做一个聚合去重再按优先级排序。这部分还有一个容易忽略的问题Neo4j 返回结果的序列化。py2neo的查询结果本质上是Record对象直接丢给 FastAPI 的 JSONResponse 会报错。我统一用一层 data class 把结果转成纯 Python 的 dict再交给接口返回。class RecommendedFoods(BaseModel): constitution: str recommended: list[str] avoid: list[str]3.4 大模型接入与 Prompt 设计要点大模型链路我封装了一个LLMClient统一走 OpenAI 兼容的 HTTP 接口。这么做的好处是后面想换模型供应商只需要改 base_url 和 model 名称不用动业务代码。from openai import OpenAI class LLMClient: def __init__(self, api_key, base_url, model): self.client OpenAI(api_keyapi_key, base_urlbase_url) self.model model async def ask(self, question: str, kg_context: str | None None) - str: messages [{role: system, content: self.system_prompt()}] if kg_context: messages.append({ role: system, content: f以下是知识图谱查询到的参考事实请优先引用\n{kg_context} }) messages.append({role: user, content: question}) resp self.client.chat.completions.create( modelself.model, messagesmessages, temperature0.4, timeout30, ) return resp.choices[0].message.contentPrompt 的设计是整个大模型问答链路的核心。我给系统提示词定了三条硬性要求如果知识图谱提供了参考事实回答必须以这些事实为基础展开不能另起炉灶编造新的养生结论对图谱未覆盖的内容要明确使用“一般认为”“在中医养生理论中”这种相对谨慎的表达避免绝对化判断所有回答末尾必须附加“本内容仅供参考不能替代专业医疗诊断”。temperature我设成了 0.4兼顾回答的稳定性和语气的自然度。养生问答不是创意写作不需要太高的随机性太低又容易让表达变得机械。4. HTML 前端不用框架也能做出能用的聊天界面4.1 页面结构与样式先搭一个能看的聊天框前端我刻意没上 Vue 或 React原因是这个页面的交互并不复杂——一个输入框、一个消息列表、两个结果展示区。纯 HTML CSS JavaScript 完全可以胜任而且部署简单到可以直接扔进 Nginx。页面大体结构如下div idapp div idchat-header中医养生问答/div div idchat-messages !-- 这里动态渲染问答消息 -- /div div idchat-input-area input typetext idquestion-input placeholder例如脾胃虚寒吃什么好 / button idsend-btn发送/button /div /divCSS 我保持了极简风格上方固定标题栏中间消息区可滚动底部输入区固定。消息分为“用户消息”“图谱答案”“大模型答案”三种卡片用不同的左边框颜色区分。图谱答案卡片左侧是绿色边框大模型答案卡片是蓝色边框用户消息没有边框、右对齐。这样用户一眼就能分辨当前答案的来源。4.2 fetch 调用后端接口与异步处理前端调后端接口我用的是原生fetch没有引入 axios。好处是整个页面零依赖打开即用。发送问题时先把用户消息渲染到聊天区然后发起异步请求async function sendQuestion(question) { // 先渲染用户消息 appendMessage(user, question); // 显示“正在思考”状态 const loadingEl appendLoading(); try { const resp await fetch(/api/qa, { method: POST, headers: { Content-Type: application/json }, body: JSON.stringify({ question: question }) }); const data await resp.json(); loadingEl.remove(); // 根据返回数据类型渲染不同卡片 renderAnswer(data); } catch (err) { loadingEl.remove(); appendMessage(system, 网络开小差了稍后再试吧); } }这里有个在开发时很容易踩的细节fetch请求如果没有设置超时遇到后端大模型接口响应慢的时候页面会一直挂在 loading 状态。我在前端给AbortController设置了一个 30 秒的超时超过之后主动中断请求并提示用户超时后重试。这个体验上的细节在大模型接口不稳定时非常重要。4.3 图谱答案和大模型答案的分区展示后端返回给前端的 JSON 结构我定义为{ source: kg | llm | hybrid, answer: ..., kg_nodes: { constitution: 气血两虚, recommended: [桂圆, 山药, 红枣], avoid: [苦瓜, 绿豆] } }前端拿到数据后kg_nodes存在就用绿色卡片渲染图谱事实列表answer字段用蓝色卡片渲染完整答案。如果source是kg说明大模型只是对图谱结果做了扩展阐释如果source是llm说明这条答案完全来自大模型前端会在卡片顶部加一个“AI 生成仅供参考”的角标。不要小看这个卡片细节。在养生类问答里信息来源的可信度直接影响用户的采纳意愿把“图谱依据”和“AI 生成”可视化地区分开等于变相给用户交了个底。5. 双模式路由与答案融合的工程细节5.1 图谱优先、大模型兜底的路由逻辑双模式路由是整个系统的中枢。我在后端/api/qa接口里实现了这样一套策略对用户问题进行实体识别和意图分类。如果识别出至少一个图谱实体且意图能匹配到模板执行图谱查询。图谱查询有结果时把结果作为参考上下文发送给大模型生成一段完整的文字回答source标记为hybrid或kg。图谱查询没有结果或实体识别失败直接走纯大模型问答source标记为llm。为什么要“图谱查到了还要再走一次大模型”因为图谱查出来的是碎片化的节点数据比如“宜食山药、桂圆忌食苦瓜”直接把这种结果丢给用户体验很生硬。让大模型基于这些结构化事实做一次组织不仅能生成通顺的表达还能补充一些食材搭配原理之类的背景信息用户阅读体验会好很多。但也有一个例外当图谱查询结果非常完整、答案已经是标准句式时我会跳过二次生成这个大模型调用直接把图谱结果拼装成答案返回。比如“薏米的性味是什么”这种简单属性查询图谱返回“薏米性微寒归脾、胃、肺经”就不需要再让大模型加工了。这样既省了一次大模型调用的费用也让答案更快返回。5.2 图谱查询降级的条件与处理降级逻辑做得不好很容易出现用户重复问同一个问题系统每次都先白等一次图谱查询的超时才慢吞吞地走大模型。我的处理方案是给图谱查询加一个超时上限用asyncio.wait_for把 Cypher 查询的等待时间限定在 3 秒以内超过则直接视为“图谱链路不可用”立刻走大模型兜底。另外图谱实体命中率也是判断降级的一个依据。如果用户问“最近工作压力大有点焦虑怎么调理”实体识别可能只命中“焦虑”这个症状但图谱里根本没有“焦虑”这个节点对应的完整路径。这种情况下我不会强行构造一个残缺的图谱查询而是直接判定为“图谱深度不足”让大模型完整回答。实际经验是图谱兜底得越干脆用户对系统的信任越高。用户不会因为你及时切换到 AI 生成而不满反而会因为界面卡住、迟迟不出结果而流失。5.3 答案后处理养生内容的安全提示与语气控制养生内容有个特殊性它不是药品说明但又有一定的健康指导属性。所以我在答案后处理阶段统一做了一层过滤和包装。首先是提示语统一追加。无论走哪条链路最终返回的answer字段末尾都会带上“以上内容仅供参考不能替代专业医疗建议如有不适请及时就医”。这句话不是走过场从产品角度说它既是法律合规需要也是降低用户误用风险最基本的保障。其次是语气控制。大模型的 prompt 里我明确要求避免使用“必须”“一定”“绝对”这类绝对化措辞如果知识图谱里只有单一的结论大模型需要补充说明“该结论适用于 xxx 体质个体差异较大”。这层控制不算难但能明显提升回答的专业感。6. 开发过程中踩过的坑与调优经验6.1 Neo4j 查询超时问题出在缺少索引和关联爆炸第一个坑是图谱查询越查越慢。一开始数据量只有几千个节点时查询都是毫秒级返回但数据量到几万后某些多跳查询开始出现秒级延迟。用PROFILE一检查发现全表扫描的警告。原因是只给节点设置了name属性但没有建索引。Neo4j 里创建索引很简单CREATE INDEX FOR (c:Constitution) ON (c.name); CREATE INDEX FOR (f:Food) ON (f.name); CREATE INDEX FOR (s:Symptom) ON (s.name);另外一个坑是查询模板里用了过宽的MATCH条件。比如我最初查失眠相关食材时写了MATCH (s:Symptom)-[:对应体质]-(c:Constitution)-[:宜食]-(f:Food)这条路径本身没问题但如果某个体质节点关联了几百个食材节点返回的中间结果集会非常大。后来在路径里加入WHERE c.name $constitution这种前置过滤条件把中间结果大幅压缩查询时间一下就降下来了。6.2 中文分词把“脾胃虚寒”切开后的补救办法前面提过jieba自定义词典的问题这里展开说一下。即使加了自定义词典也还是会遇到用户输入的是“脾虚”“胃寒”这类拆分表达而图谱里的标准节点是“脾胃虚寒”的情况。我的补救方案是两层第一层先做整词匹配把用户输入直接与实体库比对第二层做包含匹配比如用户输入包含“脾虚”或“胃寒”就映射到“脾胃虚寒”这个更完整的体质节点。这类映射本质上是“从短概念到长概念”的补全匹配的优先级需要低于精确匹配否则会出现“用户问脾虚怎么补系统推荐的全是针对脾胃虚寒的方子”这种偏差。6.3 大模型接口不稳定缓存、超时与流式输出的取舍大模型接口的稳定性是生产环境最大的变数。我遇到过 API 返回 500、响应超时、内容被安全策略拦截等各种情况。最直接的处理是给LLMClient加上重试机制第一次失败后间隔 1 秒重试最多重试两次。同时把相同的问题 图谱上下文组合做一个简单缓存用字典存最近 100 条结果。养生问答的口径相对固定用户反复问同一个问题的概率不低缓存能省掉大量重复调用。流式输出我也试过SSE 模式确实能让用户感觉“出字快”但代价是前端代码复杂度大幅上升而且图谱答案和大模型答案混合渲染时流式分片很难控制好段落结构。对现阶段的系统来说非流式 loading 提示反而是性价比更高的方案。6.4 知识库更新爬取公开资料时的清洗套路最后说说数据的持续更新。知识图谱不是建完一次就完事的养生知识虽然相对稳定但也会有新的药食同源目录、新的研究结论出来。我在后端写了一个独立的更新脚本定期从公开来源抓取最新资料经过格式校验后执行增量更新。清洗脚本里有一条铁律入库前必须跑一轮重复实体检查。有一次我从两个不同来源抓取食材信息同一个“黑豆”在两个文件里一个写成“黑大豆”、一个写成“黑豆”跑完批量写入后图谱里出现两个实体导致后续查询的推荐结果少了一半。后来我加了实体别名表写入前统一做一次名称归一化再没出现过这个问题。如果你也要做类似系统我建议从第一天起就用统一的实体别名表管理数据别等到数据量大了再回头清洗那工作量是真的会让人崩溃。整个系统从模型设计到上线前后大概花了两周半的时间。回头看最花时间的不是写代码而是梳理中医养生知识里那些微妙的层级关系——什么该作为实体、什么该作为属性、什么时候该走图谱、什么时候该放手交给大模型。这些决策没有标准答案但一旦想通了系统的骨架就稳了。希望这篇文章能帮你少走点弯路。本文还有配套的精品资源点击获取