ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

图RAG实践:Neo4j+Milvus+LLM打造烹饪知识问答系统

图RAG实践:Neo4j+Milvus+LLM打造烹饪知识问答系统 1. 项目前言从“AI聊天”到“AI会按图索骥”做 RAG 的朋友应该都有同感纯向量检索的方案搞个知识库 Demo 很容易但一上生产就露馅。用户问“宫保鸡丁和鱼香肉丝有什么区别”向量检索返回的是两篇高度相似的菜谱片段LLM 对着拼凑出来的上下文要么答非所问要么直接编造步骤。这个问题的根子在于传统 RAG 把知识库当成了一袋子文档碎片丢失了实体之间的关系。而烹饪这个场景恰恰是关系密集型知识的典型代表——食材、菜品、技法、菜系、口味之间有着清晰的层级和关联。比如“鱼香肉丝”依赖“鱼香汁”“鱼香汁”又依赖“泡椒”“泡椒”属于“川菜调料”。这种知识结构用文档切片去表达天然就是错的。所以我做了这个基于图 RAG的烹饪问答系统核心思路很简单用Neo4j把菜谱领域知识建模成图用Milvus做食材描述和自由文本的向量召回再用LLM做意图识别和答案组织。三个组件各司其职把“知识图谱的结构化推理能力”和“向量检索的语义泛化能力”结合起来实测下来回答质量和可解释性都远超纯向量方案。这篇文章会把整个项目的完整工程实践写下来从架构设计到环境搭建从数据建模到混合检索再到 LLM 的编排和常见问题排查全部是实操向的内容。适合正在做 RAG 应用、或者想了解图数据库和向量数据库如何协同工作的朋友尤其是遇到“纯向量检索答非所问”、“知识库关系密集”这类问题的场景这篇文章应该能给你一个具体可落地的参考方案。2. 整体架构设计为什么是 Neo4j Milvus LLM 三件套2.1 解构需求烹饪问答到底难在哪先花点篇幅聊聊这个项目的需求拆解因为架构选择的依据全部来自业务场景本身。烹饪问答系统表面上的需求是“用户问菜谱系统答菜谱”但实际用户的问题类型差别很大。我整理了一下大概能分成四类第一类是事实查询型比如“鱼香肉丝需要哪些食材”这类问题答案相对固定关键词匹配就能定位。第二类是关系推理型比如“川菜里有哪些菜用了花生”这就需要在菜系、菜品、食材之间做多跳关联查询。第三类是泛化语义型比如“我想吃点清爽的荤菜”这没有明确实体必须靠语义理解来匹配食材属性和菜品标签。第四类是约束排除型比如“有没有不用油炸的鸡胸肉做法”这类问题带有排除条件需要把“不包含某技法”作为硬约束。如果只用向量检索第一类和第三类还能对付第二类基本无能为力第四类效果也很差。如果只用图谱查询第三类直接没法处理因为用户输入里根本没有实体可以匹配。所以混合架构不是炫技而是业务需求逼出来的。2.2 组件选型三个数据库为什么是它们先说 Neo4j。图数据库领域 Neo4j 是事实标准Cypher 查询语言表达能力很强社区版就能满足项目需要。选择图数据库的核心原因是烹饪知识天然是图结构菜品节点连接食材节点技法节点连接菜品节点菜系节点归类菜品节点。用图来存查询“回锅肉用了什么豆瓣酱”、“哪些菜用到郫县豆瓣”就是一个简单的路径查询不用做多表 JOIN。再说 Milvus。向量数据库里 Milvus 是开源方案里最成熟的之一支持多种索引类型社区活跃而且有 Attu 这个可视化工具调试起来方便。它负责的是“语义模糊匹配”这部分——用户说“清爽”系统要能召回“凉拌”、“清蒸”、“低油”相关的菜品。最后是 LLM。这里 LLM 扮演的是编排者的角色不是知识来源。它接收用户的自然语言问题判断应该走图谱查询、向量召回还是两者并行然后把结果组织成自然语言答案。我用的是 OpenAI 兼容接口的模型具体模型名不影响架构只要支持函数调用或工具调用就行。注意这个架构里 LLM 和知识库的分工要非常清楚——LLM 负责理解和表达知识库负责事实。如果把 LLM 既当编排器又当知识源那就不需要图数据库和向量数据库了但那样做出来的系统幻觉问题会很严重。2.3 系统流程一次问答背后的数据流转整个系统的工作流程可以拆成六个步骤我在这里先给个总体预览后续章节会逐个展开第一步用户输入问题LLM 先做一轮意图识别和实体抽取。第二步系统根据抽取结果决定检索策略有明确实体就走图谱查询分支有模糊描述就走向量召回分支两者都有就并行执行。第三步图谱查询结果经过后处理转成 LLM 能理解的文本片段。第四步Milvus 召回结果与图谱结果做融合排序过滤掉低相关度的片段。第五步所有候选上下文拼装成 Prompt交给 LLM 生成答案。第六步答案经过格式化和引用标注展示给用户。这个流程的巧妙之处在于检索阶段是规则的、可解释的生成阶段是灵活的、自然的。规则的归规则语义的归语义各管一段互不干扰出现问题也好排查。3. 环境搭建Neo4j 和 Milvus 的安装避坑指南3.1 Neo4j 安装与配置Windows 和 Docker 两条路这个项目里 Neo4j 是知识图谱的存储引擎安装方式取决于你的开发环境。我自己用的是 Docker 方式但不少朋友在 Windows 上折腾过原生安装这里两条路都说一下。如果本机已经装了 Docker推荐直接用容器跑干净且好卸载。一条命令就能起一个带数据卷的 Neo4j 实例docker run -d \ --name neo4j-cooking \ -p 7474:7474 -p 7687:7687 \ -e NEO4J_AUTHneo4j/yourpassword \ -v $PWD/neo4j-data:/data \ neo4j:5.20-community这里端口说明一下7474 是浏览器端的 Neo4j Browser 访问端口7687 是 Bolt 协议端口Python 驱动连的是 7687。数据卷一定要挂载否则容器删了数据就全没了这个坑我踩过一次重新导入图谱的滋味不好受。Windows 上不依赖 Docker 的话可以去 Neo4j 官网下载 Desktop 版或社区版 zip 包。Desktop 版带图形界面新建数据库很方便适合新手。zip 社区版要自己配环境变量把NEO4J_HOME指到解压目录然后进 bin 目录执行neo4j console前台启动首次启动会自动初始化。配置方面有两个点值得特别注意第一如果要用 Python 或 Java 驱动远程连接记得修改neo4j.conf里的监听地址。默认只监听 localhost需要改成server.default_listen_address0.0.0.0才能被容器外的程序访问。第二社区版最多打开一个数据库Enterprise 版才支持多库。做实验的话 Community 版完全够用不用纠结企业版的功能差异。3.2 Milvus 安装非 Docker 部署和 etcd 依赖处理Milvus 的安装是大多数人的痛点因为官方主推 Docker Compose 方式对纯本机开发环境不太友好。我推荐两个方案方案一Docker Compose 单机版这是官方推荐的快速开始方式。下载milvus.yaml和docker-compose.yml执行docker compose up -d即可。它会同时启动三个容器etcd元数据存储、MinIO对象存储和 Milvus 主服务。注意这里etcd 是 Milvus 的元数据存储不是可选项很多人以为只装 Milvus 一个容器就行结果启动后报错找不到 etcd其实是因为 Compose 文件里没有把 trio 一起拉起来。方案二Windows 非 Docker 安装。Milvus 官方其实不提供 Windows 原生二进制包非要在 Windows 裸机跑只能靠 WSL2。在 WSL2 里装 Ubuntu 子系统然后在子系统内按照 Linux 方式安装。这个过程有点折腾我的建议是开发阶段直接用 Docker 方案最省心生产环境一般也是 K8s 或物理机部署Windows 原生安装意义不大。装好之后强烈建议再装一个Attu这是 Milvus 的图形化管理工具。Attu 是独立容器连接到 Milvus 的 19530 端口即可docker run -d \ -p 8000:3000 \ -e MILVUS_URLhost.docker.internal:19530 \ zilliz/attu:latest打开http://localhost:8000就能在浏览器里查看 collection、向量检索结果。调试阶段有没有可视化工具效率完全是两回事。版本兼容性提示Attu 对 Milvus 版本有一点挑剔。我用的是 Milvus 2.4.x Attu 2.4.x 的组合如果你用的是 Milvus 2.3 或 2.5尽量选择相同大版本的 Attu避免出现“能连接但看不到数据”这种诡异问题。3.3 Python 依赖 langchain4j-milvus 的替代方案热词里出现了langchain4j-milvus这里多说一句。langchain4j 是 Java 生态的 LangChain 移植版如果你用 Java 写服务它确实提供了 Milvus 的集成包。但大多数做算法原型的人用的是 Python对应的是pymilvus这个官方 Python SDK。我的项目里用的是 Python 技术栈核心依赖如下# 向量数据库相关 pip install pymilvus2.4.9 # 图数据库相关 pip install neo4j5.20.0 # LLM 调用相关 pip install openai1.35.0 # 数据处理相关 pip install pandas numpy版本号是我实测过的组合不做强制要求但建议大版本不要差太多。pymilvus2.4.x 连接 2.4 的 Milvus 服务端没问题如果装了最新 2.5 的 SDK 去连 2.3 的服务端可能出现 proto 协议不兼容的报错遇到就降版本。4. 数据建模把烹饪知识变成图结构4.1 实体和关系的设计菜品、食材、技法、菜系图谱建模是整个项目里最核心的环节建模的好坏直接决定后续查询能做什么、不能做什么。我先列出这个项目用的实体类型和关系类型再解释为什么这样设计。节点类型标签一共六类Dish菜品核心实体如“宫保鸡丁”、“麻婆豆腐”Ingredient食材如“鸡胸肉”、“花生米”Technique技法如“炒”、“炸”、“蒸”、“凉拌”Cuisine菜系如“川菜”、“粤菜”、“鲁菜”Flavor口味如“麻辣”、“酸甜”、“清淡”Seasoning调料如“郫县豆瓣酱”、“生抽”、“料酒”关系类型一共八类(Dish)-[:HAS_INGREDIENT {amount: 200g, optional: false}]-(Ingredient)(Dish)-[:USES_TECHNIQUE]-(Technique)(Dish)-[:BELONGS_TO]-(Cuisine)(Dish)-[:HAS_FLAVOR]-(Flavor)(Dish)-[:USES_SEASONING]-(Seasoning)(Ingredient)-[:IS_A]-(IngredientCategory)如“鸡胸肉”属于“禽肉类”(Seasoning)-[:COMPOSES]-(Seasoning)如“鱼香汁”由“泡椒、糖、醋”组成(Dish)-[:SIMILAR_TO]-(Dish)菜品相似关系用于推荐这里有两个设计上的细节值得展开讲。第一个是关于食材的量化和可选性。我在HAS_INGREDIENT关系上挂了属性amount和optional因为用户经常问“宫保鸡丁要不要放糖”“哪些食材可以不放”。如果不把属性放在关系上而是放在食材节点上语义就是错的——同一个食材在不同菜品里的用量不同“花生米”在宫保鸡丁里是主料在别的菜里可能是 garnish。第二个是关于技法的层级化。技法节点之间我设计了SUB_TECHNIQUE_OF关系例如“干煸”是“炒”的子技法“清蒸”是“蒸”的子技法。这样用户问“不用炒的鸡肉做法”图谱查询可以做子技法排除不至于把“干煸鸡”这类菜也算进去。4.2 Cypher 批量导入从结构化数据到图谱建模设计好之后面临的问题就是数据怎么进 Neo4j。菜谱数据以结构化表格形式存在CSV 或 JSON导入方式我推荐用 Cypher 的LOAD CSV语句或者批量 MERGE。以菜品和食材的关系为例CSV 数据格式如下dish_name,ingredient_name,amount,optional,cuisine,technique 宫保鸡丁,鸡胸肉,200g,false,川菜,炒 宫保鸡丁,花生米,50g,false,川菜,炒 宫保鸡丁,干辣椒,10g,false,川菜,炒 鱼香肉丝,猪里脊,200g,false,川菜,炒导入时先创建节点再创建关系分两步走// 第一步导入菜品节点和食材节点MERGE 去重 LOAD CSV WITH HEADERS FROM file:///dishes.csv AS row MERGE (d:Dish {name: row.dish_name}) MERGE (i:Ingredient {name: row.ingredient_name}) MERGE (c:Cuisine {name: row.cuisine}) MERGE (t:Technique {name: row.technique}); // 第二步创建关系 LOAD CSV WITH HEADERS FROM file:///dishes.csv AS row MATCH (d:Dish {name: row.dish_name}) MATCH (i:Ingredient {name: row.ingredient_name}) MERGE (d)-[:HAS_INGREDIENT {amount: row.amount, optional: row.optional}]-(i);这里必须提醒一个常见问题file:///指向的是 Neo4j 服务器所在机器的import目录不是你的本地目录。Docker 方式部署时要把 CSV 文件挂载到容器的/var/lib/neo4j/import下否则LOAD CSV会报文件不存在。另外导入时一定要用MERGE而不是CREATE。我第一版用的是CREATE跑完发现一个菜品出现了十几条重复节点查询结果全是笛卡尔积排查了半天才找到原因——CSV 里有重复行CREATE不会去重。4.3 图谱数据的质量保障实体对齐和去重图谱数据质量这块容易被忽略但决定系统上限的恰恰就是它。我遇到的主要有三类问题第一类是同名异义。“土豆”和“马铃薯”指同一个东西但在导入时会被当成两个节点。解决办法是在导入前做实体归一化维护一个同义词映射表统一实体名称。第二类是关系冗余。同一道菜在多个数据源里都有合并时要判断是不是同一个菜品我的做法是用“菜品名 菜系”做联合唯一键。第三类是属性缺失。很多菜谱不标注食材用量和是否可选这类数据直接放弃或打上默认值否则图谱查询会返回不完整的答案。经验之谈图谱数据宁缺毋滥。用户问“宫保鸡丁需要什么食材”如果图谱里只有 60% 的关系是完整的返回的结果还不如纯向量检索。我在初期测试时用了一大批不完整的菜谱数据结果图查询经常“查不到”后来加了数据校验流程只导入关系完整的菜谱条目准确率才上来。5. Milvus 向量检索食材描述的语义化召回5.1 Collection 设计与 Embedding 模型选择Neo4j 负责精确匹配和多跳关系查询但用户提问中经常没有明确的实体词比如“清爽”、“下饭”、“低脂高蛋白”这些描述需要语义理解。我选择把这些“描述性属性”抽取出来做成向量。具体做法是为每个菜品生成一条描述性文档内容包含它的口味标签、主要技法、适用场景、食材营养属性的自然语言描述然后把这整段文本 embedding 成向量存入 Milvus。创建 Collection 的核心代码如下from pymilvus import ( connections, CollectionSchema, FieldSchema, DataType, Collection ) connections.connect(aliasdefault, hostlocalhost, port19530) fields [ FieldSchema(namedish_id, dtypeDataType.INT64, is_primaryTrue, auto_idFalse), FieldSchema(namedish_name, dtypeDataType.VARCHAR, max_length128), FieldSchema(namedescription, dtypeDataType.VARCHAR, max_length4096), FieldSchema(nameembedding, dtypeDataType.FLOAT_VECTOR, dim1024) ] schema CollectionSchema(fields, descriptionDish semantic descriptions) collection Collection(dish_semantic, schema) # 创建 IVF_FLAT 索引nlist 根据数据量调整 index_params { index_type: IVF_FLAT, metric_type: IP, params: {nlist: 128} } collection.create_index(embedding, index_params)Embedding 模型我选的是BAAI/bge-large-zh-v1.5这是一个中文语义向量模型1024 维在中文语义匹配任务上表现稳定。如果你资源有限可以用bge-base-zh或text2vec-large-chinese维度会不同一般是 768 维代码里对应改dim即可。提示metric_type用了内积IP而不是余弦COSINE。bge 系列模型官方建议在 embedding 向量做归一化之后用内积计算效果等价于余弦相似度但速度更快。如果你没有对向量做归一化还是用COSINE更稳妥否则相似度分数语义会有偏差。5.2 数据同步从 Neo4j 到 Milvus 的管道数据进入 Milvus 之前需要做一步转换从图结构生成文本描述。这个过程本质上是一个 Cypher 查询 文本拼接 向量化 写入的管道。核心逻辑如下def generate_description(dish_name: str, ingredients: list, techniques: list, flavors: list) - str: desc f{dish_name}是一道{flavors}风味菜肴。 if techniques: desc f主要烹饪技法包括{、.join(techniques)}。 if ingredients: desc f主要食材包括{、.join(ingredients)}。 # 追加更多属性和标签文本... return desc这一步生成的文本质量直接影响 embedding 的效果。如果只是把食材和技法名平铺拼起来语义召回效果会很差。我的建议是加入一些模板化的描述比如“这道菜口味偏麻辣适合下饭”、“食材以鸡肉为主属于高蛋白低脂肪选项”让文本更接近自然语言的语义空间。生成文本后逐条调用 embedding 接口得到向量写入 Milvus。这里有个工程优化点批量 embedding 比逐条调用快得多一次传 32 条文本吞吐量能提高好几倍。写入 Milvus 也建议用批量 insert。5.3 Attu 验证召回效果同一个语义不同表述Milvus 写数据之后我的习惯是用 Attu 里的查询功能先做一轮验证确认向量召回效果符合预期。在 Attu 的查询界面里可以手动输入一段描述文本选择 collection点击查询就能看到按照相似度倒序返回的结果列表。我的一个测试示例是输入“酸甜口味的猪肉菜”期望返回的结果应该包含“糖醋里脊”、“菠萝咕咾肉”、“锅包肉”等。第一次测试我得到的结果里混进了一堆“酸辣汤”和“酸菜鱼”原因是相似度阈值太低而“酸甜”和“酸辣”在向量空间里距离并不远。这个现象说明单靠向量检索做菜谱匹配容易受表达方式干扰用户说“酸甜”得到的可能更多是“辣”向量的邻居。这也从侧面验证了混合架构的必要性。调整方式有两个方向一个方向是优化描述文本的生成模板把“酸甜”这类 key flavor 词在文本中前置并重复强调另一个方向是在检索后处理阶段加入关键词过滤用户没提辣就把带“辣”标签的菜品过滤掉。第二个方向涉及混合排序后面细说。6. LLM 编排层意图识别、函数调用与答案生成6.1 用提示词把检索策略“教”给 LLMLLM 在这个系统里是“大脑”的角色但它不是知识库的替代品。我通过 system prompt 告诉 LLM你的任务是理解用户问题然后调用提供的工具函数来获取事实信息最后基于返回内容组织答案。核心的 system prompt 简化版如下你是一个烹饪助手。你必须通过调用工具获取事实信息不能根据自己的知识编造菜谱步骤。 可用的工具 1. query_graph: 查询知识图谱参数是 cypher 语句适用于有明确实体或关系的问题。 2. search_semantic: 语义搜索菜品描述参数是自然语言描述文本适用于模糊语义匹配。 3. get_dish_detail: 根据菜品名获取完整菜谱信息。 流程要求 - 首先判断问题类型决定调用哪个工具可以并行调用多个工具。 - 收到工具返回结果后基于结果组织回答。 - 如果结果为空明确告诉用户“知识库中暂未找到相关信息”。这里的关键是让 LLM 学会工具调用function calling。如果模型不支持原生 function calling也可以用 ReAct 模式的提示词模拟让模型输出 JSON 格式的工具调用指令代码解析后执行再返回结果。但原生 function calling 的稳定性和格式规范性要好得多能用原生的就别自己造轮子。6.2 工具层实现Cypher 查询和 Milvus 召回的封装说了这么多看看工具层具体怎么实现。所有工具函数的签名都统一成一个 JSON Schema方便 LLM 按格式调用。query_graph工具的实现会做一层 Cypher 语句的白名单控制。LLM 生成的 Cypher 语句不能直接透传给 Neo4j 执行必须做校验——至少检查是否只包含MATCH和RETURN禁止DELETE、MERGE、CREATE等写操作。这个安全措施一定要有因为 LLM 生成的语句不可控生产环境更要严格限制。def query_graph(cypher: str) - list: # 安全校验禁止写操作和危险语句 forbidden [DELETE, MERGE, CREATE, SET , REMOVE, DROP] upper cypher.upper() for kw in forbidden: if kw in upper: raise ValueError(fForbidden keyword: {kw}) with driver.session() as session: result session.run(cypher) return [record.data() for record in result]search_semantic工具实现时我对返回结果做了字段裁剪只保留 dish_name、dish_id 和相似度得分避免大段文本塞进 Prompt 导致 token 超限。def search_semantic(query_text: str, top_k: int 5) - list: query_vector embed_model.encode(query_text) collection.load() results collection.search( data[query_vector], anns_fieldembedding, param{metric_type: IP, params: {nprobe: 16}}, limittop_k, output_fields[dish_name] ) return [ {dish_name: hit.entity.get(dish_name), score: hit.score} for hit in results[0] ]6.3 多工具并行图谱分支和向量分支的结果融合实际项目里LLM 经常会同时调用多个工具。比如用户问“有没有不用油炸的鸡胸肉菜谱”这个问题既有实体“鸡胸肉”又有约束“不用油炸”还有属性“菜谱推荐”。我让 LLM 同时调用query_graph和search_semantic。图谱查询负责精确匹配鸡胸肉相关的菜品并排除油炸技法向量召回负责找语义上接近的菜品。两份结果需要融合融合策略我用的是加权分数合并图谱命中基础分 1.0匹配“不用油炸”条件的不加分命中油炸的在排序时直接过滤向量命中直接用相似度分数融合分 图谱命中分数 × 0.7 向量命中分数 × 0.3融合后的列表按分数降序取 Top-N 作为上下文片段再返回给 LLM 组织答案。这个权重比例是我调了多轮才确定的图谱结果可信度更高所以权重更大。6.4 Prompt 拼接策略控制上下文长度和信息密度上下文拼接到 Prompt 里有一个很现实的问题——token 超限。一个菜品的信息如果全部展开包含食材、步骤、技法、口味、调料很容易超过 2000 token。而一次问答往往同时返回 5 个菜品全塞进去根本不够用。我的做法是分层次摘要。图谱查询结果先转成简洁的结构化文本菜品宫保鸡丁 所属菜系川菜 主要食材鸡胸肉、花生米、干辣椒、花椒 技法炒 口味麻辣、微甜这种格式信息密度高token 消耗小LLM 理解起来也不费力。详细的食材用量和步骤只在用户明确要求时才去调get_dish_detail工具补充。实操心得LLM 生成的答案质量很大程度取决于上下文的结构化程度。投喂大段 JSON 原始记录让 LLM “自己找重点”效果远不如我们先把重点提炼成简短条目。这个提炼过程应该是代码做的而不是 LLM 做的。7. 工程实现从单个 Demo 到可复用的服务7.1 项目目录结构整个项目我用 FastAPI 封装了一层 HTTP 服务目录结构如下cooking-rag-graph/ ├── app/ │ ├── main.py # FastAPI 入口 │ ├── agents/ │ │ ├── orchestrator.py # LLM 编排逻辑 │ │ └── prompts.py # 提示词模板 │ ├── tools/ │ │ ├── neo4j_tool.py # 图谱查询工具 │ │ ├── milvus_tool.py # 向量召回工具 │ │ └── recipe_tool.py # 菜谱详情工具 │ ├── models/ │ │ └── schema.py # API 请求/响应模型 │ └── services/ │ ├── graph_service.py │ └── vector_service.py ├── data/ │ ├── dishes.csv │ └── descriptions.json ├── scripts/ │ ├── import_graph.py │ ├── sync_milvus.py │ └── test_query.py ├── requirements.txt └── .env这个结构把工具层、服务层和编排层分开了后续如果要加新的数据源或者换成其他 LLM改动的范围可以控制得很小。7.2 函数调用循环LLM 与工具之间的完整交互核心的编排循环代码如下这是一个简化但完整的 function calling 流程def handle_question(user_question: str) - str: messages [ {role: system, content: SYSTEM_PROMPT}, {role: user, content: user_question} ] # 第一轮调用LLM 决定调用哪些工具 response llm.chat_completion( messagesmessages, toolsTOOL_SCHEMAS, tool_choiceauto ) # 如果 LLM 没有调用工具直接返回生成结果 if not response.tool_calls: return response.content # 执行工具函数收集结果 tool_results [] for tool_call in response.tool_calls: tool_name tool_call.function.name arguments json.loads(tool_call.function.arguments) result execute_tool(tool_name, arguments) tool_results.append({ tool_call_id: tool_call.id, role: tool, content: json.dumps(result, ensure_asciiFalse) }) # 第二轮调用把工具结果回传给 LLM 生成答案 messages.append(response) messages.extend(tool_results) final_response llm.chat_completion(messagesmessages) return final_response.content这里有个细节值得注意messages.append(response)这一步很关键。OpenAI 兼容接口要求工具调用完成后原始 assistant 消息包含 tool_calls 字段必须追加回对话历史中然后再追加 tool 角色的结果消息。如果顺序错了或漏了 assistant 消息接口会报 400 错误。有朋友会遇到开头热词里提到的error: llm request failed: provider rejected the request schema or tool payload这个报错的常见原因之一就是 tool schema 格式不符合 provider 要求。比如有些 provider 要求parameters必须是一个合法的 JSON Schema 对象如果你传成了{type: object}之外的格式或者某些字段类型不兼容就会触发这个错误。排查方法是把tools参数单独打印出来格式化检查逐个字段对照官方文档。7.3 常见 LLM 请求错误超时和拒绝实操中 LLM 调用最常见的两个报错我直接给出排查经验。第一个是llm request timed out. the model did not produce a response before the model这类超时问题。原因一般有两个推理模型在 function calling 场景下思考时间过长或者是网络链路慢。解决办法调大超时时间比如从 30s 调到 120s如果业务允许用流式输出配合 SSE 给前端做 loading还有一种情况是模型本身在循环调用工具停不下来这时需要限制最大工具调用轮数我一般设置为 2 轮。第二个是provider rejected the request schema or tool payload这类 schema 拒绝问题。大多数是因为提示词和 tool schema 信息量太大加上上下文过长超过了 provider 的单次请求限制。解决办法精简 system prompt减少工具描述的冗余文本必要时压缩候选上下文后再调用 LLM。7.4 FastAPI 接口封装服务层我用 FastAPI 暴露了一个简单接口app.post(/api/ask) async def ask(request: AskRequest): try: answer orchestrator.handle_question(request.question) return {answer: answer, status: success} except Exception as e: return {answer: str(e), status: error}这个接口本身非常简单但要注意线程安全的问题。Neo4j 的driver对象是线程安全的可以全局共享Milvus 的connections也是线程安全的。但如果你的代码里用了全局的 embedding 模型实例要确认它是否线程安全不安全的模型实例需要用线程锁包起来。8. 图 RAG 的查询优化从 Cypher 到混合检索的细节8.1 典型图查询模式多跳关联和条件排除图查询是这套系统最有优势的地方举几个实际场景的 Cypher 示例都是测试过程中反复用到的模式。场景一多跳关系查询。用户问“川菜里有哪些菜用了花生”需要从 Cuisine 走到 Dish再走到 Ingredient两跳查询MATCH (c:Cuisine {name: 川菜})-[:BELONGS_TO]-(d:Dish) MATCH (d)-[:HAS_INGREDIENT]-(i:Ingredient {name: 花生}) RETURN d.name AS dish_name场景二条件排除。用户问“不用油炸的鸡肉菜谱”需要先找用鸡肉的菜品再排除用油炸技法的MATCH (d:Dish)-[:HAS_INGREDIENT]-(i:Ingredient {name: 鸡胸肉}) WHERE NOT EXISTS { MATCH (d)-[:USES_TECHNIQUE]-(t:Technique {name: 炸}) } RETURN d.name AS dish_name LIMIT 10场景三图谱中的相似推荐。用户问“有没有类似麻婆豆腐的菜”利用SIMILAR_TO关系一步就能找到推荐菜品MATCH (d:Dish {name: 麻婆豆腐})-[:SIMILAR_TO]-(recommend) RETURN recommend.name AS dish_name这三个场景充分说明了图查询的价值——结构化约束和关系推理能力这是纯向量检索做不到的。8.2 混合检索的排序策略图谱分数和向量分数的权重混合检索不是简单地把两个结果列表拼接起来。我在项目里实现了一个比较简单的融合排序函数逻辑如下def fuse_results(graph_results: list, vector_results: list, top_k5): score_map {} # 图谱结果存在即给 1.0 基础分 for item in graph_results: name item[dish_name] score_map[name] score_map.get(name, 0) 1.0 # 向量结果相似度分数乘以权重 for item in vector_results: name item[dish_name] score_map[name] score_map.get(name, 0) item[score] * 0.3 # 按分数降序排列取 Top-K ranked sorted(score_map.items(), keylambda x: x[1], reverseTrue) return [name for name, score in ranked[:top_k]]这个融合逻辑虽然简单但实际效果很不错。它保证了两件事图谱命中的菜品永远排在有语义相似度但无图谱关系的菜品前面向量召回可以作为图谱召回的有效补充捕获那些图谱没有显式建模的语义近似关系。权重参数 0.3 怎么定的我做了几轮评测让三个朋友分别打分对比不同权重下回答结果的满意度。0.5 以上向量权重时图谱的硬约束会被稀释0.2 以下时向量召回的宽松语义匹配基本不起作用。0.3 是一个经验值业务数据不同可能需要微调。8.3 图谱回答的可解释性设计图 RAG 相比传统 RAG 的一个巨大优势就是可解释性。图谱查询的结果可以精确追溯到“哪个菜品、哪个食材、哪个关系”每个答案都能画出一条或者多条路径。我在 API 返回结果里加了一个evidence字段记录答案对应的图谱查询路径或向量召回依据{ answer: 宫保鸡丁是川菜中一道经典菜品主要食材包括鸡胸肉、花生米、干辣椒等。, evidence: { graph_path: 宫保鸡丁 -[BELONGS_TO]- 川菜, graph_path: 宫保鸡丁 -[HAS_INGREDIENT]- 鸡胸肉 } }这个设计的实际价值在于用户或者开发者怀疑答案有误时可以直接检查图谱路径是否合理。传统 RAG 的“黑盒召回 LLM 生成”模式下根本没法定位错误是来自于检索还是生成。而图 RAG 中图谱路径是精确的、可审计的。9. 常见问题与排查技巧实录9.1 Neo4j 常见坑连接、导入和权限我整理了一份速查表直接列出问题和对应的解决方向问题现象可能原因解决方向Python 连接 Neo4j 报错未修改监听地址修改neo4j.conf中server.default_listen_address0.0.0.0LOAD CSV 找不到文件文件不在服务器 import 目录Docker 挂载 CSV 到/var/lib/neo4j/importMERGE 后节点重复唯一约束未创建为 Dish.name、Ingredient.name 等字段创建唯一约束Cypher 执行超时图数据量过大缺少索引为常用查询字段创建索引浏览器访问 7474 无法打开Docker 端口映射错误检查映射容器内 Neo4j 默认监听 7474 和 7687这里要特别强调唯一约束的创建。导入大量数据前一定要先建约束CREATE CONSTRAINT dish_name_unique IF NOT EXISTS FOR (d:Dish) REQUIRE d.name IS UNIQUE; CREATE CONSTRAINT ingredient_name_unique IF NOT EXISTS FOR (i:Ingredient) REQUIRE i.name IS UNIQUE;不建约束的话即使用了 MERGE并发导入时也可能产生重复节点。我在一次大批量导入时因为漏了约束结果图谱里出现了上千个重复菜品节点。9.2 Milvus 常见坑版本匹配和索引异常Milvus 的坑大多是版本相关的。举几个我实际遇到的问题第一个是Attu 连接不上本地 Milvus。最常见原因是 Attu 容器里的localhost指向的是容器自己不是宿主机。要用host.docker.internal或者宿主机的局域网 IP 来连接。Docker Desktop 下host.docker.internal是直接可用的。第二个是搜索时报索引不匹配。我在创建 Collection 时用了IVF_FLAT但搜索时nprobe参数没有传或者传的params格式不对报index not found或者nprobe must be greater than 0。解决办法搜索前需要collection.load()把数据加载到内存并且search_params里要带params: {nprobe: 16}。第三个是删除 collection 后重新创建报错。如果有同名 collection 处于加载状态删除后马上重建可能会遇到元数据残留问题。解决办法先collection.release()再drop等几秒再重建。9.3 LLM 编排的常见坑工具调用循环和上下文超限LLM 编排这块的问题更隐蔽因为错误往往是逻辑错误而不是系统错误。工具调用死循环LLM 反复调用同一个工具每次都返回同样的结果就是不结束。解决办法在编排层加最大工具调用轮数我设置 2 轮超过后强制终止用已有上下文生成答案。上下文超限工具结果和对话历史太长超出了模型的最大上下文长度。解决办法工具返回结果尽量精简保留对话历史时只保留最近两轮如果用的是长上下文模型可以放宽一些。答案中使用幻觉信息LLM 在调用工具拿到结果后仍然会在回答中加入工具结果之外的知识。比如图谱查询返回三道菜LLM 却在回答中补充了第四道菜的步骤。解决办法prompt 中明确要求“只能使用工具返回的信息组织答案”同时让 LLM 在回答末尾标注信息来源图谱/向量降低用户对幻觉信息的信任度。9.4 完整版的“避坑清单”最后把实践中总结的清单完整列出来Neo4j 导入前必建唯一约束Milvus 搜索等待 Collection load 完成collection.load()是异步的需要轮询或加延时LLM 工具调用要对 Cypher 做只读校验禁止执行写操作所有工具返回结果都要做 token 裁剪不要让生成长文本直接进 Prompt向量召回 top_k 不宜过大一般 5-10 条足够图谱结果和向量结果的时间戳最好都记录下来方便后续效果分析和权重调优Embedding 模型要和查询文本匹配中文问题用中文模型中英混合效果会差数据量和索引参数要匹配。小数据量直接FLAT索引即可IVF_FLAT的nlist一般设为4*sqrt(N)左右10. 实践复盘这套方案适合什么场景项目做完之后我对这套“图 RAG”方案的适用边界有了更清晰的认识。它最适合的场景是知识本身具有强结构、实体关系密集、且查询中包含明确关系约束的领域。烹饪是典型例子电商导购商品-类目-属性、医疗问答症状-疾病-药物、企业知识库项目-人员-文档也都是适合的方向。这些场景的共同特点是用户问的问题中有大量“条件约束”和“关系推理”纯向量检索无法精确满足。但如果你的知识库是松散的文档集合比如一堆技术博客、新闻资讯、会议纪要实体关系非常弱用户的问题也以开放型检索为主那么图 RAG 的优势发挥不出来反而增加了图谱构建和维护的成本。这种情况下传统的向量 RAG 加上 rerank 可能更合适。从工程成本来看图 RAG 的引入确实增加了很多工作量。图谱建模需要领域知识数据导入需要清洗和实体对齐混合检索需要调权重的经验。但带来的回报是回答准确率提升、幻觉率下降、可解释性增强。对一个面向生产环境的知识问答系统来说这些回报是值得的。我在实际测试中有一个很深的体会图 RAG 的查询链路里最需要的不是复杂的算法而是清晰的职责划分。Neo4j 管事实精确匹配、Milvus 管语义模糊匹配、LLM 管理解与表达三者不越界系统自然就稳定。如果哪天出现了回答质量下降先检查是哪个环节出了问题而不是一上来就调模型参数。最后说一个后续可以扩展的方向目前图谱数据是离线构建的后续可以做一个半自动的知识更新管道从新增菜谱文档中抽取实体和关系经过人审后写入 Neo4j。另外混合排序的权重目前是静态的可以尝试根据用户反馈做动态调整。这些都是在现有架构上的增量优化骨架不用变。如果你正在做一个 RAG 项目并且被“答非所问”和“结果不可控”困扰不妨试试这个组合。图数据库加向量数据库的混合检索方案值得踩一遍坑。
返回列表