ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

图RAG实战:Neo4j+Milvus混合检索解决烹饪问答

2026/9/8 12:43:35 拓冰建站 浏览量
图RAG实战:Neo4j+Milvus混合检索解决烹饪问答 最近在折腾一个烹饪知识问答的小项目核心需求特别朴素用户问一句“有没有不放猪肉的东北炖菜”系统要能翻出靠谱菜谱还得解释清楚为什么推这几道。一开始我也迷信纯向量检索方案把菜谱描述切块、embedding 后丢进向量库就完事结果实测下来这类问题翻车率非常高。原因也不复杂——“不放猪肉”是排除性约束“东北菜”是关系型条件这两类知识恰好都是向量 embedding 最不擅长编码的东西。后来我把检索层重构成 Neo4j Milvus 双路检索再用 LLM 做查询理解和答案生成问题才真正被解决。这篇文章想把整个工程的思考过程、数据建模、搭建步骤、检索编排以及我一路踩过的坑完整记录下来。内容会比较细适合正在做 RAG 应用、想在项目里引入图数据库但又不知道从哪儿落地的朋友。不需要你有太深的图数据库基础但至少要知道 Cypher 和向量检索大概是什么读起来会轻松很多。1. 为什么是图 RAG从纯向量检索到混合检索1.1 纯向量 RAG 的三大痛点先说结论纯向量 RAG 不是不能用而是它擅长的和烹饪问答需求不匹配。向量检索的本质是“语义相似度匹配”适合回答“和这个菜谱最像的菜有哪些”“帮我找几个类似麻婆豆腐的做法”这类模糊问题。但一旦问题里带上精确约束它就露馅了。第一个痛点排除性条件容易丢。比如“不放猪肉”这个条件embedding 模型会把“不放”“猪肉”这种否定关系编码成什么大概率是把整句语义往里压缩最后检索返回的结果里依然可能混入猪肉菜谱。你可以把 top50 结果拉出来看真正排除掉猪肉的可能不到六成。第二个痛点多跳关系问题处理不了。“东北菜里有哪些用土豆的炖菜”这种问题天然需要沿着“菜系 - 食谱 - 食材”的关系链走两步向量检索一步到位匹配不出来。你必须在召回阶段就把整段文本切成包含“东北”“炖”“土豆”的块还得祈祷切块切得足够巧。第三个痛点可解释性差。用户问“推荐理由是什么”纯向量检索只能告诉你“这段文本和你问题语义相近”但相近在哪儿、靠不靠谱说不清楚。这在内容型产品里是硬伤。1.2 图数据库补上“关系”这一课图数据库的思路完全反过来。它把数据存成节点和关系查询本身就是沿着关系路径走。你可以这么理解向量库就像一个“看脸找相似”的推荐系统图数据库则更像“按人际关系网找人”。你要找“一个东北人推荐的、不用猪肉的炖菜”向量库是等你描述长相图数据库是直接问你认识的人里有谁符合这些条件。在我的这个项目里Neo4j 承担的就是“关系路径查询”这一路。用户问条件组合问题我就用 Cypher 写查询沿着(CuisineType)-[BELONGS_TO]-(Recipe)-[HAS_INGREDIENT]-(Ingredient)这种关系链直接找答案。一次查询就能完成多跳关系推理而且每一跳路径都是可追溯的回答时能把“因为它是东北菜、它没用猪肉、它用了炖的方法”讲得明明白白。1.3 整体架构Neo4j、Milvus、LLM 怎么分工最后落到工程上系统分三层意图解析层LLM 把用户自然语言问题解析成两类检索参数。一类是结构化约束比如菜系、烹饪方法、排除食材另一类是语义检索 query用于在向量库里做相似度召回。双路检索层Neo4j 负责执行结构化查询按照菜系、食材、方法等关系约束精确过滤Milvus 负责语义召回把用户问题和菜谱文本向量做相似度搜索补上非结构化描述的召回。两条路并行最后合并结果。答案生成层把两路检索结果作为上下文连同原始问题一起交给 LLM让它基于上下文生成最终答案并给出推荐理由。这套架构有个好处结构化约束交给图数据库语义相似问题交给向量库各干各的擅长事。实测下来“不放猪肉的东北炖菜”这类问题准确率从纯向量方案的不到六成提升到了九成以上。2. 数据建模把菜谱塞进 Neo4j 之前先想清楚2.1 节点与关系设计图数据库建模的核心原则是“面向查询建模”。你得先想清楚未来会问哪些问题再决定建什么节点和关系。我当时梳理出几类典型问题按菜系找菜、按烹饪方法找菜、排除特定食材、找食材搭配、组合条件查询。基于这些设计了一套非常朴素但够用的模型。节点类型节点关键属性Recipe食谱name、description、cooking_time、difficulty、servings、stepsIngredient食材name、category荤/素/调味、descriptionCuisineType菜系name、region、characteristicsCookingMethod烹饪方法name、descriptionTag标签name素食、辛辣、清淡等关系类型关系语义属性(Recipe)-[:HAS_INGREDIENT]-(Ingredient)食谱使用了食材quantity用量(Recipe)-[:BELONGS_TO]-(CuisineType)食谱属于某个菜系无(Recipe)-[:USES_METHOD]-(CookingMethod)食谱用了某种烹饪方法无(Recipe)-[:HAS_TAG]-(Tag)食谱拥有某个标签无(Ingredient)-[:GOES_WELL_WITH]-(Ingredient)食材搭配reason为什么把食材单独建模而不是直接塞进 Recipe 属性里因为未来要做排除查询“不放猪肉”对应的 Cypher 是沿着HAS_INGREDIENT关系过滤掉指定食材节点。如果食材只是 Recipe 上的一个字符串数组排除逻辑会写得非常别扭也没办法做食材搭配的延伸查询。“食材搭配”这个关系后来被证明很值。用户问“鸡肉能和什么一起炖”直接走(Ingredient)-[:GOES_WELL_WITH]-(Ingredient)反查再转回 Recipe比纯语义检索精确得多。2.2 数据准备与 CSV 导入数据导入这块开发阶段强烈推荐用LOAD CSV边导边看结果比neo4j-admin import那种全量导入友好太多。官方文档说得很清楚neo4j-admin适合千万级节点的首次离线导入咱们这种几千条菜谱的实验项目用不到。我的数据源是一份爬好的菜谱 CSV字段包括菜名、简介、食材、步骤、菜系、烹饪方式等。导入前先整理成三张表recipes.csv、ingredients.csv、recipe_ingredients.csv。关键点是 CSV 必须存成 UTF-8 编码最好是带 BOM 的 UTF-8不然中文乱码会让人疯掉。recipes.csv大致长这样recipe_id,name,description,cooking_time,difficulty,servings,steps,cuisine,method 1001,东北乱炖,经典东北家常菜多种食材一锅炖,45,简单,4人份,step1...,东北菜,炖导入的 Cypher 这样写LOAD CSV WITH HEADERS FROM file:///recipes.csv AS row MERGE (c:CuisineType {name: row.cuisine}) MERGE (m:CookingMethod {name: row.method}) MERGE (r:Recipe { recipe_id: toInteger(row.recipe_id), name: row.name, description: row.description, cooking_time: toInteger(row.cooking_time), difficulty: row.difficulty, servings: row.servings, steps: row.steps }) MERGE (r)-[:BELONGS_TO]-(c) MERGE (r)-[:USES_METHOD]-(m);recipe_ingredients.csv稍微特殊重点在带用量属性recipe_id,ingredient_name,quantity 1001,土豆,300克 1001,豆角,200克 1001,猪肉,150克导入关系时用 MERGE 而不是 CREATE能防止数据重复时产生重复关系LOAD CSV WITH HEADERS FROM file:///recipe_ingredients.csv AS row MATCH (r:Recipe {recipe_id: toInteger(row.recipe_id)}) MERGE (i:Ingredient {name: row.ingredient_name}) MERGE (r)-[h:HAS_INGREDIENT]-(i) SET h.quantity row.quantity;有个小习惯非常好数据导入完成后用CALL apoc.meta.schema()看一眼全貌或者直接在 Neo4j Browser 里执行MATCH (n) RETURN labels(n), count(*)确认节点和关系数量对得上再往下走。不然数据少了后面查不出来还以为自己 Cypher 写错了。2.3 Cypher 查询的几种典型模式建模和导入做完真正爽的是查数据。我总结了几类高频 Cypher 模式基本覆盖了烹饪问答里 80% 的问题。单条件筛选按菜系找菜MATCH (r:Recipe)-[:BELONGS_TO]-(c:CuisineType {name: 东北菜}) RETURN r.name, r.description ORDER BY r.cooking_time LIMIT 10;组合条件查询找“用炖的方式、且属于东北菜”的食谱MATCH (r:Recipe)-[:BELONGS_TO]-(c:CuisineType {name: 东北菜}), (r)-[:USES_METHOD]-(m:CookingMethod {name: 炖}) RETURN DISTINCT r.name LIMIT 20;排除性约束找“东北菜、炖、但不放猪肉”的食谱。这是图查询最擅长的部分直接排除关系路径上的指定食材MATCH (r:Recipe)-[:BELONGS_TO]-(c:CuisineType {name: 东北菜}), (r)-[:USES_METHOD]-(m:CookingMethod {name: 炖}) WHERE NOT EXISTS { (r)-[:HAS_INGREDIENT]-(:Ingredient {name: 猪肉}) } RETURN DISTINCT r.name LIMIT 20;食材搭配反查找“和鸡肉相性不错的食材”再把食材映射到食谱MATCH (i:Ingredient {name: 鸡肉})-[:GOES_WELL_WITH]-(pair:Ingredient) MATCH (r:Recipe)-[:HAS_INGREDIENT]-(pair) RETURN pair.name AS ingredient, collect(DISTINCT r.name) AS recipes LIMIT 20;这套模型的表达力已经够用了。真遇到超复杂条件还可以用subquery和apoc库辅助但对绝大多数问答场景上面几种模式足够了。3. Milvus 向量检索部分的搭建3.1 Milvus 安装与配置要点原本我以为菜谱数据量不大用 SQLite 那种内存向量库就够了。后来发现一个关键需求——用户会拿很不精确的描述来搜菜比如“一道东北农村过年经常吃的炖菜”这种问题里没有菜系、食材等可结构化字段只能靠语义召回。而语义召回想要效果稳定向量库的速度和索引质量不能太差这才引入了 Milvus。开发环境我直接用 Docker Compose 装 standalone 版。Milvus 依赖 etcd 和 MinIO前者存元数据后者存段文件。这个依赖关系很多人第一次接触容易漏等会儿专门在“常见问题”里吐槽。标准的docker-compose.yml关键部分摘录如下version: 3.5 services: etcd: image: quay.io/coreos/etcd:v3.5.18 environment: - ETCD_AUTO_COMPACTION_MODErevision - ETCD_AUTO_COMPACTION_RETENTION1000 - ETCD_QUOTA_BACKEND_BYTES4294967296 volumes: - etcd_data:/etcd minio: image: minio/minio:RELEASE.2024-01-16T16-07-38Z environment: MINIO_ACCESS_KEY: minioadmin MINIO_SECRET_KEY: minioadmin command: minio server /minio_data volumes: - minio_data:/minio_data milvus: image: milvusdb/milvus:v2.4.15 command: [milvus, run, standalone] environment: ETCD_ENDPOINTS: etcd:2379 MINIO_ADDRESS: minio:9000 ports: - 19530:19530 volumes: - milvus_data:/var/lib/milvus volumes: etcd_data: minio_data: milvus_data:起完容器后可以用桌面可视化工具 Attu 连接本地 Milvus。默认地址是localhost:19530用户名密码留空或者用官方默认的root/Milvus。很多人在这一步卡住后面常见问题里细说。3.2 烹饪文本的向量化策略向量化这事真正决定 RAG 效果下限。我试过两种路线经验如下第一种是把菜谱整段描述直接 embedding适合“语义相近”的模糊检索。但整段过长时向量会被无关信息稀释。我的做法是把每个菜谱切成多个块每块约 500 到 800 字符块与块之间重叠 50 到 100 字符保证关键信息不会正好被切掉。第二种是结构化拼块。我会把“菜名 菜系 烹饪方法 食材清单 简介”拼接成一个块然后再切。比如“东北乱炖 东北菜 炖 土豆,豆角,猪肉 经典家常菜”。这种结构化的文本块embedding 出来能给食材、菜系等关键词更高权重召回率明显提升。embedding 模型选型上中文菜谱场景我用的是支持中文的 bge-large-zh 或者 m3e编码维度需要记好。bge 系列的输出维度通常是 1024m3e-base 是 768。建 Milvus collection 时字段维度必须和模型输出维度一致不一致会导致写入失败或者查询直接用不了这是特别容易踩的坑。3.3 集合设计与数据写入用 pymilvus 写起来不难核心是把字段设计清楚。我的 Collection 设计如下字段类型说明idINT64主键自增recipe_idINT64对应 Neo4j 里的 Recipe.recipe_idchunk_textVARCHAR原始文本块给 LLM 看embeddingFLOAT_VECTOR向量维度按模型定cuisine_nameVARCHAR冗余存储菜系做标量过滤method_nameVARCHAR冗余存储烹饪方法做标量过滤冗余cuisine_name和method_name的原因是有些简单问题可以直接走 Milvus 的标量过滤不必每次都被路由到 Neo4j。比如用户问“有哪些川菜”直接在向量库里过滤菜系字段再按相关性排序速度很快。创建 Collection 和写入数据的核心代码from pymilvus import ( connections, Collection, CollectionSchema, FieldSchema, DataType, utility ) connections.connect(aliasdefault, hostlocalhost, port19530) fields [ FieldSchema(nameid, dtypeDataType.INT64, is_primaryTrue, auto_idTrue), FieldSchema(namerecipe_id, dtypeDataType.INT64), FieldSchema(namechunk_text, dtypeDataType.VARCHAR, max_length2000), FieldSchema(nameembedding, dtypeDataType.FLOAT_VECTOR, dim1024), FieldSchema(namecuisine_name, dtypeDataType.VARCHAR, max_length50), FieldSchema(namemethod_name, dtypeDataType.VARCHAR, max_length50), ] schema CollectionSchema(fields, descriptionrecipe chunks) col_name recipe_chunks if utility.has_collection(col_name): collection Collection(col_name) else: collection Collection(namecol_name, schemaschema) index_params { index_type: HNSW, metric_type: COSINE, params: {M: 16, efConstruction: 200} } collection.create_index(field_nameembedding, index_paramsindex_params) data [ [1001], # recipe_id [东北乱炖 东北菜 炖 土豆,豆角,猪肉 经典东北家常菜], # chunk_text [embedding_vector], # embedding [东北菜], [炖], ] collection.insert(data) collection.flush()写入后记得collection.load()否则查询时索引没加载会直接报错。这块我在 5.2 节会细讲。4. LLM 与检索编排真正的“问答系统”实现4.1 查询理解与路由很多做 RAG 的人把 LLM 的角色只限定在最后一步生成答案其实 LLM 在检索之前的“查询理解”环节更重要。我会让 LLM 把用户问题解析成一个 JSON 结构里面包含结构化约束和非结构化 query。提示词大致是这样你是一个菜谱问答的查询理解器。请把用户问题解析成 JSON字段包括 - query: 用于语义检索的自然语言描述 - cuisine: 指定菜系数组 - exclude_ingredients: 明确排除的食材数组 - methods: 指定烹饪方法数组 - tags: 指定标签数组 只输出 JSON不要解释。 用户问题有没有不放猪肉的东北炖菜LLM 返回的可能是{ query: 东北炖菜 不用猪肉, cuisine: [东北菜], exclude_ingredients: [猪肉], methods: [炖], tags: [] }然后路由逻辑就简单了如果cuisine、exclude_ingredients、methods里有任一项非空就优先并行走 Neo4j 结构化查询和 Milvus 语义召回如果所有结构化字段都为空只走 Milvus 语义召回再用 LLM 生成。这样的好处是避免每次查询都双路打满节省不少 API 调用成本。解析失败是必然会发生的事。我的兜底策略是如果 LLM 输出不是合法 JSON就把原始文本当作query字段默认只走 Milvus并且不阻塞业务。宁可少召回不能直接报错。4.2 两路检索结果的合并与重排双路检索完两堆结果怎么合是个被很多人忽略的细节。最简单有效的方案是按 recipe_id 去重加权打分排序。打分公式我用的final_score 0.6 * graph_score 0.4 * vector_scoregraph_score来自图查询的匹配度比如同时满足菜系、方法、排除条件的为 1.0只满足部分条件的按比例递减。vector_score来自 Milvus 返回的余弦相似度已经归一化到 0-1 区间。合并代码大致如下def merge_results(graph_results, vector_results): merged {} for r in graph_results: merged[r[recipe_id]] { recipe: r, score: 0.6 * r.get(graph_score, 0.5) } for r in vector_results: rid r[recipe_id] if rid in merged: merged[rid][score] 0.4 * r.get(score, 0.0) else: merged[rid] { recipe: r, score: 0.4 * r.get(score, 0.0) } ranked sorted(merged.values(), keylambda x: x[score], reverseTrue) return ranked[:10]有个细节如果在两路里都找到了同一道菜说明这道菜置信度很高给它一个小奖励分也合理。我在代码里实际上会加0.1的 bonus但这一点并非必须主要看你对结果的敏感度。4.3 生成提示词的设计检索结果终于喂给 LLM 时提示词设计直接决定输出质量。我的原则是给足约束强制引用禁止胡编。核心提示词模板你是美食领域的问答助手。请基于以下检索到的菜谱信息回答用户问题。 要求 1. 只使用提供的菜谱信息不要编造不存在的菜谱。 2. 回答时要说明推荐理由涉及菜系、烹饪方法、食材关系时明确提到。 3. 如果检索结果没有满足用户要求的菜谱请直接说“暂时没有找到完全匹配的食谱”不要硬凑。 4. 用简洁口语化的中文回答。 用户问题{question} 检索到的菜谱信息 {context} 请回答{context}我会填成结构化文本把每一道菜的菜名、菜系、食材、步骤、相似度分数一并列进去。这样 LLM 在输出时上下文是足量的。一个非常有用的细节在 context 里把“匹配条件”也标出来比如“这道菜属于东北菜使用了炖的方法食材列表为 土豆、豆角不包含猪肉”。这能显著降低推理时的幻觉概率因为 LLM 不需要自己猜推荐理由它要做的主要是转述和润色。5. 常见问题与排查记录5.1 Neo4j 里的三个高频坑中文乱码。第一次导入 CSV 后在 Neo4j Browser 里查到的菜名全是乱码差点以为文件编码坏了。后来确认问题出在 CSV 的编码格式上。Windows 下导出的 CSV 往往是 GBKNeo4j 默认按 UTF-8 解析。解决方式是转码保存为带 BOM 的 UTF-8或者用 Python 一次性转好不要手动去改。重复节点。MERGE依赖唯一性约束保证不出重复。如果没有给Recipe.recipe_id建唯一约束第二次跑数据导入脚本就会出现一堆相同菜名的重复节点。强烈建议在导入前执行CREATE CONSTRAINT unique_recipe IF NOT EXISTS FOR (r:Recipe) REQUIRE r.recipe_id IS UNIQUE; CREATE CONSTRAINT unique_ingredient IF NOT EXISTS FOR (i:Ingredient) REQUIRE i.name IS UNIQUE;内存调优。Neo4j 默认堆内存不算大数据量上来后容易 OOM。我是在neo4j.conf里调整了dbms.memory.heap.initial_size和dbms.memory.heap.max_size两个都设成 2G。容器部署需要注意宿主机内存充足不然不仅 Neo4j 起不来Milvus 那边也会跟着遭殃。5.2 Milvus 里的常见问题etcd 没起导致 Milvus 起不来。这是最经典的报错。Milvus 启动时连不上 etcd会在日志里反复刷连接失败。如果你用 Docker Compose 且 all-in-one 正常那不用管如果分开部署一定先确认 etcd 容器健康再启动 Milvus 容器。实际排查可以用docker logs milvus | grep -i etcdAttu 连不上本地 Milvus。很多人装了 Attu 后填localhost:19530连不上。如果 Milvus 跑在 Docker 里在宿主机上localhost:19530通常没问题如果 Milvus 跑在另一个容器里你就要写容器的 IP 或者把端口映射出来。实在不行在 docker-compose 里暴露19530:19530然后用宿主机 IP 连。向量维度不一致导致查询报错。这种错误一般在插入数据时不会立刻暴露但查询时会提示dimension mismatch。因为建 Collection 时定了 1024 维插入的数据就必须每一条都是 1024 维。建议在写入前统一走同一个 embedding 接口不要混用不同模型。索引状态不是 Loaded。创建索引后没有执行collection.load()查询时会出现no index or collection not loaded之类的错误。执行一次collection.load()再查问题立刻消失。5.3 RAG 效果不理想时先查哪几件事如果系统已经搭起来但回答质量不稳别急着调提示词按这个顺序排查先看召回对不对。在 Attu 里跑一次同样的 query 看看 top10 结果和你预期是否相关。如果不相关大概率是分块策略或 embedding 模型问题。你可以打印几个真实 chunk 文本看看是不是被切碎了。再看图查询返回的记录数。有些时候 LLM 答得稀烂是因为 Neo4j 这边根本没匹配到菜谱导致 context 为空。在路由日志里把 Cypher 结果数量和返回的 recipe_id 打出来一眼就能看出来。最后才检查提示词。上下文没问题但答案还是瞎编那就是提示词约束不够强。可以试试把“不要编造”写得再具体点比如“除非 context 中出现食谱否则不要提及具体菜名”。我个人的体感是RAG 系统 80% 的问题出在检索不是生成。检索结果干净了LLM 基本不会太离谱。写在最后的经验这个项目从纯向量方案做到图 向量双路 RAG最大的收获是图 RAG 绝不会替代向量 RAG两者是互补关系。结构化的关系约束交给图数据库模糊的语义相似度交给向量库LLM 在最外层做理解与表达这套分工目前看是最稳的。如果你也想在自己的项目里复刻这套架构我建议从最小闭环开始先拿几百条数据把 Neo4j Milvus LLM 的链路跑通再逐步加数据、加关系、调提示词。不要一开始就想着把数据全量灌进去调优阶段数据太多反而干扰判断。还有一个小技巧测试问题时固定一套 30 到 50 个问题集每次改动都跑一遍记录回答质量的变化这样迭代效率会高很多也不会出现改完一个模块、另一块莫名其妙变差的情况。