ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

基于Python和Neo4j的美食知识图谱问答系统构建实战

基于Python和Neo4j的美食知识图谱问答系统构建实战

简介:基于Python知识图谱的美食问答系统完整源码,面向高校期末大作业、课程设计及毕业设计场景,适合需要快速搭建智能问答演示项目的学生或开发者学习借鉴。压缩包共18个文件,以Python脚本、JSON知识数据、TXT字典文本为主,另有README说明、图标及示例图片,整体仅162KB,体积小巧却覆盖知识图谱构建、问句解析、答案搜索与图形界面交互等完整模块。如foodQASystem_graph.py、question_parser.py、answer_search.py等核心脚本,配合food.json等数据文件,可清晰展现从美食实体关系建模到自然语言匹配检索的实现链路。目前已有163人学习下载,资源经过本地验证,既可直接用于课设作业,也支持在此基础上二次开发或定制扩展,是快速上手知识图谱问答项目的实用参考。

1. 把“能做菜谱问答”当成期末作业,知识图谱是那条最短路径

期末拿到这个选题时,大多数人第一反应是做个爬虫抓菜谱,再用关键词匹配做搜索。但那个方案只能回答“怎么做红烧肉”,回答不了“有哪些菜用了牛肉且属于川菜”。这道题的核心不在“问答”,而在“知识图谱”:把菜品、食材、菜系、口味、做法这些概念建模成节点和关系,让问题是查图谱、答案是查关系的结果。基于 Python 的知识图谱美食问答系统,本质是用 Neo4j 存数据、用模板匹配做推理,最后套一个 Web 界面把查询过程展示出来。适合谁?适合有 Python 和数据库基础、想用期末作业证明自己理解图数据库而不只是会调接口的人。这篇笔记会把架构、建图谱、写问答、踩坑四段完整讲清楚,照着做能跑通,也能在答辩时讲明白每个模块为什么长这样。

2. 系统架构与技术选型:先搞清楚这块作业要拆成几层

2.1 一个期末作业为什么值得用知识图谱来做

直接用关系型数据库存菜谱也能做问答,但知识图谱的真正优势是“关系的可遍历性”。比如用户问“适合春天吃的清淡川菜”,你需要同时满足三个条件:菜系是川菜、口味是清淡、季节属性是春季。在 MySQL 里要写多表 JOIN,在 Neo4j 里就是一次路径遍历,而且每个节点的属性可以随时扩展。对于期末答辩来说,知识图谱还自带一个加分项:能可视化地展示节点和关系,评委一眼就能看懂你“建了哪些实体、连了什么边”。

技术选型方面,常见做法是 Python 3 + Neo4j + py2neo + Flask + jieba。Neo4j 存图谱,py2neo 是 Python 操作 Neo4j 的客户端,Flask 提供问答接口和页面,jieba 做中文分词。交互界面有两种做法:一种是纯 Flask 渲染 HTML 页面,另一种是 Flask 提供 JSON 接口再加一个静态前端页面。期末作业推荐前者,减少前后端联调的工作量,一个模板文件就能搞定。

这套选型里最容易翻车的点是 py2neo 的版本。py2neo 4.x 和 5.x 的 API 差异很大,网上很多老教程用的是 4.x 的写法,装成 5.x 之后Graph()和NodeMatcher()的用法完全不同。我建议直接锁定 py2neo 4.1.3,这是和 Neo4j 4.4 配合最稳定的组合,也是大多数课程环境默认踩过的版本路径。

2.2 拿到源码先看这些:目录结构与启动顺序

一个规范的基于知识图谱的美食问答系统,目录结构大致是下面这样。拿到任何类似源码,第一步不是运行,而是先确认这些文件是否齐全:

food_qa/ ├── data/ │ ├── food_data.json # 原始数据,含菜品、食材、菜系等 │ └── dict.txt # jieba 自定义词典 ├── kg/ │ ├── entity_relation.py # 定义实体、关系类型 │ └── build_graph.py # 建图谱入库脚本 ├── qa/ │ ├── question_parser.py # 问句解析、意图识别、模板匹配 │ ├── answer_search.py # 通过 Cypher 查询答案 │ └── kbqa.py # 问答主流程,串联解析和查询 ├── app.py # Flask 入口 ├── templates/ │ └── index.html # 前端问答页面 └── requirements.txt

启动顺序有一个固定套路。先启动 Neo4j 服务,确认浏览器能打开管理界面;然后运行build_graph.py把数据写入图谱;最后运行app.py启动 Flask,访问本地端口测试问答。如果启动 Flask 时报连接 Neo4j 失败,问题几乎都出在前两步。很多同学喜欢先跑 Flask 再回头建图谱,结果看不到任何答案返回,这时候不是代码错了,是数据还没进去。

启动服务的基础命令如下:

# 启动 Neo4j 服务(以 Neo4j 4.4 为例) bin/neo4j start # 检查服务状态 curl -s http://localhost:7474 | head # 建图谱 python kg/build_graph.py # 启动问答服务 python app.py

这里的逻辑是:Neo4j 必须先于 Flask 存在,因为 Flask 启动时会实例化Graph()连接,万一 Neo4j 没起来,py2neo 的连接池会报错。build_graph.py执行一次即可,重复执行会创建重复节点,所以脚本里一般要带去重判断,后面入库章节会细说。

参数说明:7474是 Neo4j 默认 HTTP 端口,7687是 Bolt 协议端口,py2neo 连接的是 Bolt 端口,后面连接代码里要写对。若用默认账号neo4j/neo4j,首次登录还要求改密码,这也会导致连接失败,建议统一改成自己熟悉的密码,后面所有代码里也同步替换。

3. 知识图谱构建:本体设计、数据整理与 Neo4j 入库

3.1 美食本体设计:把“菜”拆成哪些概念和关系

知识图谱的根是一个本体模型,也就是先定义有哪些类型的节点和关系。美食领域常见的实体类型有:菜品、食材、菜系、口味、烹饪方式、营养功效、季节。比如“麻婆豆腐”是一个菜品节点,“豆腐”是食材节点,“川菜”是菜系节点,“麻辣”是口味节点。关系则是实体之间的边,比如菜品属于菜系、菜品使用食材、菜品具有口味、食材适合季节。

以下是一个经过实践验证的实体和关系设计表,拿来直接用即可:

实体类型含义示例属性
菜品一道菜名称、做法描述、热量
食材原材料名称、别名
菜系菜系分类名称、发源地
口味味型标签名称
烹饪方式做菜方法名称
季节适合季节名称
营养功效食疗作用名称

关系类型设计要能覆盖问答里最常见的六类问题:问归属(“属于哪个菜系”)、问材料(“用什么食材做的”)、问做法(“怎么做/用什么烹饪方式”)、问口味(“什么味道”)、问适合人群(“适合什么人吃”)、问时令(“什么季节吃”)。对应关系如下:

(菜品)-[:BELONGS_TO]->(菜系) (菜品)-[:USES]->(食材) (菜品)-[:COOKED_WITH]->(烹饪方式) (菜品)-[:HAS_TASTE]->(口味) (菜品)-[:SUITS_SEASON]->(季节) (食材)-[:GOOD_FOR]->(营养功效)

选型理由是:关系数量控制在 6 条以内,既保证问答覆盖面,又降低建库成本。期末作业数据量通常在几百个节点,手工整理或半自动爬取都可以,不用追求大而全。关系少还有一个好处:Cypher 模板能写得更短,匹配逻辑更可控,答辩时讲“为什么只有这些关系”也更有底气。

3.2 把数据写进 Neo4j:py2neo 批量入库脚本

数据源一般有两种:自己整理的 JSON 文件,或者从美食网站爬取后清洗的 CSV。我建议用 JSON,因为嵌套结构天然符合实体-关系模型。下面是一个简化后的数据格式:

[ { "name": "麻婆豆腐", "belongs_to": ["川菜"], "uses": ["豆腐", "牛肉末", "豆瓣酱"], "cooked_with": ["烧", "炒"], "has_taste": ["麻辣"], "suits_season": ["冬季"] } ]

注意cooked_with是“烹饪方式”,既可以是动词也可以是名词,设计数据时要统一,否则后面模板匹配会乱。建议全部用名词,比如“炒”“烧”“炖”“蒸”,不要混入“爆炒”“小火慢炖”这类带修饰的词,模板匹配时会把修饰词误当成实体。

入库脚本用 py2neo 的Graph和Node、Relationship实现。下面是一个能直接跑通的批量入库代码:

# coding=utf-8 # build_graph.py import json from py2neo import Graph, Node, Relationship graph = Graph("bolt://localhost:7687", auth=("neo4j", "password")) # 先清空数据库,防止重复运行产生脏数据 graph.delete_all() with open("data/food_data.json", encoding="utf-8") as f: dishes = json.load(f) def get_or_create_node(label, name): # 按 label + name 检索已存在节点,避免重复创建 node = graph.nodes.match(label, name=name).first() if not node: node = Node(label, name=name) graph.create(node) return node for dish in dishes: dish_node = get_or_create_node("菜品", dish["name"]) for cuisine in dish["belongs_to"]: cuisine_node = get_or_create_node("菜系", cuisine) graph.create(Relationship(dish_node, "BELONGS_TO", cuisine_node)) for ingredient in dish["uses"]: ingredient_node = get_or_create_node("食材", ingredient) graph.create(Relationship(dish_node, "USES", ingredient_node)) for method in dish["cooked_with"]: method_node = get_or_create_node("烹饪方式", method) graph.create(Relationship(dish_node, "COOKED_WITH", method_node)) print("图谱构建完成,节点数:", graph.nodes.__len__())

逻辑说明:graph.nodes.match(label, name=name)是先查再插,防止同一食材被多道菜重复创建出多个节点。graph.delete_all()放在最前面,确保脚本可重复执行,不会因为多次运行导致节点翻倍。关系类型用的是大写字符串常量,和前面设计表保持一致。

参数说明:bolt://localhost:7687是 Neo4j 的 Bolt 连接地址,端口和 Neo4j 配置文件里的dbms.connector.bolt.listen_address对应。auth元组里分别是用户名和密码。如果 Neo4j 跑在远程服务器,把localhost换成 IP 即可。graph.nodes.__len__()只是为了打印节点总数,确认入库量符合预期。

这里有个常见误区:有的同学会把graph.create(Relationship(...))写成graph.run("CREATE ...")。两者都能用,但graph.run传字符串容易在中文转义上出问题,而用 py2neo 的 Python 对象方式写,字符转义由驱动处理,更安全。入库完成后,建议在 Neo4j 浏览器里执行MATCH (n) RETURN n LIMIT 25,确认节点和关系渲染正常,这一步能排除 90% 的数据错误。

4. 问答核心链路:问句解析、意图识别与 Cypher 查询

4.1 问句解析链路:分词、词典、实体识别

问答系统的入口是用户输入的一句话,比如“麻婆豆腐属于哪个菜系”“有什么适合冬天的菜”“鱼香肉丝怎么做”。处理链路是:先分词,再从分词结果里找出实体,最后根据问法判断意图。这三个步骤顺序不能乱,因为实体识别依赖分词结果,意图识别又依赖实体是否命中。

首先建立自定义词典,把食材、菜品、菜系这些词塞给 jieba,否则“麻婆豆腐”会被切成“麻婆”和“豆腐”。以下是一个词典片段:

麻婆豆腐 5 n 鱼香肉丝 5 n 回锅肉 5 n 豆瓣酱 4 n 郫县豆瓣 4 n

词典格式是“词 词频 词性”,词频越高越容易被 jieba 分词时保留。这里的关键坑是:自定义词典必须在分词前加载,而且加载路径要用绝对路径,否则 Flask 里相对路径会失效。加载代码:

# coding=utf-8 import jieba import os base_dir = os.path.dirname(os.path.abspath(__file__)) jieba.load_userdict(os.path.join(base_dir, "data/dict.txt")) question = "麻婆豆腐属于哪个菜系" words = jieba.lcut(question) print(words) # 输出: ['麻婆豆腐', '属于', '哪个', '菜系']

逻辑说明:jieba.load_userdict把自定义词加载进词典,lcut返回分词列表。注意jieba的分词结果依赖词频和上下文,自定义词频不要设太低,一般不低于 3,否则在某些句子里会被切开。

实体识别的常见做法是用实体词表做匹配。先构建一个“实体类型表”,把菜品、菜系、食材、口味全部映射到统一词典,然后遍历分词结果,遇到词表里的词就标记为实体。代码实现:

# question_parser.py 节选 from py2neo import Graph class EntityMatcher: def __init__(self): graph = Graph("bolt://localhost:7687", auth=("neo4j", "password")) self.entity_dict = {} # 从图谱中加载所有实体,按类型分组 for label in ["菜品", "食材", "菜系", "口味", "烹饪方式", "季节"]: nodes = graph.nodes.match(label) self.entity_dict[label] = [node["name"] for node in nodes] def match(self, words): matched = [] for word in words: for label, names in self.entity_dict.items(): if word in names: matched.append((word, label)) break return matched

逻辑说明:这一步的核心是“把图谱里的实体变成问答侧的词典”,因为实体是建图时定义的,问答侧直接用图谱数据做匹配,两个模块数据源一致,不会出现“图谱里叫豆腐、词典里叫豆腐块”的错位。graph.nodes.match(label)返回该类型下的所有节点,提取name属性组成列表。

这里有一个性能细节:如果实体数量上千,双重循环匹配会很慢。期末数据量几百个节点无所谓,但如果要扩展,就要把词表转成集合或字典,用in判断 O(1) 查询。上面的代码已经用了list,四百个词以内完全够用,建议先跑通再说优化。

4.2 意图与模板:预定义 Cypher 模板的匹配逻辑

意图识别决定答案的查询方式。美食问答的问题类型相对有限,用规则匹配就够了,常见做法是预先定义几类问法特征,比如“属于”对应菜系查询,“怎么做”对应烹饪方式查询,“用什么”对应食材查询。规则的本质是问句中的关键动词或疑问词。

意图识别代码可以这样组织:

# question_parser.py import re class IntentParser: def __init__(self): self.patterns = { "query_cuisine": ["属于", "是什么菜系", "哪个菜系", "什么菜系"], "query_ingredient": ["用什么", "食材", "材料", "原料"], "query_method": ["怎么做", "如何做", "做法", "烹饪方式"], "query_taste": ["什么味", "口味", "味道"], "query_season": ["季节", "适合什么时候", "什么时候吃"], } def parse(self, question): for intent, keywords in self.patterns.items(): for kw in keywords: if kw in question: return intent return None

意图识别之后是模板匹配。模板是“意图到 Cypher 查询语句”的映射,具体写法如下:

# answer_search.py 核心逻辑 class AnswerSearcher: def search(self, intent, entities): if intent == "query_cuisine": cypher = ( "MATCH (d:菜品)-[:BELONGS_TO]->(c:菜系) " "WHERE d.name = {name} " "RETURN c.name AS cuisine" ) answer = self.graph.run(cypher, name=entities["菜品"][0]).data() return [item["cuisine"] for item in answer] # 其他意图类似,省略

这段代码的逻辑是:根据意图选择 Cypher 模板,把实体值作为参数传入。注意 Cypher 里的{name}是参数占位符,graph.run()的第二个参数是参数字典,这样设计的目的是防止字符串拼接导致查询语法错误,尤其是名称含引号时容易出现转义问题。

模板匹配的坑在于“一个实体可能同时属于多个类型”。比如“豆腐”既是食材也是菜品(有一道凉菜叫小葱拌豆腐),模板匹配时要按意图过滤实体类型。比如query_ingredient只取食材类型实体,query_cuisine只取菜品类型实体。如果实体类型不对,查询结果会为空,而且很难排查。

完整的问答流程串起来是这样的:

def kbqa_answer(question): words = jieba.lcut(question) entities = entity_matcher.match(words) if not entities: return "我还不知道这个,换个问法试试吧" intent = intent_parser.parse(question) if not intent: return "我没理解你的问题意图" result = answer_searcher.search(intent, entities) if not result: return "图谱里没有找到对应的答案" return result

参数说明:kbqa_answer是问答主函数,先分词再实体匹配再意图识别,最后查图谱。这四步中任何一步失败都要有兜底回复,否则前端会直接抛异常。实际运行时,实体为空的情况最频繁,因为用户输入往往包含图谱里没有的词。兜底文案不能太生硬,可以提示用户试试“麻婆豆腐怎么做”这样的句式。

5. 美食问答避坑指南:5 个从“能跑”到“好用”的排查点

5.1 Neo4j 连接失败或查询超时

现象:运行build_graph.py或app.py时抛出py2neo.errors.ConnectionUnavailable,或者浏览器访问 7474 端口正常但 Python 连不上。原因排查看三点:Neo4j 服务没启动、Bolt 端口不是 7687、密码不对。其中最常见的坑是 Neo4j 默认监听的是localhost,但 Python 代码里写了127.0.0.1,有些环境两者并不等价。解决方法是统一用bolt://localhost:7687,并检查neo4j.conf里的dbms.connectors.default_listen_address。

至于查询超时,多数是因为 Cypher 里出现了全表扫描,比如MATCH (n:菜品) WHERE n.name CONTAINS '豆腐'。只要加上索引就能解决:

CREATE INDEX ON :菜品(name) CREATE INDEX ON :食材(name)

Cypher 索引是判断知识图谱建得是否规范的硬指标。没有索引时几百个节点也能撑住,但节点上千后响应时间会拖到秒级,答辩演示时体验很差。建索引这个动作要在建图完成后执行一次,然后重启 Neo4j 才生效。

5.2 中文乱码和 JSON 加载失败

现象:json.load()读入数据后打印出来是\u9ebb\u5a46之类的转义序列,或者 Flask 返回的中文变成乱码。原因多数是文件编码和读取编码不一致,Windows 下默认 GBK,文件是 UTF-8。解决方法是读取文件时强制指定编码:

with open("data/food_data.json", encoding="utf-8") as f: dishes = json.load(f)

如果源码里已经写死了open()没带encoding参数,在 Windows 下就会直接抛 UnicodeDecodeError。另外,如果 JSON 文件是从网页复制来的,可能带 BOM 头,utf-8-sig比utf-8更稳妥。这一点属于典型的“换台电脑就翻车”问题,期末验收往往在教室电脑上跑,务必提前验证。

5.3 py2neo 版本差异导致批量创建失败

现象:按网上的老代码写graph.find()或node.labels报属性不存在,或者Relationship构造函数不被识别。原因是 py2neo 4.x 和 5.x 的 API 不兼容。4.x 用graph.nodes.match(label, name=value),5.x 用graph.nodes.match(label, name=value).first(),看起来差不多,但底层的NodeMatcher说拆就拆。

解决方法是固定版本。在requirements.txt里写死:

py2neo==4.1.3 flask==2.0.3 jieba==0.42.1

这里强调一点:不要为了省事直接用pip install py2neo装最新版,最新版配合 Neo4j 5.x 可能没问题,但如果你用的 Neo4j 是 4.4,5.x 的 py2neo 也能连,只是部分接口已废弃,运行时不报错但警告刷屏。最稳妥的组合是 Neo4j 4.4 + py2neo 4.1.3。

5.4 jieba 自定义词典不生效

现象:分词结果里还是出现“麻婆 / 豆腐”,自定义词没起作用。原因有两个:一是词典文件路径不对,二是词频设置太低。路径不对的判断方法是在代码里先打印词典加载路径和当前工作目录:

print("当前目录:", os.getcwd()) print("词典路径:", os.path.abspath("data/dict.txt"))

如果启动 Flask 时工作目录是项目根目录,相对路径没问题;但如果通过 IDE 运行或设置在子模块,相对路径就会失效。解决方法是统一用os.path.dirname(os.path.abspath(__file__))拼绝对路径。

词频问题则要调高数值。jieba 对自定义词有一个优先级逻辑:词频大于默认阈值时才优先使用。实践教训是:词频设为 5 以上比较保险,低于 3 基本等于没设。

5.5 查不到答案但不报错:先查图谱再查问答

现象:前端输入“麻婆豆腐用什么食材做的”,返回“图谱里没有找到对应的答案”,但浏览器里 Neo4j 可视化明明能看到节点。这个坑最隐蔽。

排查顺序应该固定为:先在图谱层面验证数据,再在问答层面验证模板。比如先在 Neo4j 浏览器执行:

MATCH (d:菜品)-[:USES]->(i:食材) WHERE d.name='麻婆豆腐' RETURN i.name

如果这条语句能返回结果,说明图谱数据没问题。然后检查问答层的实体匹配,打印entities和intent两个变量。大多数情况是意图识别失败,比如问句是“麻婆豆腐需要什么食材”,但意图关键词列表里只写了“食材”,没写“需要什么”。规则匹配的覆盖度取决于关键词列表,期末阶段多收集问法、多补关键词是最有效的办法。

还有一个容易忽略的点:实体匹配是精确匹配,用户输入“麻婆豆腐饭”这类噪声词时,分词后得到的是“麻婆豆腐”和“饭”,“饭”不在实体库里所以被忽略,但“麻婆豆腐饭”整体匹配不上。应对办法是在匹配时用“包含”而不是“等于”,即判断word in entity_name或entity_name in word,但要控制误匹配率,建议优先用精确匹配,再处理常见噪声后缀。

6. 从作业到真正可用的系统:模板匹配还能怎么升级

模板匹配系统的瓶颈是覆盖率和泛化能力。期末作业用规则匹配是合理的,但如果想让这个系统在答辩后还能继续用,有几个低成本的升级方向。第一个方向是给实体匹配加同义词映射。比如用户说“马铃薯”而你图谱里存的是“土豆”,这俩指同一食材。做法是建一个同义词表,在实体匹配前做一次归一化。这样做的收益很大,因为美食领域的别名体系极其丰富,而代价只是多维护一张小表。

第二个方向是给意图识别加否定词处理。比如“麻婆豆腐不是川菜吗”,这句话的意图是确认而不是否定,但规则匹配容易误判。简单做法是检测问句开头是否有“不是”“难道不是”,如果有,就翻转意图类型。这块逻辑不用做复杂,一个if就能覆盖大半场景。

第三个方向是把回答从“查名字”变成“查上下文”。比如问“麻婆豆腐怎么做”,模板返回的是烹饪方式“炒”,如果图谱里存了做法描述字段,前端可以把这个字段一并返回,体验会好很多。做法是在菜品节点上加一个description属性,回答时先查属性再查关系,这比改模板更省事。

下面给出一个实际可用的改进:记录问答日志并统计失败率。在 Flask 路由里把每次问答的 question、intent、entities、success_flag 写入 CSV 文件,跑一天就能看出哪些问法覆盖不到。这个日志系统是答辩时最有说服力的材料,比单纯截图更能证明你在真实使用中发现并修复过问题。

@app.route("/qa", methods=["POST"]) def qa(): data = request.get_json() question = data.get("question", "") answer, intent, entities, success = kbqa_answer(question) with open("qa_log.csv", "a", encoding="utf-8") as f: f.write(f"{question},{intent},{entities},{success}\n") return {"answer": answer}

这段代码的逻辑是把问答结果落盘,success是布尔值,用来快速统计成功率。注意 CSV 写入要追加模式,别每次覆写。

我的个人习惯是:所有期末技术型作业,最后一天必须做三件事——清空数据库重新建图验证可重复性、换一台电脑运行验证依赖完整性、把日志文件和截图放进答辩 PPT 的附录。一次课上演示时,某同学的图谱数据是在自己电脑上建的,换到教室机器 Neo4j 是空的,问什么答什么都是“不知道”,这就是典型的没有做空库重建验证。希望这篇笔记能帮你少踩这个最懊恼的坑,愿你的美食问答系统从建图到答辩一路顺畅,希望帮到你。

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

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进