基于知识图谱与大模型的Python中医养生问答系统构建指南
2026/8/26 11:12:45 网站建设 项目流程

简介:在垂直领域问答系统中,单一技术方案往往难以兼顾准确性与交互体验:大模型擅长自然语言生成却容易产生幻觉,知识图谱能提供结构化事实却缺乏语言组织能力。为解决这一矛盾,工程上常采用“知识图谱+大模型”的混合架构,由知识图谱负责事实兜底,大模型负责语义理解与表达生成,从而在医疗、法律、养生等对准确性要求高的场景中实现可靠且自然的智能问答。本文以Python FastAPI为后端、Neo4j为图谱存储、本地Qwen模型为生成引擎,完整演示了从领域本体建模、知识图谱构建与清洗、后端服务设计到前端交互实现的落地过程,并给出混合问答策略与性能优化方案,适合希望构建垂直领域问答系统的开发者参考。 去年我在做中医养生问答方向的项目时,碰到一个典型矛盾:纯靠大模型回答,语气很流畅,但细节特别容易“一本正经地胡说八道”;纯靠知识图谱,又没法应付用户那些绕弯子的问题,比如“最近总是夜里醒,手心发热,是不是该补点什么”。这个python中医养生问答系统,就是围绕这个矛盾做的:后端用Python搭服务,前端用HTML写单页,集成了基于知识图谱的问答+大模型问答双通道能力。知识图谱负责给准确事实兜底,大模型负责组织语言和理解上下文,两者组合起来,比我之前单独用任何一条路线都稳得多。

这篇文章适合两类人看:一是想自己撸一个垂直领域问答系统的开发者,尤其医疗、养生、法律这类对事实准确性要求高的场景;二是对知识图谱和大模型工程结合感兴趣,想找一个完整参考实现的同学。我会把从领域建模、Neo4j建库、后端接口设计,到前端页面实现,再到混合问答策略的完整过程都过一遍,顺带把我踩过的坑也列出来。

1. 整体设计:为什么非要把知识图谱和大模型拼在一起

1.1 单一技术路线的局限性

先说说为什么不能只用其中一种方案。

只用大模型,最直接的问题就是幻觉。通用大模型在中医养生领域虽然有大量训练语料,但它不具备查证能力。你问“阳虚体质能不能吃西瓜”,它可能给你一个听起来很有道理、实际上却是模棱两可甚至错误的答案。中医养生又很讲究体质辨别和食材宜忌,一个推荐错误,用户看了觉得没事,但长期照做很可能影响健康,这个责任项目方承担不起。

只用知识图谱也有问题。知识图谱本质上是“实体-关系-实体”的三元组存储,它擅长回答“阳虚体质宜食哪些食材”这种结构化查询,但不擅长处理自然语言。用户不会总按标准实体名提问,他可能说“我手脚冰凉,冬天特别怕冷”,你得先把这句话映射到“阳虚体质”这个实体上,再做图谱查询。而且图谱只能给你事实列表,没法生成一段像人话一样的完整建议。所以单独用图谱会把交互体验做得特别生硬。

因此我做的是双通道:图谱管事实,大模型管表达,图谱查不到的时候,再由大模型兜底并明确提示用户“以下是基于通用知识的参考”。这样既控制了幻觉风险,也让回答更像一个真正的养生顾问。

1.2 双通道架构与核心流程

整个系统从数据流上可以分成四层:

第一层是前端页面,纯HTML+CSS+JavaScript实现,跑在浏览器里,负责收集用户问题、展示回答、渲染知识图谱卡片的实体关系。第二层是Python后端服务,我用FastAPI搭建,提供/api/chat接口,内部做意图识别、图谱查询、大模型调用的路由。第三层是知识图谱存储,用Neo4j保存中医养生领域的实体和关系,比如“体质-宜食-食材”“穴位-主治-症状”。第四层是大模型服务,我用本地的Qwen模型提供生成能力,避免调用外部API导致数据外泄和响应不可控。

用户发起一个问题后,后端先做意图识别:如果问题里能抽出“体质”“食材”“穴位”等实体,就去Neo4j查询相关三元组;查询结果塞进大模型的提示词上下文里,让模型基于“知识图谱事实+通用养生知识”生成回答。如果图谱没查到,大模型就用自身知识生成一个低置信度回答,并附上“建议咨询中医师”的免责提示。整个流程用一条链路串起来,不是两个独立接口,也不是简单的“先图谱后大模型”,而是在提示词层面深度融合。

1.3 技术选型的几个关键决策

技术选型上,我做了几个对比。后端框架,我在Flask和FastAPI之间选择了FastAPI,原因是它对异步支持好,自动生成API文档,而且用Pydantic做参数校验很方便。在一个问答系统里,图谱查询和大模型调用都是耗时长、适合并发的操作,异步协程能明显提升系统吞吐量。Flask本身也很优秀,如果团队只熟悉Flask,用它也能做,但对于新项目我建议直接上FastAPI。

前端不用Vue或React,而是用纯HTML,这其实是刻意为之。这个项目的核心价值在后端,前端只需要一个聊天窗口加几个卡片,用原生HTML+JS足够,还能避免引入Node.js构建链路的复杂度。如果你想快速验证想法,或者给后端Demo用,纯HTML是最省事的方案。

核心存储我选了Neo4j。知识图谱本来就是Neo4j的主场,Cypher查询做多跳关系遍历非常自然。比如查询“阳虚体质宜食的食材,以及这些食材对应的食谱”,用MySQL要写好几层JOIN,用Cypher只要一行MATCH。大模型推理服务选本地Qwen,主要考虑是中医健康数据敏感,用户问的问题可能涉及身体状态,我不希望数据经过第三方API。用Ollama把Qwen跑在本地服务器上,虽然对硬件有一定要求,但在可控成本和数据安全之间是合理的平衡。

2. 中医养生知识图谱:从领域建模到落地入库

2.1 实体和关系怎么定义

知识图谱不能上来就灌数据,第一步是定义本体。我先梳理了中医养生场景里最常用的几类实体,最核心的是:体质类型、食材、穴位、经络、症状、养生方法、食谱。这些实体覆盖了大部分日常养生问题:用户问“我是什么体质、该吃什么、该按哪个穴位”,底层都能落到这些实体上。

关系方面,我设计了以下几组最常用的:

  • 体质 - 宜食 -> 食材:比如“阳虚体质 - 宜食 -> 羊肉”
  • 体质 - 忌食 -> 食材:比如“阴虚体质 - 忌食 -> 辣椒”
  • 体质 - 易感 -> 症状:比如“痰湿体质 - 易感 -> 身体沉重”
  • 症状 - 宜按 -> 穴位:比如“失眠 - 宜按 -> 神门穴”
  • 穴位 - 归经 -> 经络:比如“足三里 - 归经 -> 足阳明胃经”
  • 养生方法 - 适用 -> 体质:比如“艾灸 - 适用 -> 阳虚体质”

实体属性也要想清楚。食材节点要存“性味”“归经”“功效简介”;体质节点要存“典型表现”“调理原则”;穴位节点要存“定位”“操作方法”。这些属性在后续大模型生成回答时非常重要,它们能作为事实依据直接塞进提示词。

2.2 数据来源与清洗

数据来源我主要用了三类:中医体质分类与判定标准、公开的中医药膳资料、经典中医养生书籍中涉及食疗和穴位的内容。需要注意,这些公开资料的版权和适用性要自己把关,少量摘录用于知识整理还好,不能全文拷贝商用。项目里我特意加了一句免责声明:所有内容仅供参考,不构成医疗诊断,身体不适请及时就医,这句话放在前端页脚和每次回答的末尾。

清洗是工作量最大的环节。我碰到最多的坑就是同一实体的不同叫法,比如“红枣”和“大枣”其实是一个东西,“阳虚”和“阳气虚”也指向同一体质。如果不做实体对齐,图谱会变成一个分裂的迷宫。我的做法是先建一个“实体别名表”,把所有同义词统一映射到标准名,入库时一律用标准名。比如大枣、红枣、干枣统统映射到“红枣(大枣)”,然后在节点属性里存别名列表,方便前端搜索。

2.3 Neo4j建库实操

Neo4j建库我用的是Cypher批量导入的方式。比如导入食材节点和“体质-宜食”关系,代码大概是这样:

CREATE (f:Food {name: '羊肉', nature: '温', flavor: '甘', meridian: '脾经、肾经', effect: '温中暖肾,益气补虚'}); CREATE (b:BodyType {name: '阳虚体质', typical: '畏寒怕冷,手足不温', principle: '温阳散寒'}); MATCH (b:BodyType {name: '阳虚体质'}), (f:Food {name: '羊肉'}) CREATE (b)-[:宜食]->(f);

对于批量导入,我写了一个Python脚本读取CSV,然后调用Neo4j的批量接口,避免在Cypher里逐条手写。脚本里有一个关键步骤:导入前先做实体名归一化,再通过MERGE而不是CREATE来避免重复节点。MERGE相当于“存在则匹配,不存在则创建”,对实体对齐很有用。

还有一个容易忽略的点:关系方向。中医养生里“体质宜食食材”和“食材宜用于体质”是反方向,但语义不同。我统一约定主体在前:体质 - 宜食 -> 食材,查询时只按这个方向查,避免后续代码里方向混乱。

2.4 图谱数据校验与补全

建完图谱后,我做了几轮校验。第一轮是查孤立节点,比如没有任何关系的食材节点要检查是不是漏建了关系;第二轮是查重复关系,比如同一对实体之间出现两条“宜食”,需要去重;第三轮是逻辑校验,比如“阳虚体质”的忌食列表里不能出现“生姜”这种温性食材,虽然这种校验不能完全自动化,但可以靠规则筛选明显冲突的数据。

补全则靠迭代。用户问过的高频问题里,如果发现图谱没有覆盖,我就手动补充。比如有人问“湿气重怎么办”,我当时图谱里只有“痰湿体质”,没有“湿气重”这个症状实体,后来就补了“湿气重 - 相关体质 -> 痰湿体质”的关系。知识图谱是越用越完整的,最开始不必追求大而全,先覆盖核心场景,再按真实提问慢慢扩展。

3. 后端服务:Python如何把图谱和大模型串起来

3.1 后端接口设计

我用FastAPI写了三个核心接口。第一个是POST /api/chat,接收用户消息,返回最终回答、命中的实体、来源类型;第二个是GET /api/entities?query=xx,给前端做搜索联想;第三个是GET /api/graph?entity=xx,返回指定实体的周边关系,前端用小卡片展示。

/api/chat的请求体定义如下:

from pydantic import BaseModel class ChatRequest(BaseModel): message: str session_id: str = "default"

返回结构我设计成:

{ "reply": "阳虚体质的人冬天怕冷,宜吃羊肉、韭菜、桂圆等温性食材……", "entities": ["阳虚体质", "羊肉", "韭菜"], "source": "knowledge_graph+llm", "confidence": "high" }

这样前端可以不仅显示文本,还能把命中的实体渲染成可以点击的知识卡片,用户点了就能看图谱关系。

3.2 知识图谱查询模块

图谱查询模块我封装了一个KnowledgeGraphService,核心方法是根据用户问题抽取的实体名到Neo4j查关系。例如用户说“阳虚体质吃什么”,后端先用一个简单的规则抽取“阳虚体质”,然后执行Cypher:

from py2neo import Graph class KnowledgeGraphService: def __init__(self): self.graph = Graph("bolt://localhost:7687", auth=("neo4j", "password")) def get_food_by_physique(self, physique_name: str) -> list: query = """ MATCH (b:BodyType {name: $name})-[:宜食]->(f:Food) RETURN f.name AS name, f.nature AS nature, f.effect AS effect """ data = self.graph.run(query, name=physique_name).data() return data

查完后不是直接返回,而是组装成一个“知识片段”的字符串,比如“羊肉,性温,功效温中暖肾,益气补虚”。这个片段后面会被拼进大模型提示词。

3.3 大模型问答模块

大模型我通过Ollama提供的HTTP接口调用,模型用Qwen。为了不让模型离题,我设计了一个提示词模板,先把真实知识图谱结果放进去,再加约束。

system_prompt = """ 你是一位有经验的中医养生顾问。请根据下面提供的知识图谱事实回答用户问题。 要求: 1. 优先引用知识图谱中的内容,不要编造图谱里不存在的食材、穴位或功效。 2. 回答要口语化、有温度,但不要给人“包治百病”的感觉。 3. 如果用户症状严重,请提醒及时就医,不要耽误诊疗。 4. 如果知识图谱中没有相关信息,请明确说“这部分内容我掌握得不够准确”。 知识图谱事实: {kg_facts} """

这里的关键是“知识图谱事实”不能太长。我一开始把所有查询结果都塞进去,结果大模型反而找不到重点,回答变得啰嗦。后来做了剪裁,最多保留5条食材、3个穴位、2条食谱,并且按“宜食”“忌食”“穴位”“食谱”分块排列。

3.4 混合问答策略

混合问答不是简单的“图谱优先”。我实际用的是三条分支:

第一分支:如果规则抽取出明确实体并且图谱查询结果非空,就走“图谱+大模型”增强路径,系统提示词中强制要求回答时引用图谱事实,置信度高。第二分支:如果实体抽取不到,但用户描述偏向症状,就用症状关键词做模糊匹配,尝试找关联穴位或食材,如果关联到了再放大模型生成;这个路径置信度中等。第三分支:如果完全匹配不到,就直接让大模型回答,但在回复末尾附上“以上内容来自通用知识,建议您线下咨询中医师”,置信度低。

我把这三条分支的路由逻辑写成了一个简单的词典分类器加规则。你也可以用训练好的意图分类模型,但对于垂直领域,规则加词表在初期已经足够稳定,而且方便快速调整。

4. 前端页面:纯HTML也能做出好用的聊天界面

4.1 页面整体布局与交互设计

前端虽然是纯HTML,但我没有做得很简陋。整体布局分左右两栏:左侧是聊天窗口,右侧是知识图谱实体卡片区。用户没有提问时,右侧展示一些推荐问题,比如“易疲劳是哪种体质”“哪些食材适合寒性体质的人”;用户提问后,右侧根据返回的实体动态加载图谱关系,点击实体名称可以继续展开。

交互上我只有一个关键点:不要让用户干等。聊天消息发送后,前端马上显示一个“正在结合知识图谱查询……”的加载状态。虽然实际上后端是大模型生成,可能耗时几秒到十几秒,但这个提示能让用户知道系统正在工作,而不是卡死了。

4.2 关键前端代码实现

页面核心是调用后端接口并渲染回复。我写了一个sendMessage函数:

async function sendMessage() { const input = document.getElementById('chatInput'); const message = input.value.trim(); if (!message) return; appendMessage('user', message); input.value = ''; showLoading(); const response = await fetch('/api/chat', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ message: message }) }); const data = await response.json(); hideLoading(); appendMessage('assistant', data.reply); renderEntities(data.entities); }

这里一定要处理两个细节:一是fetch请求后端时,如果前后端分开部署,会有跨域问题,需要在FastAPI里加 CORS 中间件;二是前端拿到的中文内容如果出现乱码,要检查后端返回时是否设置了正确的Content-Type: application/json; charset=utf-8。FastAPI默认用UTF-8,但我曾因为用了旧版本Py2neo返回的字符串编码异常而踩过坑。

4.3 前后端联调细节

联调阶段最容易出问题的就是路径和请求格式。我本地后端起在127.0.0.1:8000,前端HTML直接用浏览器打开,此时跨域是必现的。后端加中间件解决:

from fastapi.middleware.cors import CORSMiddleware app.add_middleware( CORSMiddleware, allow_origins=["*"], allow_methods=["*"], allow_headers=["*"], )

调试时我习惯先用Postman测试后端接口,确认返回结构后再去写前端渲染,这样能隔离问题。前端渲染如果返回结构和预期不符,优先打开浏览器F12的Network面板看真实响应,而不是猜代码。

5. 完整实操:从零搭一个“体质鉴别+养生推荐”示例

5.1 场景拆解

我用一个高频需求来完整走一遍流程:用户输入“我冬天特别怕冷,手脚老是冰凉,应该吃点啥?”这个问题里的核心信息是“怕冷”“手脚冰凉”,它们都能映射到“阳虚体质”。

这个例子很适合说明混合问答的价值。如果只靠图谱直接查“阳虚体质宜食食材”,系统也能给答案,但给不出针对“手脚冰凉”的完整解释;如果只靠大模型,又容易出现推荐不准确的情况。现在系统会先通过图谱把“阳虚体质”相关的食材和穴位查出来,再让大模型基于这些事实组织成一段贴心的回答。

5.2 后端处理流程

后端接收到这条消息后,先走实体识别。我用了一个关键词表:

physique_keywords = { "阳虚体质": ["怕冷", "手脚冰凉", "畏寒", "四肢不温"], "阴虚体质": ["手心发热", "失眠", "口干", "盗汗"], "痰湿体质": ["身体沉重", "容易胖", "舌苔厚"], }

根据句子里的“怕冷”“手脚冰凉”,系统判断命中“阳虚体质”。然后执行图谱查询,查出:

  • 宜食食材:羊肉、韭菜、桂圆、生姜
  • 忌食食材:苦瓜、绿豆、西瓜
  • 推荐食谱:当归生姜羊肉汤、韭菜炒核桃仁
  • 推荐穴位:关元穴、命门穴、足三里

这些结果被组装成知识片段,拼进大模型提示词。

5.3 实际返回结果与前端展示

大模型生成后的回答大致是:

“从您说的冬天怕冷、手脚冰凉来看,偏向阳虚体质。阳虚的人阳气不足,温煦功能减弱,所以容易怕冷。饮食上适合吃羊肉、韭菜、桂圆这类温性食材,每天可以煮一点当归生姜羊肉汤;像苦瓜、绿豆这类寒凉食物尽量少吃。日常还可以艾灸或按揉关元穴、命门穴,帮助温阳散寒。如果这种怕冷持续加重,或者伴随其他不舒服,建议去中医科做个系统调理。”

返回结果里带着entities: ["阳虚体质", "羊肉", "当归生姜羊肉汤", "关元穴"],前端把这些实体渲染成右侧卡片,用户点击“关元穴”,会触发一次/api/graph?entity=关元穴的查询,展示穴位归经、主治等相关关系。

5.4 性能优化

实际部署后我发现两个性能问题。第一个是Neo4j查询耗时。随着数据量增加,未加索引的模糊查询会变慢。解决办法是在name属性上建索引:

CREATE INDEX FOR (b:BodyType) ON (b.name); CREATE INDEX FOR (f:Food) ON (f.name);

第二个是大模型并发问题。Ollama默认单实例处理请求,如果多个用户同时问,后面的请求会排队。我在后端做了一个简单的请求队列,限制大模型调用并发为1或2,避免Ollama内存溢出。同时在应用层加了Redis缓存,把高频问题的回答缓存30分钟,重复问题就直接走缓存,明显降低了整体响应时间。

6. 踩坑记录与排查思路

6.1 中文编码与字符集问题

我最早遇到最频繁的问题,就是中文乱码。有一次前端明明收到了正确JSON,但页面显示却是\u9633\u865a。后来发现是Python的json.dumps默认把中文转成Unicode转义序列。FastAPI内部会自动处理,但我在调试阶段用json.dumps(data, ensure_ascii=False)打印日志时没注意,导致日志里的内容没法看。所以排查时,一定要确认工具链里每一层的编码设置。

6.2 Neo4j导入慢或内存不足

如果一次性导入几千个节点和关系,用逐个CREATE会非常慢。我改成用UNWIND批量写入,比如把数据读成列表后:

UNWIND $rows AS row MERGE (f:Food {name: row.food_name}) SET f.nature = row.nature, f.effect = row.effect

同时控制每次写入条数,不要超过500条。如果Neo4j内存不足,优先调整JVM堆内存配置,之前默认堆内存只有512M,我调到2G以后,导入速度明显提升,也更稳定。

6.3 大模型回答幻觉和偏离专业问题

这个坑比较难解决。即使我给了知识图谱事实,模型偶尔还是会在推荐食谱的剂量上“自由发挥”,比如“当归生姜羊肉汤里放当归20克”。中医里当归用量有讲究,我作为系统开发者没有资格确认这个剂量是否适合所有人,因此在提示词里直接加了硬性约束:不要给出具体的药物剂量,只提食材和大致做法。这个规则能大大降低风险。

6.4 前后端联调时的CORS和路径问题

CORS问题前面提过,但还有一个容易被忽略的点:如果把前端HTML放到后端静态目录下,则不需要跨域,却要注意静态资源路径。我在FastAPI里挂载了静态目录,访问http://127.0.0.1:8000/就能打开首页,此时前端请求/api/chat是同源请求,不需要CORS。这比每次打开本地文件再跨域顺畅很多。

6.5 常见问题速查表

我把几个高频问题整理成了一张表,方便以后排查:

问题现象可能原因排查与解决
前端返回乱码后端编码或Content-Type不对检查是否返回UTF-8 JSON,浏览器Network面板看响应头
大模型回答与图谱不符提示词约束不足调整提示词,加强“优先引用知识图谱事实”的指令,减少自由发挥
Neo4j查询很慢缺少索引或数据量过大给常用属性建索引,用批量Cypher写入
多个用户同时问时卡死Ollama并发能力有限后端加大模型调用队列,限制并发数
前端请求跨域失败后端未配置CORS加CORSMiddleware,或把前端挂到后端静态目录
实体识别不准用户表述口语化持续补充同义词表和触发规则,加入更多别名

7. 项目落地后的一些体会

我做完这套系统最深的感受是:知识图谱和大模型不是替代关系,而是互相补位的关系。尤其在中医养生这种垂直领域,用户既要准确的事实,又要自然的交流体验,只有一个技术栈就很难两全。如果让我从零重做一遍,我会在项目最开始就规划好“实体别名表”的维护机制,而不是等数据多到开始乱才回头清洗。

另外想提醒一句:这类项目往深了做,一定会碰到医疗合规问题。系统的定位必须明确是“养生科普”而不是“医疗诊断”,界面提示、回答文案、免责声明都要做到位。这个底线守住了,技术上的尝试反而能更放开手脚。

最后分享一个实用的小技巧:如果你没有足够数据建完整知识图谱,可以先跑一个“大模型+少量结构化JSON”的最小版本,把高频问题的答案做成JSON片段,再让大模型基于JSON生成回答。等数据积累多了,再把这些JSON片段迁移到Neo4j。这样能缩短项目落地时间,也方便观察用户真正问什么,避免一开始就过度设计。

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

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

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

立即咨询