14227 words
71 minutes
实际 AI 应用开发中的 RAG、Graph RAG 技术经验

通过这篇文章,总结一下学习和开发 AI 应用近一年中收获的经验与知识。

这篇文章主要解释这三个问题:

  1. 真实项目里的 RAG 为什么经常效果不稳定
  2. 传统 RAG 和 Graph RAG 分别该优化哪些环节
  3. 我在 MiraMate 中是如何把“依赖模型决策”改成“受约束的稳定工作流”的

如果要把全文压缩成一句话,我的结论是:RAG 优化的本质,往往不是堆更多技术名词,而是给系统提供更适合当前任务的上下文。

什么是 RAG(检索增强生成,Retrieval-Augmented Generation)#

RAG 是一种为了解决 LLM(大语言模型,Large Language Model)自身知识容量不足,提高输出效果的技术,目前的 LLM 是利用大量训练资料和数据进行训练得到的权重模型,因此天然存在相当丰富的知识储备,这些编码在 LLM 权重中的信息称为**参数化知识(Parametric Knowledge)**或内隐知识(Implicit Knowledge),在不使用任何技术辅助的情况下,LLM 可以利用训练资料中存在的信息回答用户的问题,但这种情况存在以下的问题:

  • 模型自身的知识容量终究有限,虽然目前顶级大模型训练公司在预训练大模型时的训练数据量已经达到万亿甚至数十万亿 Token 级别,但是终究存在部分不在其数据范围内的信息,尤其是相对冷门、过于专业的资料信息。
  • LLM 的训练数据存在明显的时间的滞后性,LLM 自身能获知的信息一定是早于其训练时间的,这导致对很多需要信息时效性的场景下无法满足需求。
  • 受不同模型训练方法影响,并不是所有被加入训练数据的信息都能被 LLM 准确“回忆”出来,内隐知识(Implicit Knowledge)一词强调这些知识不是以显式的数据库条目存在,而是通过**分布式表示(Distributed Representation)**隐含在网络结构中。通常只有一部分在训练资料中密集出现、权重较高的信息更容易被输入的参数激活,成为输出信息的一部分。

RAG 就是为了解决这些问题而出现的技术方案。RAG 主要指通过在 LLM 回答用户问题前先对知识库进行检索召回,将召回结果与对话上下文和用户的问题一起交给 LLM 进行处理分析从而弥补 LLM 在某些专业知识、或特定领域信息上的不足的问题。加入这一方法不仅能弥补 LLM 在一些领域上的信息缺口,更能有效降低 LLM 的幻觉率,避免 LLM 在不知道准确信息的情况下对实际情况胡编乱造。

现在主流的 RAG 方法分为经典的RAG(Vector-based RAG)和较新的图查询 RAG(Graph RAG),经典 RAG 通过使用特定的**嵌入向量模型 (Embedding Model)将文本或图像信息转化为具备语义信息的高维嵌入向量索引(Embedding Vector)存入向量数据库,查询时将用户输入的问题或关键词使用相同的嵌入模型转化为嵌入向量与向量数据库中的已有向量数据进行比对,由于使用嵌入模型生成的向量是根据语义信息生成的,相似语义的数据生成的向量在向量空间上的距离更近,因此可以使用余弦距离(Cosine Distance)**等方法计算得到与输入问题语义相近的信息作为召回,交给 LLM。

可以用下面这张图快速理解一个最基础的 RAG 工作链路:

flowchart LR A["用户问题"] --> B["Query 理解 / 改写"] B --> C["知识库检索"] C --> D["召回片段 / 文档"] D --> E["与对话上下文拼接"] E --> F["LLM 生成回答"] F --> G["基于证据的输出"]

如何优化传统 RAG 系统#

要优化传统 RAG 系统主要是从查询方法、向量数据库设计、数据预处理三方面进行优化。

查询方法优化#

要优化查询方法其实就是优化输入的查询文本,而要优化查询输入核心就是利用速度较快的小模型对输入内容进行理解,生成查询参数,常见的是以下几种:

查询改写(Query Reformulation)#

这是最常见的一种做法,包括:

  • 关键词提取,从用户的问题里抽取实体、术语、限定词,减少口语对语义信息的噪声干扰。
  • 查询重写,把用户的原始问题改写为更合适检索的表达,比如更完整、更规范、更书面化。
  • 多轮上下文补全,在多轮对话下结合上下文语义补全查询内容,比如“那它的原理呢”补成“RAG 中 rerank 的原理是什么”。
  • 查询扩展, 给原查询补充同义词、缩写展开、相关术语。
  • 歧义消解,经典的比如“Apple”是公司还是水果。
  • 假设答案检索(Hypothetical Document Embeddings),让LLM根据问题生成一段假想的答案或文档进行查询,这一方法的重点是让通常以疑问、口语语气进行提问的用户信息转化为与数据库内容更相似的陈述说明风格的文本来提高检索匹配度。

通常而言做到这一步就能把查询效果提升很多,直观想象就可知这一处理对基于语义检索的向量查询的提升有多么重要。

多查询检索(Multi-Query Retrieval)#

由LLM根据原问题生成多个并行查询,再合并召回结果。

  • 原问题改写+背景补充版
  • 原问题改写+多角度、多类型信息版
  • 原问题拆成多个子问题分别检索

这种方法在针对某些企业、高校内部信息的综合检索对话应用中较为常用,能显著提高检索内容的广度和丰度,由于使用并行查询,通常并不会增加太多查询延迟。

路由检索(Query Routing)#

不是所有问题都走同一种检索逻辑或检索路径,将特定问题路由到最合适的数据源,这种方法需要结合向量数据库设计使用。

对于向量数据库检索这一方法主要体现在路由到不同知识库

从查询方法优化的角度总结,要优化查询效果的核心问题就是“怎么把用户问题转化为适合召回知识的检索表达”,针对这个问题我个人的理解是:Context is all you need. 就是要让查询更加贴合上下文,这里的上下文可以是这些内容:

  • 让query更贴合数据库资料的内容
  • 让query更符合业务范围
  • 让query更符合对话上下文语义
  • 让query尽可能覆盖当前场景下的多检索角度

从实际开发经验来说,除了查询改写几乎是提高查询效果所必须的,通用型对话应用和专业领域问答常用的是路由检索,对于大型企业数据库的查询常用多查询检索来提升查询到的信息范围提升回答效果,不同的方法间也可以灵活结合使用,关键是结合实际业务场景来选择。

向量数据库设计#

向量数据库的设计我们要从几个方面来讨论:向量索引的构建、索引的设计、元数据处理和设计、数据库选型

嵌入向量的构建#

对于AI应用的开发来说这一步通常指的就是嵌入模型的选择,不同的嵌入模型在嵌入向量生成和检索效果时有不同的效果,有一点要注意的是使用什么嵌入模型构建的嵌入向量库就要用什么模型来生成查询的向量,不同的嵌入模型不可混用,因为不同嵌入模型对同样文本的处理和生成的向量可能截然不同,他们处于完全不同的向量空间。不同的嵌入模型由于训练方法和训练数据的不同,存在能力和侧重领域的差别,不同的嵌入模型通常在这些方面显示出区别:

  • 多语言检索生成
  • 商业闭源/开源
  • 对各种专业领域的支持是否广泛
  • 侧重向量检索/聚类

比如国内常见的开源嵌入模型BGE 系列(BAAI)BAAI/bge-m3,BAAI/bge-large-zh-v1.5,BAAI/bge-base-zh-v1.5等强调对中文和多语言的适配,商业产品常用的OpenAI的闭源APItext-embedding-3-small, text-embedding-3-large

此外嵌入模型基于不同训练方法,生成的**嵌入向量维度(embedding dimension)**也不同,比如常见的384,768,1024,1536,不同维度的向量数据对向量查询的影响主要有这些方面:

  • 向量表示空间的大小,更高维度的向量索引可以编码更多语义信息,通常有更细的语义差别、更多上下文特征、更复杂的概念关系,因此可能带来:更好的语义区分能力、更高的召回率、更好的排序相关性。
  • 向量生成成本和相似度计算成本,更高的维度需要更高的计算量来完成每次计算,在实际应用中表现为在大规模检索时吞吐量更低或查询延迟更高。
  • 生成的向量索引体积,对存储成本的影响较大,高维的向量直接代表了更高的磁盘占用、内存压力、数据传输成本,由于数据库在使用向量索引时为了提高读取速度回将一部分索引加载到内存中,更大的空间占用导致加载到内存的占比较低也可能会间接影响查询速度。

向量维度会直接影响向量检索系统的效果与成本。一方面,更高维的向量通常具有更强的语义表示能力,可能提升相似度匹配的精细度和检索质量;另一方面,维度升高也会显著增加向量存储开销、索引大小、相似度计算成本以及查询延迟。因此,向量维度的选择本质上是在“表示能力”与“系统成本”之间做权衡。在实际 RAG 系统中,维度并不是越高越好,更重要的是结合模型质量、语料特征、索引规模和在线时延要求进行综合选择。

嵌入向量空间中的语义聚类示意图

索引的设计#

索引的设计指的就是我们如何利用原始数据资料构建出能够发挥出最好检索效果的文本用于嵌入生成向量索引,这里我们不妨用一个常见的具体业务场景来做说明。

假设我们要将红楼梦中的经典片段“刘姥姥进大观园”写入数据库,很多人的第一反应可能是直接将文本内容进行向量化写入数据库,但首先必须强调的是:不适合直接对整篇长文生成一个嵌入向量,比如一篇文章全文、一篇论文全文、一本书的一整章。关于这一问题我们要理解一点,嵌入模型在生成嵌入向量时会把一次接受到的文本全部压成一个向量,这个向量表达的就是这段文字的一个整体平均语义,但是用户在提问的时候往往需要的是文章局部的信息,一旦对整篇文章进行了向量化,细粒度信息会被淹没,局部信息的权重被稀释、与整体主题不完全相同。同时这也导致召回粒度太粗,即使成功召回了这篇文章,返回的也是很大的文本块(chunk),这不仅浪费上下文,而且在能力一般的较差的模型处理时很可能影响其对关键信息的注意力,降低回答效果。因此为了解决这些问题我们通常需要进行 chunk 切分,将文本切分成多个语义相对完整的 chunk,例如按段落切、按小节切等,让每个 chunk 表达一个更明确的局部主题。

当然,这也不是绝对的,比如在科研型Agent的数据库设计中有时我们需要确保Agent对查阅的每一篇论文有完整的理解,此时依然需要整篇地返回,不过我们依然可以通过后续讲到的一些方法来解决细粒度丢失等降低召回率的问题。

对于不那么大段的文本,我们也有需要处理的工作。回到刘姥姥进大观园的例子,为了提升嵌入模型生成的向量的语义准确性,还需要对文本进行一些处理,比如我们可以对文本进行概述,用下面这段概述文本来生成嵌入向量:

刘姥姥来到贾府,在众人陪同下进入大观园。她一路见到园中精致华美的亭台楼阁、陈设器物和饮食排场,对贾府的富贵繁华感到惊讶,也因言行质朴、见识有限而与园中人物形成鲜明对比。

这种做法能在一定程度上提升检索效果,确保嵌入模型生成的语义符合需求,但是很显然这依然存在容易丢失信息的问题,对于检索来说不够**“显式”**,我们要做的应该是减少“嵌入模型的难度”,让内容更具特征性,就像让人来判断哪些需要被召回,如果我们把关键信息直接提取并写在索引文本里,看一眼就知道这是相关的重要信息,应该被召回,而如果放原文或概述我们可能看到了还要思考一下这到底符不符合要求。

这里如果用流程来理解,会更容易看出“原始文本”和“适合检索的索引文本”之间到底差了什么:

flowchart LR A["原始长文 / 原始片段"] --> B["Chunk 切分"] B --> C["补充标题、人物、场景、主题、关键词"] C --> D["生成更显式的索引文本"] D --> E["Embedding"] E --> F["向量数据库"]

原始文本块与增强索引文本的对比示意图

这时候就可以把文本加工成更适合检索的索引文本:

作品:《红楼梦》
情节标题:刘姥姥进大观园
主要人物:刘姥姥、贾府众人
场景:大观园、贾府
主题:贾府富贵生活、贫富对照、人物反差、讽刺与世俗视角
情节概述:
该情节描写刘姥姥进入大观园后的所见所感。她看到贾府内部精致奢华的园林建筑、生活陈设和饮食排场,对豪门生活感到新奇和震惊。刘姥姥的朴素、拘谨与见识有限,与贾府环境及其中人物形成明显反差,从而突出了贾府的富贵繁华,也增强了作品的讽刺意味和世俗观察视角。
关键词:
刘姥姥进大观园、红楼梦经典情节、贾府奢华、贫富对照、人物反差、讽刺意味、园林描写、豪门生活见闻

这段文本通过加入作品名、情节标题、人物、场景、主题和关键词这些信息大大增强了信息的“清晰度”,对检索意图更友好,并且补足了文本片段的上下文信息,让LLM的回答更准确。

用户在检索时,往往不会直接复述原文,而是会使用“贫富对照”“贾府奢华”“刘姥姥人物作用”“经典情节”等更概括的表达。因此,在生成向量索引时,更合理的做法通常是对原始文本进行适度增强,在保留原始内容的基础上补充作品名、情节标题、人物、场景、主题和关键词,使其成为更适合检索的索引文本。

设计用于生成向量索引的文本,核心并不是简单复用原始片段,而是将“局部内容”加工为“具备明确主题、上下文和检索线索的语义单元”。

元数据处理和设计#

**元数据(metadata)**是描述文本片段来源、属性、结构和业务含义的附加信息。它本身不一定直接参与向量化,但会在过滤、路由、排序、权限控制和结果解释中发挥关键作用。

常见元数据:

  • 文档标题
  • 来源文档 ID
  • 章节标题
  • 作者
  • 创建时间 / 更新时间
  • 文档类型
  • 所属系统 / 模块
  • 标签
  • 语言
  • 权限范围
  • 版本号

元数据的使用类似于SQL查询的条件查询,通过元数据,我们可以在召回信息的时候筛选指定时间、版本、标签的资料,实现精准的约束,由于向量匹配擅长语义相似而不擅长显式约束,这能弥补向量匹配存在的不确定性。

元数据不仅影响检索效果,还影响系统工程能力,比如:

  • 权限隔离
  • 多租户隔离
  • 版本控制
  • 数据源路由
  • 灰度索引
  • 文档生命周期管理

元数据还能提升结果可解释性,比如我们常见的ai对话助手可以在下面列出知识库参考文本,就是通过在召回信息中保留元数据来实现展示来源文档标题、章节、时间、来源等信息的功能。

从上面的介绍里我们可以看出适合加入元数据的字段可以分为以下三类:

  • 文档级元数据(Document-level Metadata),title、source、doc_type、author
  • chunk 级元数据(Chunk-level Metadata),chunk_id、section_title、chunk_index、page_no、token_count
  • 业务级元数据(Business Metadata),权限标签、环境(测试 / 生产)

用一个完整的RAG流程来总结元数据的功能和要点: 检索前过滤,先 metadata 缩小范围(查询方法中的路由检索也可以利用元数据),再做向量检索=》

检索后排序与去重,召回后可以结合 metadata 对召回内容进行优化=》

答案展示与引用,生成答案时可展示来源标题、节标题、页码、更新时间、文档链接,提升可信度。

metadata 过滤与检索流程示意图

数据库选型#

向量数据库的数据组织方式通常以 **collection(集合)为一个整体,类似关系型数据库(SQL)**中的一个库,每一条数据都有document、metadata和embedding,document就是用于生成嵌入向量的文本,使用时需要指定向量匹配方法、使用的嵌入模型等,一次向量检索只会在一个collection中进行检索匹配。

在数据库选型上,不同产品的定位差异非常明显。以我接触过的 FAISS、Chroma 和 Milvus 为例:

FAISS

FAISS 是一个高性能向量检索库,支持大规模向量集合,适合本地实验、算法验证以及需要自行控制索引结构的场景,提供 C++ 和 Python 接口,并且部分算法支持 GPU。它更像一个向量检索引擎/算法库,而不是完整意义上的数据库,考虑到使用复杂度较高,并不适合在一般的业务场景使用。

Chroma

Chroma 更偏向面向个人 AI 应用开发的轻量数据库,内置了 embedding 存储、metadata 管理和检索能力,适合快速构建 RAG 原型或轻量 AI 应用。Chroma 还有直接在应用代码里以内嵌方式运行的版本,在程序启动后可以直接在内存中启动 Chroma 并返回可用的 client,数据写入本地目录,类似 SQLite。在 Python 中使用 Chroma 只需要简单的几行代码即可创建一个集合并使用,对初学者极为友好,不必一上来就考虑各种参数配置、实例部署等操作,因此它在学习、原型开发和小型应用中优势非常明显。

Milvus

Milvus 官方明确把自己定义为高性能、可扩展的向量数据库,面向从本地 demo 到大规模分布式系统的场景,并强调可扩展到非常大的向量规模,是RAG领域真正的生产级可规模化部署的向量数据库,与其他领域的专业数据库一样为规模化、弹性伸缩、故障隔离和长期运行而设计。Milvus 的功能更完整、架构更重,意味着它的部署、运维和理解成本通常会高于本地嵌入式方案,新手或小型项目使用时需要慎重。

数据预处理#

数据处理是我们在构建RAG系统时经常会遇到的流程,因为在很多场景下要存入数据库的数据并不是高质量的文本数据,相反,我们经常会遇到如文本图片、PDF、网页、表格、Markdown等各种类型的数据,这种情况就需要我们进行数据清洗和规范化

核心就是以下几点:

  • 去噪:去掉页眉页脚、导航栏、版权声明、模板话术、OCR 噪声、乱码、无意义换行。
  • 格式规范化:统一标点、空白符、时间格式、编号格式、大小写、全角半角、中英文混排术语。
  • 去重与近重复合并:同一内容多次采集、不同版本重复入库、网页正文和摘要重复,会污染召回结果。
  • 结构修复:把被错误解析的标题、列表、表格、代码块修正回来。
  • 版本与时效处理:旧制度、旧接口文档、过期信息如果不清理或标记版本,很容易把回答带偏

具体方法有很多,网上有不少开源工具可以处理各种情况,也可以用本地部署的LLM或相对便宜的LLM API进行更细致的处理。

数据预处理这一步看起来不像查询优化、索引设计那样“高级”,但在真实项目里往往非常关键。RAG 系统的很多问题并不是出在检索算法本身,而是出在输入知识库的数据质量上:如果原始资料存在噪声、重复、结构错乱或版本过期,那么后面的向量化、召回和生成都只能建立在低质量数据之上。因此,数据预处理的价值并不在于增加系统的能力上限,而在于先保证知识库内容“可用、干净、可信”。

什么是 Graph RAG#

Graph RAG 是一种在传统 RAG 基础上引入图结构检索能力的方案。它通常通过**图数据库(Graph Database)**或知识图谱来组织文本中的实体及其关系,从而让系统不仅能够完成基于语义相似度的检索,还能够基于实体关系进行遍历、关联分析和多跳推理。

图数据库是一类专门用来存储和查询高关联数据的数据库。它不是把数据主要组织成表和行,而是组织成:

  • 节点(Node / Vertex):表示实体
  • 边(Relationship / Edge):表示实体之间的关系
  • 属性(Property):挂在节点或边上的字段值
节点:张三、deepseek、deepseek-ocr2论文
边:张三 -> 就职于 -> deepseek,deepseek-ocr2论文 -> 作者 -> 某研究者
属性:张三{年龄:30},就职于{开始时间:2024}
graph LR A["张三"] -- "就职于<br/>开始时间: 2024" --> B["deepseek"] C["deepseek-ocr2 论文"] -- "作者" --> D["某研究者"]

图数据库的一个核心特点是:它将关系本身作为一等公民进行建模。与关系型数据库依赖外键和 join 在查询阶段临时拼接不同,图数据库会直接把实体及其关系结构化存储下来,因此特别适合处理知识图谱、多跳关系检索和路径推理等任务。

从检索角度看,向量检索擅长“找到语义接近的内容”,而图数据库擅长“表示实体关系与路径结构”。Graph RAG 的核心,就是将这两种能力结合起来:一方面利用向量检索召回语义相关内容,另一方面利用图结构补充实体关系、路径信息和可解释的推理链。

因此,Graph RAG 更适合处理这类问题:

  • 某个实体与哪些实体存在关系
  • 某两个实体之间通过怎样的路径相连
  • 某个结论依赖了哪些中间实体和关系链
  • 某个问题是否需要通过多跳关系才能找到完整证据

在很多基于 Neo4j 的 Graph RAG 实现中,系统通常会先借助 LLM 从用户问题中抽取实体、关系和查询意图,再进一步生成 Cypher 查询语句,在图数据库中执行检索。也就是说,Graph RAG 的检索过程不再只是“把问题转成向量去做相似度匹配”,而是会把自然语言问题转换为对图结构的查询与遍历。

如何优化 Graph RAG#

Graph RAG 的优化通常可以从三个层面展开:图数据构建、检索机制、查询生成。

图数据构建#

与传统RAG系统类似,数据结构的构建对整体查询效果非常重要,因为 Graph RAG 的效果,很大程度上取决于你导入进去的图到底是不是“可用的图”。图数据库的图结构通常使用LLM从文本资料中抽取实体和关系来构建,可以从这些角度考虑图结构的质量:

实体抽取质量#
  • 实体识别是否准确
  • 别名是否统一
  • 同名实体是否消歧
  • 粒度是否合适
关系抽取质量#
  • 关系类型是否清晰
  • 关系方向是否合理
  • 关系是否过泛或过细
  • 是否存在大量噪声关系

由于构建过程存在一定不可控性,图结构导入完成后最好进行清洗与去重:

  • 重复节点合并
  • 重复边去除
  • 无效孤立节点处理
  • 低质量关系过滤

检索机制#

普通的图数据检索通常是直接根据查询语句在图数据库中进行匹配和遍历,但这种方式有一个明显问题:在自然语言问答场景下,用户往往不会直接给出标准化的节点名称,而是使用较模糊、概括甚至带有别名的表达。在这种情况下,如果完全依赖查询语句去定位起始节点,检索效果就会比较依赖 LLM 对问题的理解能力以及图结构本身的命名规范。

随着 Neo4j 官方引入向量索引存储与检索能力,可以进一步为实体节点添加向量索引,使检索流程从“直接图查询”变成“先语义召回候选节点,再进行图查询”的混合模式。具体来说,可以先对节点名称、节点描述或关联文本生成嵌入向量并建立向量索引;当用户提出问题时,系统先通过向量检索召回与问题语义最接近的一批候选节点,再将这些候选节点和相关上下文一并交给 LLM,由其生成更准确的查询语句进行后续图检索。

向量索引的加入,本质上是为 Graph RAG 增加了一层面向语义的候选节点召回机制。它能够帮助系统在自然语言表达不够规范、节点数量较大或节点命名不统一的情况下,更容易找到符合问题需求的起始节点;而图数据库则继续负责沿着这些节点进行关系遍历和路径查询。也就是说,向量索引负责“找到可能相关的点”,图查询负责“确认这些点之间的关系”。二者结合后,Graph RAG 的检索优化就不只是优化图查询本身,还包括如何利用向量索引提升候选节点召回质量,以及如何在图结构和语义相似度之间做融合。

另外,向量索引并不一定只加在节点名称上,也可以加在节点描述、节点摘要、关联文档片段等更丰富的文本字段上。

因此,Graph RAG 的检索起点不再完全依赖显式实体匹配,而是可以先通过向量语义召回缩小候选范围,再通过图查询完成结构化关系检索。

查询生成#

这是 Graph RAG 中非常关键的一个环节。很多人在刚接触 Graph RAG 时,构建出来的系统经常出现“检索不到结果”或“查询语句执行报错”等问题,而这些问题往往都与 LLM 生成查询语句这一环节有关。

与传统向量检索不同,图数据库的检索结果与图结构本身高度相关。也就是说,LLM 只有在充分理解图数据库中的节点类型、关系类型、属性字段和结构约束的前提下,才能生成质量较高的查询语句。否则就很容易出现关系名不存在、字段名错误、查询方向不对,或者生成的语句虽然可以执行但无法准确命中目标数据等问题。因此,这一环节对提示词设计有较高要求,prompt 质量会直接影响 Graph RAG 的检索效果。

在提示词中,通常需要向 LLM 提供以下几类信息:

图结构信息:

比如:

  • 节点类型列表
  • 关系类型列表
  • 属性字段说明
  • 常见查询模式的 Cypher 示例

数据库 schema 与约束信息:

  • 某类节点包含哪些字段
  • 某类关系连接哪些节点类型
  • 哪些关系允许进行多跳查询
  • 哪些字段可以用于过滤、排序或限制返回结果

在实际项目中,few-shot 示例通常也非常有效。例如,可以向 LLM 提供几组“用户问题—对应 Cypher 查询语句”的示例,或者提供针对不同节点、关系和过滤条件的查询样例。这类示例能够帮助模型更准确地理解当前图数据库的查询习惯与结构特征,从而显著提升查询语句的生成质量。

此外,查询生成的目标不仅是生成一条可执行的 Cypher 语句,还包括生成一条范围合理、结果可控、适合后续回答的查询语句。因此,还可以在提示词中进一步加入路径长度、返回字段、结果数量等约束,减少无关路径和噪声数据。

另外,考虑到 LLM 仍然可能偶尔输出效果较差的查询语句,建议设置判断条件,例如查询结果为空或查询执行失败时触发自动重试,并将上一次失败的查询语句以及错误信息提供给 LLM 作为参考,以便其进行修正。

一个简单例子#

如果只讲方法和原因,可能还是显得抽象。下面我用一个简单的流程说明它和传统向量 RAG 的区别。

假设用户问题是:

DeepSeek 有哪些 OCR 相关论文,这些论文的作者后来还参与了哪些项目?

如果只做传统向量检索,系统更可能召回和“DeepSeek”“OCR”“论文”语义相近的文本片段,但不一定能稳定回答“作者后来还参与了哪些项目”这种需要沿关系继续追踪的问题。

而在 Graph RAG 中,更合理的流程通常是:

  1. 先通过向量索引从节点名称、节点描述或关联文本里召回候选节点,例如 deepseekdeepseek-ocr2论文某研究者
  2. 再由 LLM 结合图 schema 生成 Cypher,明确要从“论文”节点走到“作者”节点,再走到“参与项目”节点
  3. 最后将图查询结果整理成回答所需的证据链

Graph RAG 从自然语言到图查询的流程示意图

比如可以生成一条类似这样的查询:

MATCH (c:Company {name: "DeepSeek"})<-[:AFFILIATED_WITH]-(p:Paper)-[:AUTHORED_BY]->(a:Author)
WHERE p.topic CONTAINS "OCR"
OPTIONAL MATCH (a)-[:PARTICIPATED_IN]->(proj:Project)
RETURN p.name AS paper, a.name AS author, collect(proj.name) AS projects
LIMIT 10

这类例子能够看出 Graph RAG 的核心价值并不只是“查图”,而是让系统能够围绕明确的实体与关系路径去组织证据。对这类需要多跳关系、链路解释和实体追踪的问题,图结构检索往往会比纯向量召回更稳定。

总结#

Graph RAG 的优化并不只是图数据库查询速度的优化,而是一个贯穿图数据构建、检索机制和查询生成的系统性问题。图数据质量决定了后续检索与推理的上限;向量索引的引入使 Graph RAG 可以将语义召回与图结构检索结合起来;而查询生成阶段则直接影响图查询语句的准确性、结果范围和系统稳定性。总体来看,Graph RAG 的优化核心不只是“把图查快”,而是优化从自然语言问题到图检索结果返回的整条链路。

Context is all you need#

前面讲的很多优化本质上都可以归结为给 LLM 足够正确、足够具体、足够贴近当前任务的上下文。在 RAG 系统中,提示词工程最重要的原则之一,就是尽可能为 LLM 提供充分且有效的上下文。这里的“上下文”并不只是用户当前输入的那一句问题,而是所有能够帮助模型正确理解任务、生成合理中间结果并完成最终回答的信息,包括业务场景、数据来源、数据库结构、检索目标、用户历史对话、字段约束、示例输入输出,甚至错误反馈信息等。从这个角度看,传统 RAG 中的查询改写、Graph RAG 中的 schema 提供、Cypher 示例、路径限制以及失败重试,本质上都是在补充上下文。所以对 LLM 来说,很多所谓的“能力问题”,其实最后都会转化为“上下文是否充分”的问题。

需要注意的是,提示词并不是越长越好,最理想的情况是能用最少的提示词实现最准确的表达、约束、指导。太长太详细或具体的提示词在部分模型上可能导致注意力分散、错判问题重点、模型能力下降等导致生成效果下降的问题,另外一个重要的现实的问题是更长的提示词会导致更多的token消耗,并往往带来更大的生成时间和算力消耗,增加系统的运行成本。

当然,Context is all you need 只是一个工程上的总结和玩梗。模型能力、数据质量、索引设计和系统约束依然重要,只不过这些因素最终都会体现在“你到底能为模型准备什么样的上下文”上。

通常可以提供给LLM的上下文:

业务上下文#

让模型知道当前任务发生在什么业务场景里。

例如:

  • 当前是客服问答、代码助手,还是企业知识检索
  • 用户在查技术文档、业务规则,还是图数据库中的实体关系
  • 回答更偏解释、排障,还是精确检索

这类信息能帮助模型判断应该用什么思维理解问题,以及该优先关注哪些信息。

结构上下文#

让模型知道数据的组织方式和约束。

例如:

  • 数据库 schema
  • 节点类型和关系类型
  • 属性字段
  • 可过滤字段
  • 哪些关系允许多跳
  • 哪些字段可以返回

在查询生成的场景中这是重中之重,优化这部分的提示词,因为它直接决定了模型能不能生成“合法且有效”的查询语句。

会话上下文#

让模型理解用户这一轮问题是建立在什么历史对话基础上的。

例如:

  • 上一轮提到的实体
  • 上一轮已经确定的条件
  • 当前轮里的代词指代对象
  • 用户已经排除过的选项

在多轮 RAG 场景里,这类上下文非常关键。否则模型很可能把“它”“这个模块”“上一个版本”这些指代理解错,而完整的会话上下文能让 LLM 更准确地理解用户意图,生成更符合用户需求的查询。

任务上下文#

让模型知道这一轮具体要完成什么。

例如:

  • 是要做关键词提取
  • 还是要做 query rewrite
  • 还是要生成 SQL / Cypher
  • 还是要从候选结果中筛选最相关内容
  • 还是要根据证据组织最终回答

很多时候模型出错,不是因为它不知道内容,而是因为它不知道“此刻到底要做什么”。

示例上下文#

给出输入输出示例,让模型理解应该如何执行任务。

例如:

  • 用户问题 → 检索 query
  • 用户问题 → Cypher
  • 错误查询 → 修正后的查询
  • 检索结果 → 最终回答格式

few-shot 之所以有效,本质上也是因为它给模型补充了“行为上下文”。

如何判断优化是否真的有效#

写到这里,一个很现实的问题是:你做了 query rewrite、metadata 过滤、图查询约束、缓存和重试之后,怎么判断它们到底有没有带来真实收益?

在实际项目里,我更倾向于把评估拆成三个层面:

检索层指标#
  • 是否召回到了正确的 chunk / 节点 / 关系
  • Recall@K、Hit Rate 是否提升
  • 路由是否把问题送到了正确的数据源

这一层主要回答的是“该找的东西有没有找回来”。

回答层指标#
  • 回答是否真正建立在召回证据之上
  • 是否出现答非所问、证据不足却强答的问题
  • 是否遗漏了关键事实,或者把多个来源混在一起

这一层主要回答的是“找回来的东西有没有被正确使用”。

工程层指标#
  • P95 延迟是否可接受
  • token 消耗是否明显上升
  • 缓存命中率、查询失败重试率是否合理
  • 新方案是否引入了更多维护复杂度

这一层主要回答的是“系统是否真的变得更适合上线和长期维护”。

很多看上去“检索效果更好”的方案,最后未必值得上线,因为它们可能只是把回答质量换成了更高的延迟和成本。反过来说,有些优化即使对单次回答提升有限,只要能显著降低错误率和不稳定性,在真实系统里往往也很有价值。

实际项目设计分享#

这里我分享一下我在MiraMate中的RAG记忆系统设计以供参考,我对这套记忆系统进行过深度的重构。大家可以从我的重构前后的变化中看出RAG的优化。

首先简单介绍一些我这个项目,它的目标是通过LLM的应用设计模拟人类的长期情感变化,打造可作为长期情感陪伴的对话Agent。

MiraMate-v1(开发于2025年8月)#

这个项目的“记忆系统”本质上不是传统问答型 RAG,而是一个面向情感陪伴场景的“长期记忆 RAG + 情绪状态机 + 多代理工具编排”系统。

基本思路是:把用户相关信息拆成多种记忆类型存进 ChromaDB,再由一个 memory_manager (LLM驱动的Agent) 按需检索、补充和写回,而不是简单做一次“向量检索 + 拼 prompt”。

其中 memory_manager 的提示词为:

你是一个记忆管理专家。你负责:
1. 通过search_memories工具搜索与当前交互相关的过去记忆
2. 通过update_emotion工具更新智能体的情感状态,请注意:这里的情感状态是指智能体的情感状态,而不是用户的情感状态,在你提供信息的时候也要表明这是智能体的情感状态。
3. 通过save_user_preference工具识别并保存用户偏好
4. 通过record_relationship_event工具记录关系发展事件,关系发展事件指的是让用户和智能体之间的关系变得更亲密的互动。
5. 通过spontaneous_recall工具进行自主联想
6. 通过save_user_profile_info工具保存用户关键信息(如性别、生日、家庭成员、过敏食物等客观事实)
7. 通过search_user_profile工具搜索特定的用户关键信息
8. 通过get_user_profile_summary工具获取用户信息的完整摘要
9. 通过delete_user_profile_info工具删除用户关键信息(当用户明确要求删除某些个人信息时使用)
10. 通过delete_user_preference工具删除用户偏好(当用户明确要求删除某些偏好信息时使用)
当前用户的名字是{self.user_name},你要自称"小梦",用户的称呼可以是"你"或{self.user_name}的昵称,但不要用"用户"来称呼用户。
当你收到请求时,请先使用search_memories工具,然后根据需要调用其他工具。
如果接收到"内心思考"提出的用户可能存在的任何偏好,要应尽可能保存。
请注意,用户偏好指的是用户喜欢的事物或活动,比如喜欢的食物、颜色、运动、爱好等,还有用户的习惯和偏好,比如喜欢的交流方式、喜欢的称呼等,要记录具体的偏好内容,而不是模糊的描述。
当收到"内心思考"提出的重要的互动或关系变化时,应把整件事(主要是用户的话、智能体的内心思考和回答)记录为关系事件并酌情调整关系亲密度。
用户关键信息和用户偏好的区别:
- 用户关键信息:客观的、相对固定的事实信息,如性别、生日、家庭成员、职业、过敏食物、朋友、某个特殊日子等
- 用户偏好:主观的喜好和习惯,如喜欢的食物、颜色、运动、交流方式等
当你从对话中识别到用户的关键信息时,应该使用相应的工具保存。如果发现多个关键信息,优先使用update_user_profile_from_chat工具批量保存。
每次更新关系亲密度时,要提供一个-1到1的值,表示关系亲密度的变化,-1表示关系变得更疏远,1表示关系变得更亲密。

RAG设计

  1. 存储层

    使用chromadb.PersistentClient做本地持久化,嵌入模型使用BAAI/bge-base-zh-v1.5,索引用 HNSW,距离空间算法是cosine,参数包括M=32、construction_ef=256、num_threads=4。

    其中记忆分成 4 个 collection:

    • episodic:对话情节记忆
    • preferences:用户偏好
    • relationship:重要关系事件
    • user_profile:用户客观信息
    collectiondocument (向量化文本)metadata
    episodic一段格式化对话文本:时间:... \n用户: ... \n智能体: ...,如果有用户情绪,会再追加一行 用户情绪: ...固定字段:timestamptype="conversation"importancedecay_factor=1.0last_accessed;可选字段:user_emotion(JSON字符串)、context(通常是“内心思考: …”)
    preferences一条偏好描述文本:用户喜欢{category}: {item}用户不喜欢{category}: {item}categoryitemsentimentcertaintytimestamplast_confirmed
    relationship关系事件文本,本质上就是事件描述,比如“关系亲密度从 x 变为 y”或某次重要互动描述timestamptype="relationship_event"importancerelationship_levelimpact
    user_profile一条用户客观信息文本:用户{category}: {value}categoryvalueconfidencesourcetimestamplast_updated
  2. 写入层

    每轮回复后,主流程不会阻塞等待,异步写回记忆

    写回分两步:

    • 先把本轮 用户输入 + AI回复 + 用户情绪 + 内心思考 存成 episodic memory
    • 再把“内心思考”发给memory_manager,由它决定是否更新:
      • AI 自身情绪 update_emotion
      • 关系事件 record_relationship_event→relationship
      • 用户偏好 save_user_preference→preferences
      • 用户档案 save_user_profile_info / update_user_profile_from_chat→user_profile

    回写时给 memory_manager 的额外提示词为:

    根据以下内心思考,请:
    1. 判断是否需要更新智能体情感状态(使用update_emotion)
    2. 判断是否记录关系事件(record_relationship_event)
    3. 根据内心思考的建议处理可能的用户偏好(save_user_preference)
    4. 记录可能的用户信息(save_user_profile_info),用户信息也包括上下班时间这些日常时间,假如用户提到时间相关的内容(如"昨天"、"上周"、"最近"等),
    请结合当前时间记录具体的时间,比如用户说明天下午要考试,当前时间是2025-06-09,星期一,你就要记录用户的考试时间为2025-06-10,星期二下午。
    内心思考内容:
    {inner_thoughts}
    当前对话上下文:
    用户输入: {user_input}
    智能体回答: {response}
    用户情绪: {emotion} ({valence})
    当前状态:
    当前情绪: {current_emotion}
    当前关系: {relationship_level}/10
    如果需要记录关系事件或用户偏好,请简要描述事件内容和重要性。
    如果本次的回复内容是报错信息,就不要记录任何内容。
  3. 检索层

    每轮对话时把用户输入发给 memory_manager,并附带当前时间,让它结合“昨天 / 上周 / 最近”这类时间词做检索。

    检索时的额外提示词:

    请搜索与以下用户输入相关的记忆,并获取完整的用户信息。
    用户输入: {user_input}
    当前时间信息:
    - 日期时间: {time_str}
    - 星期: {weekday}
    请在搜索记忆时考虑时间因素,如果用户提到时间相关的内容(如"昨天"、"上周"、"最近"等),请结合当前时间进行准确的记忆检索。

    这次调用的输出直接作为中间思考和主回答输出的记忆上下文,由于 memory_manager 是一个具备工具调用功能的Agent,它可以根据LLM的判断分别从各个collection和情绪状态机中读取记忆数据,因此没有直接的关于输出格式的要求,直接输出整理好的记忆上下文。

根据以上的RAG设计我们可以看出,在我V1的设计中,检索功能主要是基于 memory_manager 的判断、工具调用和信息整理,直接由代码实现的内容主要是提供给agent的工具如search_memories,get_user_profile_summary,search_user_profile,方便LLM进行调用。

这种方式的优势很明显:依靠模型决策和理解能力动态调用工具进行检索和汇总,减少了人为设计检索流程和方法的工作量,整体流程也比较直观,易于理解。但是它的问题也很明显,检索质量会高度依赖模型能力和当次决策稳定性,而不是更多地依赖系统本身的约束与设计。对很多希望效果稳定、可调试、可评估的真实应用来说,这种方式通常并不稳妥。另外这个版本还有一些具体问题,比如检索时没有给 Agent 提供充分的对话上下文、嵌入向量的 metadata 没有被充分利用等。

MiraMate-v2(开发于2025年8月)#

这个版本的记忆系统,本质上是一个“四层记忆”设计:

  1. 短期会话记忆:TimeTokenMemory 保存当前会话消息,按 token、时间、连续性裁剪。
  2. 长期语义记忆:ChromaDB 里 4 个 collection,统一用 bge-base-zh-v1.5 做 embedding,HNSW 配置是 cosine / M=32 / construction_ef=256。
  3. 会话级检索缓存:检索到的长期记忆先进入 MemoryCache,默认 TTL 5 轮。在对话中有些记忆信息虽然不是与本次input最相关的,但是在前几轮被提及、召回过,为了保证在后续的对话中保持记忆的连贯表现,通过缓存衰减让被召回过的记忆在后续轮次也加入模型上下文。
  4. 缓冲与空闲固化:事实/偏好/画像更新先写 JSON cache,空闲时再整合写回长期记忆;重大事件也是空闲时从最近对话和临时关注事件里再识别出来。

v2与v1的一个很大的区别在于v2不再使用真正的Agent调用工具的方式的来处理每个环节,而是利用Langchain的Runnable接口直接编排调用模型API接收输入并输出json参数,因此没有单独的Agent提示词。

RAG 整体链路:

  • 先用小模型跑understanding_chain,产出 intent / emotion / memory_query。
  • 再用 memory_query 去做综合检索:dialog_logs + facts + user_preferences + important_events,外加 temp_focus_events 的关键词匹配。
  • 新检索到的记忆先放进会话缓存,再把“当前有效缓存记忆”注入回答流程中的系统提示词。
  • 系统提示词里同时还会注入 user_profile.always_remember、当前状态、近期关注事件。

如果把 v2 的主链路画出来,大致是这样:

flowchart LR A["用户输入"] --> B["understanding_chain"] B --> C["intent / emotion / memory_query"] C --> D["综合检索<br/>dialog_logs / facts / preferences / events"] D --> E["MemoryCache"] E --> F["系统提示词注入"] F --> G["主回复模型"] G --> H["最终回答"] G --> I["异步写回"] I --> J["JSON cache / 临时缓冲"] J --> K["长期记忆固化"]

长期语义记忆:

collectiondocument (向量化文本)metadata
dialog_logs[对话摘要] 这是一段记录于 {natural_time} 的对话。对话主题是“{topic}”,整体情感基调为“{sentiment}”,相关标签为“{tags_str}”。
[对话内容]
用户:{user_input}
AI:{ai_response}
type = "dialog_log"timestamptopicsentimentimportancetagsadditional_metadatais_potential_major_event
facts[事实记忆] 这是一条记录于 {natural_time} 的事实,来源是“{source}”,相关标签为“{tags_str}”。
事实内容:{content}
type = "fact"timestampsourceconfidencetagsadditional_metadata
user_preferences[用户偏好] 这是一条记录于 {natural_time} 的关于用户的偏好信息,类型为“{preference_type}”,相关标签为“{tags_str}”。偏好内容:{content}type = "preference"preference_typetags timestampadditional_metadata
important_events[重大事件] 这是一条记录于 {natural_time} 的重大事件。事件类型为“{event_type}”,概要是“{summary}”,相关标签为“{tags_str}”。

[详细内容]
{content}
type = "important_event"event_typesummary tagstimestampadditional_metadata

我们从RAG的角度分析一下这里的collection设计:

  • 提供基于对话上下文提取的对话摘要信息,补全了这段对话信息的背景→补全文本的业务上下文
  • 时间信息使用将标准时间编码转化为自然语言的表述方式,更贴合查询时可能的查询方式,并降低后续模型使用该内容作为记忆上下文的理解难度,提升回答质量
  • 抽取出文本标签并写入→提高嵌入向量的语义准确度(降低嵌入模型的“理解难度”)

检索层:

这里具体展示一下我在understanding_chain中的做法:

understanding_chain = (understanding_prompt | small_llm | JsonOutputParser()).with_config(run_name="EnhancedUnderstandingChain")

understanding_chain的模型提示词

你是一个对话分析专家。
基于近期对话历史,分析用户最新输入。
提取核心意图、情感,并生成一个最适合用于向量数据库检索的精准查询语句。
数据库中的记忆内容是以 AI 第一人称视角记录的……
对话历史:
{conversation_history}
用户最新输入: {user_input}
当前时间: {current_time}
严格输出 JSON,包含:
- intent
- emotion
- memory_query

这里的提示词我们可以看到一些对LLM工作场景上的优化细节:

  • 你是一个对话分析专家→明确工作场景和思考方式
  • 基于近期对话历史,分析用户最新输入→明确任务需求
  • 提取核心意图、情感,并生成一个最适合用于向量数据库检索的精准查询语句→任务的具体处理方法,指导模型正确地完成生成任务
  • 数据库中的记忆内容是以 AI 第一人称视角记录的……→明确数据存储方式,提示模型使用第一人称的查询
  • 当前时间→针对本项目24小时情感陪伴Agent的场景的额外信息,帮助模型判断对话时间、查询相关时间的记忆

使用 Langchain 的 LLM 工作流编排方式构建整体系统,这更符合普遍 RAG 场景下的工作模式。模型不再需要做复杂且关键的全局决策,而是根据预先设定好的工作流输出下一个环节所需的参数,成为流水线上的一个环节。这样做显著降低了模型带来的不确定性,而省掉额外的工具调用流程也让一次完整链路的运行时间明显缩短,用户体验更稳定。

相较于 v1,我认为 v2 的改进主要体现在三点:

  • 模型职责被收窄了,LLM 更像一个“局部任务执行器”,而不是负责全局决策的 Agent
  • 检索链路变得更显式、更可观察,哪些输入影响了 query,哪些记忆被召回,哪些上下文被注入,都更容易调试
  • 会话缓存和分层记忆让系统在多轮对话里的连续性更自然,不需要每一轮都完全从零开始重建记忆上下文
  • 记忆文本包含更完善的对话上下文信息,使得检索和使用这些记忆时有更好的表现

当然,v2 也不是没有代价。相比让 Agent 自由调用工具的方式,工作流编排意味着更多规则前置和更多人工设计,系统的灵活性会下降一些,后续每增加一种新记忆类型、新检索策略,往往都要显式修改链路本身。这也是在设计 RAG 系统时需要抉择的部分。

另外,严格来说,RAG 系统里还有一个很常见但我在本文没有展开的话题,就是 reranker(重排序模型)。它通常用于在向量检索初步召回一批候选结果之后,再对这些候选片段做一次更精细的相关性排序,以提高最终送入 LLM 的上下文质量。由于我自己目前还没有在实际项目里深入使用和调优这类模型,所以这篇文章没有详细展开这一部分,只在这里作为一个值得继续关注的优化方向提一下。对一些召回范围较大、候选片段质量参差不齐的 RAG 场景来说,reranker 往往是很重要的一环。

从 MiraMate v1 到 v2,我逐渐明确了一点:在 RAG 系统里,不应该让模型承担过多高不确定性的全局决策,而应该让模型在受约束的流程里完成自己最擅长的局部任务。

无论是传统 RAG 的 query rewrite、metadata 利用、索引文本设计,还是 Graph RAG 中的 schema 提供、候选节点召回、Cypher 生成约束,本质上都在做同一件事:为模型补充正确的上下文,并让模型充分发挥自身能力。

所以,RAG 的优化很多时候不是“换一个更强的模型”就能解决,而是重新设计从数据入库、检索、排序到生成的整条链路。

实际 AI 应用开发中的 RAG、Graph RAG 技术经验
https://contrue.top/posts/ragexpfromrealaiproject/
Author
contrueCT
Published at
2026-03-06
License
CC BY-NC-SA 4.0