ARTICLE · INTELLIGENCE

战地情报 · 详情页

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

基于知识图谱与生成式AI的食谱推荐系统:从图谱构建到落地避坑

基于知识图谱与生成式AI的食谱推荐系统:从图谱构建到落地避坑

简介:这套Python毕业设计源码围绕知识图谱与生成式AI的智能食谱推荐系统展开,面向计算机相关专业正在做毕设的学生,也可用于课程设计或期末大作业。项目经导师指导并通过审核,源码已完成本地编译与调试,评审得分98分,难度适中且结构清晰。压缩包共42个文件、约681KB,主要包含前端React工程(19个tsx组件、9个less样式、3个ts配置)、后端Python入口(main.py)、YAML配置和shell部署脚本等,覆盖界面、逻辑、配置与发布多个环节。已有404人学习下载,适合需要快速理解知识图谱与生成式AI在推荐场景落地的学习者,也可作为项目实战与二次开发的基础模板。借助该源码可掌握前端与Python后端联调思路,并参考其目录组织、模块划分和部署脚本,为独立开发同类推荐系统提供实用蓝本,也可辅助毕业设计文档撰写与答辩展示。

1. 一个毕业设计标题背后:为什么把知识图谱和生成式AI绑在食谱推荐上

知识图谱和生成式AI这两个词近几年经常一起出现,但当它们出现在一个Python毕业设计标题里,说明不是简单的技术拼盘。拿到这个源码包,第一件事不是急着pip install,而是先想清楚它要解决的痛点:传统推荐系统只告诉你猜你会喜欢,却回答不了为什么推荐这道菜,也接不住我今天想吃清淡点这种动态需求。知识图谱把食材、菜品、口味、营养之间的关系结构化,生成式AI负责把结构化关系翻译成能看懂的理由,甚至生成一份可执行的食谱。这个方向适合想用推荐系统做毕设、又不甘心只调一个协同过滤模型的学生,也适合想看看图谱和生成式AI怎么落地的工程师。下面按我复现这个系统的路径展开。

2. 拆解系统架构:数据从食材到知识图谱,再到生成式AI的完整链路

2.1 实体与关系怎么设计:食材、菜品、营养、口味四类节点

设计知识图谱的第一步不是写代码,而是画一张节点关系图。食谱推荐涉及的实体至少包括:菜品(Dish)、食材(Ingredient)、营养(Nutrient)、口味(Taste),另外还需要菜系(Cuisine)和用户(User)。把它们全部设成独立节点而不是属性,是为了后续能走多跳查询。比如用户喜欢辣,辣的食材有哪些,这些食材出现在哪些菜里,如果口味只是菜品的一个字符串属性,这种查询就要在Python里全量循环,而在图里就是一次Cypher。

关系的种类建议控制在10条以内:菜品到食材用CONTAINS,且带上用量和单位作为关系属性;菜品到口味用HAS_TASTE;菜品到菜系用BELONGS_TO;食材到营养用HAS_NUTRIENT;用户到菜品用RATED或LIKED;用户到口味用PREFER。为什么关系属性很重要?后面计算相似度时,共享食材不能只数名称重叠,还要看用量级别,一道菜用300克豆腐和一道菜用10克豆腐完全不是一个量级。把amount和unit放在关系上,可以留给算法一个加权因子。

下面是一个种子数据文件的参考结构。我一般会用JSON而不是直接写Cypher,因为JSON更接近爬虫或公开数据集的原始形态,也好做清洗。

[ { "name": "宫保鸡丁", "ingredients": [ {"name": "鸡胸肉", "amount": 200, "unit": "克"}, {"name": "花生米", "amount": 50, "unit": "克"}, {"name": "干辣椒", "amount": 10, "unit": "克"} ], "tastes": ["辣", "甜", "麻"], "cuisine": "川菜", "cooking_method": "炒", "time_minutes": 20 } ]

这个文件里每个字段都对应图谱中的一个属性或关系。ingredients数组经过解析后,每个元素会变成一条CONTAINS关系。注意不要把ingredients整体存成一个列表属性,那样虽然导入方便,但后续所有涉及食材共同出现的查询都无法用索引,性能会很难看。

2.2 图谱存储选型:Neo4j vs 原生Python图库,我选哪个

我见过不少毕设源码是用 NetworkX 存图,理由是少装一个数据库、答辩部署省事。这个选择不是不行,但有个现实问题:当数据量到几千道菜、几万条关系时,NetworkX 的 Python 遍历会明显变慢,而且演示时没有办法向台下老师展示实体关系路径。Neo4j Community 版虽然显得重,但它自带Browser可视化,MATCH p=(...)-[...]-(...) RETURN p一敲,路径高亮就出来了,答辩效果完全不一样。

存储方式适合规模查询语言演示效果主要成本
Neo4j Community万级节点以内没问题Cypher可视化路径,直观需要JVM,内存消耗偏高
NetworkX千级节点Python原生需要自己画图实现快,但多跳查询写起来啰嗦
RDFLib适合语义推理SPARQL偏学术对普通Python开发者门槛高

如果你的目标是快速跑通后端接口,NetworkX完全可以兜底;但我接这类项目,一般默认选Neo4j,并在源码里把图操作封装成一个GraphStore接口,这样后续从Neo4j切到NetworkX只需要改一个实现类。数据量不大时,这个抽象层不复杂,但能让你在毕业设计答辩前不慌。不要把Cypher语句散落在业务代码里,否则换个存储就要重写。

Neo4j安装后第一件事是去neo4j.conf调内存参数。新版本社区版默认堆可能只有512M,导入稍大一点的JSON就会卡死。常见配置是:

dbms.memory.heap.initial_size=2G dbms.memory.heap.max_size=4G dbms.memory.pagecache.size=2G

这是给开发机用的经验值,不是严格的性能调优。如果机器内存只有8G,建议调低为1G/2G/1G。总之,先把内存预算弄清楚,再谈导入速度。

2.3 生成式AI在这里的角色:推荐理由与动态食谱生成

生成式AI在整个系统里不是推荐引擎本体,它的定位是前端表达层:知识图谱算好排序,生成式AI把它包装成人话。为什么这么切?因为大模型擅长文本生成,却不擅长稳定地约束实体关系,如果让模型直接推荐菜品,它很可能会一本正经地推荐一道图里根本不存在的菜。把这个风险放到架构层:图谱负责事实,模型负责话术。

在这个源码里,生成式AI有两个具体任务。第一,根据推荐列表生成个性化推荐理由,列表来自图谱打分结果,Prompt里会带上用户偏好和候选菜;第二,根据用户限制条件改写食谱,比如少油、不要花生、做成一人份。第二个任务需要模型有更强的指令跟随能力,Prompt里最好给出明确的输出格式。

指令跟随的实现有一个小门道:把约束条件放在Prompt末尾,也就是模型最后读取的内容位置,效果比放在开头好。另一个关键参数是temperature——推荐理由生成可以用0.7让表达有变化;但生成结构化食谱时,温度要降到0.2左右,否则菜谱里的火候和用量会飘。严格一点的实现会让两个任务走不同的LLM配置,而不是用一个服务函数包打天下。

3. 从零复现核心模块:构建知识图谱的最小可运行方案

3.1 准备数据:从公开食谱数据抽取实体关系

先做好心理准备:公开食谱数据几乎没有干净的。爬虫抓下来的菜谱里,主料和辅料混在一起,食材名称带数量、带备注,甚至同一个食材出现三种写法。我的处理顺序是:先规则抽取,再人工小样本校验,最后才考虑实体识别。对毕业设计而言,规则抽取完全够。

常见做法是直接用正则把标记字段切出来。比如"主料:豆腐 400g;牛肉末 100g",按分号切,再按空格或逗号切数量。这一步不需要完美,关键是每个源文件都统一成我们2.1里定义的那个JSON结构。为了能重复运行,我会在解析脚本里用一个parse_raw_recipe(text)函数,返回标准字典。

import re RAW_INGREDIENT_RE = re.compile(r"([\u4e00-\u9fa5A-Za-z]+)\s*(\d+\.?\d*)\s*(克|斤|毫升|ml|g|kg)?") def parse_raw_ingredients(text: str): items = [] for segment in re.split(r"[;;、,,]", text): segment = segment.strip() if not segment: continue m = RAW_INGREDIENT_RE.search(segment) if not m: items.append({"name": segment, "amount": 0, "unit": ""}) continue name = m.group(1) amount = float(m.group(2)) if m.group(2) else 0 unit = m.group(3) or "" items.append({"name": name, "amount": amount, "unit": unit}) return items

这个正则([\u4e00-\u9fa5A-Za-z]+)只匹配连续的中英文作为食材名,量词单独放后面。实际数据里有"鸡蛋2个",\d+能匹配到2;有"少许盐"这种没有数字的,就会走if not m分支,把整段当作食材名。这种脏数据不能丢,宁可让命名含混,也不要因为解析失败把整道菜丢掉。后面还有一个归一化步骤会把这些名字收拢。

3.2 用Py2neo写入Neo4j:批量导入与去重逻辑

数据清洗完后写入Neo4j。用Py2neo还是官方neo4j驱动都可以,Py2neo的对象API更贴近Python习惯。写入的关键是去重:同一个食材可能被多道菜引用,如果每导入一道菜就CREATE一个食材节点,图谱里会出现十倍碎片。解法是节点按唯一属性MERGE,关系按唯一路径MERGE,事务分批提交。

from py2neo import Graph, Node, Relationship graph = Graph("bolt://localhost:7687", auth=("neo4j", "yourpassword")) def ensure_indexes(): graph.run("CREATE CONSTRAINT IF NOT EXISTS FOR (d:Dish) REQUIRE d.name IS UNIQUE") graph.run("CREATE CONSTRAINT IF NOT EXISTS FOR (i:Ingredient) REQUIRE i.name IS UNIQUE") def add_dish_ingredient_relations(dish, ingredients): dish_node = graph.nodes.match("Dish", name=dish["name"]).first() if dish_node is None: dish_node = Node("Dish", name=dish["name"], cuisine=dish.get("cuisine", "")) graph.create(dish_node) for ing in ingredients: ing_node = graph.nodes.match("Ingredient", name=ing["name"]).first() if ing_node is None: ing_node = Node("Ingredient", name=ing["name"]) graph.create(ing_node) rel = Relationship(dish_node, "CONTAINS", ing_node, amount=ing["amount"], unit=ing["unit"]) graph.merge(rel, "CONTAINS", "amount")

注意最后的graph.merge(rel, "CONTAINS", "amount"),它把关系类型和属性amount作为身份标识。这样做有风险:同一个关系如果amount值不同,会被当成不同关系。更稳的写法是去掉身份参数,先查一下是否存在同类型、同起终点的关系,但那个查询会慢一些。对几千道菜的数据量,我宁可用慢一点的查重,也要保证关系不重复。实际开发里可以把merge的身份参数固定为"CONTAINS",但我更推荐自己先做relationship_exists()。

另外,批量导入一定要分事务。Py2neo里graph.begin()和tx.commit()是开源常用的方式。我一般每500条菜谱提交一次,避免单事务过大导致锁等待和后端超时。如果导入到一半进程杀了,重启后从断点继续,只要约束是UNIQUE,重复执行不会爆炸。

3.3 图谱查询:通过Cypher计算"相似菜品"的三条路径

知识图谱建好之后,推荐系统才有推理可言。以"用户吃过麻婆豆腐"为例,候选推荐来自于三条路径:共享食材最多、口味标签重叠、同菜系。这三条路径不是凭空拍脑袋,它们对应现实中的决策逻辑——"你爱吃麻婆豆腐,可能也爱吃辣、且配料接近的菜"。

共享食材路径是最核心的一条,也是评委最容易理解的。Cypher这样写:

MATCH (target:Dish {name: '麻婆豆腐'})-[:CONTAINS]->(i:Ingredient)<-[:CONTAINS]-(candidate:Dish) WITH target, candidate, count(DISTINCT i) AS shared_ingredients WHERE candidate <> target AND shared_ingredients >= 2 RETURN candidate.name AS name, shared_ingredients ORDER BY shared_ingredients DESC LIMIT 10;

这条查询从目标菜出发,找出所有与它共同指向某个食材的候选菜,再按共享食材数量排序。WHERE candidate <> target是必须的,否则第一个返回结果一定是它自己。shared_ingredients >= 2是过滤条件,把只因为都用了水或盐而偶然相似的菜去掉。这个阈值可以根据数据量调整,如果数据集很小,改成>= 1。

口味路径同理,只是把中间节点换成Taste:

MATCH (target:Dish {name: '麻婆豆腐'})-[:HAS_TASTE]->(t:Taste)<-[:HAS_TASTE]-(candidate:Dish) WITH target, candidate, count(DISTINCT t) AS taste_overlap RETURN candidate.name, taste_overlap ORDER BY taste_overlap DESC LIMIT 5;

同菜系路径最简单,也最弱,因为同菜系里的菜可能差别很大,比如川菜的麻婆豆腐和开水白菜没有任何食材交集。所以我把它作为加权特征,而不是单独召回条件。三条路径的召回结果在4.1里合并成一个打分。

4. 让推荐不再像黑匣子:图谱路径排序 + 生成式AI融合的两种做法

4.1 基于图谱的推荐打分:从"吃过麻婆豆腐"到"推荐宫保鸡丁"

有了图谱路径之后,推荐逻辑变成了一个多路召回再排序的问题。我不会在Cypher里直接完成加权排序,因为权重、用户个性化参数经常要调整,把逻辑放在Python里更好改。先从用户历史中拿到所有吃过的菜,对每道菜执行多路径召回,将候选菜的得分累加。

from collections import defaultdict def candidate_scores_from_dish(graph, dish_name): return graph.run( """ MATCH (d:Dish {name:$dish})-[:CONTAINS]->(i:Ingredient)<-[:CONTAINS]-(c:Dish) OPTIONAL MATCH (d)-[:HAS_TASTE]->(t:Taste)<-[:HAS_TASTE]-(c) OPTIONAL MATCH (d)-[:BELONGS_TO]->(cu:Cuisine)<-[:BELONGS_TO]-(c) WHERE c <> d RETURN c.name AS name, count(DISTINCT i) AS shared, count(DISTINCT t) AS taste_overlap, count(DISTINCT cu) AS same_cuisine """, dish=dish_name ).data() def recommend_for_user(graph, user_liked_dishes, top_k=10): scores = defaultdict(float) for liked in user_liked_dishes: rows = candidate_scores_from_dish(graph, liked) for row in rows: score = (2.0 * row["shared"] + 1.5 * row["taste_overlap"] + 0.8 * row["same_cuisine"]) scores[row["name"]] += score return sorted(scores.items(), key=lambda x: x[1], reverse=True)[:top_k]

这里defaultdict(float)的好处是候选菜不用预先注册。权重2.0/1.5/0.8是经验值,不是解析出来的最优解——我把它们放在配置里,方便答辩时现场演示调参。为什么要这个比例?共享食材是最强信号,权重给最高;口味重叠比同菜系更接近用户主观体验;同菜系的0.8只是加分项,防止召回完全为空。

需要注意,这个打分没有归一化,如果用户吃过10道菜和只吃过1道菜,候选得分范围完全不同。简单做法是最终除以用户菜品数量,得到平均贡献,或者用score = score / max_score_in_current_user做排序。毕业设计答辩时,评委通常会问这个分数有没有归一化,提前做好这个点会加分。

4.2 用生成式AI生成个性化推荐理由:Prompt模板与温度参数

图谱输出了排序结果,但用户看到的不能只是一行行菜名。把候选菜和用户偏好拼进Prompt,让LLM输出推荐理由。关键是把事实与话术分离,Prompt里用固定模板指出:只能从候选列表里选,不能新增菜。

def build_recommendation_prompt(user_prefs, candidates): candidate_text = "\n".join(f"{i+1}. {name}" for i, name in enumerate(candidates)) return f""" 你是中餐推荐助手。下面是从知识图谱中检索到的候选菜列表,已经按相似度排序。 根据用户偏好,从列表中选择3道菜,为每道写一句不超过50字的推荐理由。 必须使用列表中的菜名,不要发明新菜。 用户偏好:{user_prefs} 候选菜: {candidate_text} """

模型侧调用时,temperature建议设置在0.7到0.9之间,理由生成需要一点多样性;但如果发现每次回复的菜名对不上候选,就统一降到0.3以内。max_tokens也不要给太大,理由生成给300足够,否则模型会替你做决定,写一大段。

resp = llm_client.chat.completions.create( model="gpt-3.5-turbo", messages=[{"role": "user", "content": build_recommendation_prompt(user_prefs, top_candidates)}], temperature=0.7, max_tokens=300 )

源码里这个llm_client建议封装在LLMBackend类里,因为学校机房可能没有外网,答辩时很容易出现超时。我见过一个同学现场开演前发现API key到期,最后解决办法是本地接了一个量化模型走Ollama接口。所以封装时把base_url和model都做成配置项,切到本地只要改两个字段,不用改业务代码。

4.3 兜底策略:图谱缺失时怎么避免翻车

知识图谱最怕未知实体。用户输入"今天想吃三文鱼",图谱里只有"鲑鱼",如果不做实体对齐,推荐列表直接为空。处理方式分两层:第一层是别名表,把常见同义词集中映射,比如"三文鱼"映射到"鲑鱼";第二层是模糊匹配,用编辑距离或简单包含关系在所有Ingredient节点里找最接近的。

def resolve_ingredient_alias(name, graph): alias_map = {"三文鱼": "鲑鱼", "西红柿": "番茄", "马铃薯": "土豆"} if name in alias_map: return alias_map[name] matcher = graph.nodes.match("Ingredient") candidates = [node["name"] for node in matcher if node["name"]] # 简单包含匹配,适合小规模图谱 for candidate in candidates: if name in candidate or candidate in name: return candidate return None

这段代码逻辑很直白:先用显式别名表,再退到图节点遍历做包含匹配。它不适合大数据量,但毕设数据量在几千节点时,一次全遍历也就几十毫秒。如果不想遍历,Neo4j层面可以做WHERE i.name CONTAINS $partial的查询,走全文索引前需要先建索引。兜底原则是:宁可返回"没找到",也不要让整个接口500。

还有一个容易被忽视的兜底:LLM生成食谱时可能输出一个图谱里根本不存在的食材组合。例如图谱里只有鸡肉和花生,模型却写出"加虾仁"。解决方法是生成后解析出食材提及,与图谱Ingredient节点集合求交集,不在白名单里的词替换成"可选配菜"或直接删除。这一步在源码里通常是validate_generated_ingredients()函数,别省略,否则演示时模型随手给一个奇怪食材,整个系统的可信度就崩了。

5. 避坑指南:这5个坑让我的毕业设计多走了两个月

5.1 中文菜品名称不一致:同一道菜两个名字

现象:图谱中同时存在"西红柿炒鸡蛋"和"番茄炒蛋",两个节点没有关系。用户已经吃过前者,推荐算法在找共享食材时只能看到"西红柿"和"鸡蛋",而后者写的是"番茄"和"蛋",两个候选因此完全失联。初次跑推荐时我总觉得效果差,打印出候选列表才发现是同一个菜被拆成了两个节点。

原因:数据来源不同站点,有的叫"番茄炒蛋",有的是"西红柿炒鸡蛋",导入脚本只做了去空格和大小写处理。中文菜名不像英文食材有固定拼写,同一种东西可以有绰号、地域叫法,靠一套规则根本覆盖不完。

解决:在导入阶段维护一个dish_synonym.json别名表,把所有出现的变体映射到规范名。规范名选择出现频次最高的写法。这个表不用一次性做完整,第一版可以只录入高频重复项,然后跑一遍推荐,把仍然明显的重复导入日志并手动补录。要验证是否修好,可以查图谱里包含"番茄"的节点数量:

MATCH (d:Dish) WHERE d.name CONTAINS '番茄' OR d.name CONTAINS '西红柿' RETURN d.name, count(*) AS c;

如果还有两个高频名称并列,就继续补别名。这个操作是项目里性价比最高的,没有之一。

5.2 Neo4j内存不够导致导入卡死

现象:用Py2neo批量插入到一半,进程报ConnectionReset,Neo4j浏览器也连不上,后台日志出现OutOfMemory;重启后数据库无法正常启动,最后只能删库重导。

原因:Community版默认堆内存太小,我的数据量只有4000多个节点,但导入脚本在一个事务里把整个JSON一次性create完,JVM直接打爆。更隐蔽的是,Neo4j默认的死锁检测和事务日志也会吃内存。

解决:开发机内存如果只有8G,先把neo4j.conf改成2G堆+1G页缓存,然后重启服务。更重要的是导入端分事务批量提交:

def batch_import(graph, dishes, batch_size=500): tx = graph.begin() for i, dish in enumerate(dishes): add_dish_ingredient_relations(tx, dish) if (i + 1) % batch_size == 0: tx.commit() tx = graph.begin() tx.commit()

注意这里传入的是tx而不是graph,Py2neo里的graph.create和tx.create表现不同,事务里必须用后者的创建逻辑。改完后再导,内存曲线会平稳很多。要验证,可以看Neo4j的logs/neo4j.log,出现Started表示没有重启崩溃。

5.3 生成式AI生成不存在的食材组合

现象:推荐理由里的菜名是图谱中有的,但模型在解释时写"加入100克虾仁",实际上这道菜图谱里根本没有虾仁,用户看到会认为系统在胡说。

原因:大模型训练时见过太多菜谱,只要Prompt里给了菜名,它就会按记忆补全常见配料,不会去核对图谱。你只约束了菜名,没约束配料,幻觉就钻了空子。

解决:生成后做食材合法性校验。把图谱所有Ingredient节点名称做成一个集合,用正则从LLM输出里提取食材词,不在集合里的删除或替换成"可选配菜"。更推荐在Prompt末尾加一行"只能使用以下白名单食材:……",实测对指令跟随能力较强的模型很有效。温度也要压下来,0.3以下。校验脚本的核心就一行:

safe_ingredients = {ing["name"] for ing in graph.nodes.match("Ingredient")} unseen = [w for w in generated_ingredients if w not in safe_ingredients]

如果unseen非空,就把对应句子整体标记为"建议",不要说它是事实。

5.4 推荐结果冷启动:用户没有历史记录

现象:新用户注册后登录,推荐接口返回空数组,前端直接白屏。这个坑特别容易在答辩演示时被考官戳到,因为考官很可能让现场新建一个账号。

原因:推荐逻辑完全依赖用户与菜品之间的LIKED关系,新用户没有行为历史,图谱里没有任何可展开的路径,于是排序函数返回空。

解决:给推荐服务加一个user_based_recommend的判断分支:如果该用户没有历史记录,直接返回全局热门菜TOP20。热门菜的计算不要在查询时实时聚合,可以在导入完成后跑一个统计任务,把每个Dish的入度(被多少用户评分或浏览)写入节点属性hot_score。接口侧返回:

MATCH (d:Dish) RETURN d.name AS name, d.hot_score AS hot_score ORDER BY d.hot_score DESC LIMIT 20;

这样冷启动接口永远有数据,前端文案改成"大家都在吃"而不是"为你推荐",语义上也正确。

5.5 前端展示查询超时

现象:点击推荐按钮后转圈10秒钟才出现结果,有时直接返回 Request Timeout。第一次出现时我以为是前端问题,后来开开发者工具发现接口耗时12秒,问题在后端。

原因:Cypher查询没有限制返回行数,候选菜多的时候把上千行数据全取回来了;而且后端每次请求都新建Graph连接,Neo4j握手开销被反复支付。更糟糕的是查询里OPTIONAL MATCH三个分支同时展开,中间节点数量成倍膨胀。

解决:第一,所有推荐查询都加LIMIT 20,永远不要RETURN大字段。第二,把Graph对象设计成FastAPI的依赖单例,不每请求重连。第三,加一层Redis缓存,key是rec:{user_id}:{top_k},有效期5分钟。这三步做完,接口一般能从10秒降到200毫秒。如果还想更快,在Neo4j侧给Dish的popularity属性建普通索引,但别建全文本索引,那个对CypherORDER BY没有帮助。

6. 把源码变成能演示的系统:验证方法和三个细节

6.1 离线评估:留出法算Recall@10

推荐系统不好评估,但毕设答辩需要数字。把用户行为按时间排序,前70%作为训练集,后30%作为测试集。训练集用来生成图谱中的用户偏好关系,测试集的菜品作为标准答案。计算推荐列表在标准答案上的召回率:

def recall_at_k(recommended, actual, k=10): if not actual: return 0.0 return len(set(recommended[:k]) & set(actual)) / len(actual)

逻辑说明:除以所有真实菜品数,而不是交集大小,是因为召回率衡量推荐出来的占用户实际消费的比例。这个指标比较粗糙,但足以用来判断图谱路径调整是否向正确方向移动。你会经常发现,权重改一点,数字掉一截,这就是图谱里实体关系质量不足的信号。

6.2 三个值得加到源码里的细节

第一,给Ingredient.name和Dish.name建唯一约束,导入时会自动去重,查询也会走索引。第二,把Cypher里的路径控制在3跳以内,超过3跳的推荐语义已经稀薄,还拖慢查询。第三,把用户偏好、推荐结果都做一层缓存,接口响应从2秒降到200毫秒,演示体验完全不同。

6.3 演示时的节奏

演示时建议按冷启动热门榜,到用户点了几道菜再看推荐变化,的顺序讲,让观众先看到推荐器在没有数据时的底座,再看到图谱带来的个性化更新。生成式AI的话术放在最后,作为价值放大器,不要让大模型抢掉图谱展示的时间。我第一次跑这个系统时,只顾着调LLM的Prompt,结果推荐结果因为食材别名没有对齐而惨不忍睹,后来花了最多时间做的其实是实体对齐。这个教训一直在我脑子里。希望帮到你。

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

RELATED READING

延伸阅读

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