1. 什么是 RAG
RAG(Retrieval-Augmented Generation)的概念最早在 2020 年由 Facebook 的研究人员在论文《Retrieval-Augmented Generation for Knowledge-Intensive NLP Tasks》中提出。在这篇论文中,他们提出了两种记忆类型:
- 基于预训练模型的参数型记忆(当时 LLM 概念尚未普及,但可归类为预训练模型);
- 基于向量的非参数型记忆。
RAG 技术将这两种记忆类型进行了整合,最终在知识密集型的 NLP 任务上,比如问答(QA),比单独使用上述两种类型的记忆获得了更好的效果。接下来将具体介绍 RAG 如何补充 LLM 的短板,以及在两种记忆的具体体现,并使用 LangChain 来实现基本 RAG 流程。
2. LLM 面临的挑战和 RAG 带来的好处
目前来看,LLM 几乎是解决各个任务的最佳解决方案。在通用聊天这一领域,很多大模型都能够实现接近人类的水平表现。但它并非完美,也存在着诸多不足:
- 在没有答案的情况下提供虚假的信息(幻觉);
- 在专业领域表现不足,无法给出回答,这和大模型使用的训练数据息息相关,很多领域的数据是相对封闭的;
- 对于同样的问题可能会产生不同的回答,这在对问题答案稳定性要求高的领域是不能接受的;
- 无法感知不断变化的知识。
可以把大模型比做一个刚毕业找到工作的大学生,他具备了很多通识性的知识,但对组织内部的专业知识知之甚少,因此需要尽快掌握组织内部的领域知识。可以让资深员工手把手传输知识,也可以通过阅读组织内的文档吸收知识。与此类似,RAG 通过问题匹配知识,并将知识带给大模型,再利用大模型出色的生成能力来回答问题,这样大模型这个'新人'就能变得专业,也能感知到不断变化的外部信息。
3. LangChain 的 RAG 实践
在本节,我们将重点利用 LangChain 框架来进行 RAG 实践。
3.1 RAG 架构
典型的 RAG 架构与搜索引擎的架构类似,分为离线和在线部分。其中离线部分是对数据进行索引,这里的索引和传统的搜索引擎的倒排索引不同,这里的索引是对数据的向量化。
从图中我们可以清晰地看到,在离线索引阶段,总共有 4 个主要的步骤:
- 加载内容:非结构化数据通常需要提取内容,比如从 Word 文档、PDF 文档中提取文本内容;
- 内容分块:将提取的内容进一步切分为小块(chunk),这样在匹配问题时可以将上下文缩减到很小;
- 获取向量:对于每个分块的内容获取其向量(embedding),这个获取向量的过程可以借助大模型本身的能力来实现,例如 GPT 就提供了 embedding 的接口;
- 存储向量:将获取的向量通过向量数据库存储起来,方便查询。
这里最终存储的结果就是论文中提出的基于向量的非参数化的记忆。接下来我们再来看在线(检索和生成)的部分。
在 Question 到大模型这条链路中,增加了 Retrieve 这个步骤。用户的问题被 embedding 后,会在向量库中匹配出最佳的内容,并和用户的问题一起,构成 Prompt 交给大模型,大模型根据这个 Prompt 再生成对应的答案返回给用户。除了第二节中提到的 RAG 带来的好处,这里还有一个工程层面的优势,通过 Retrieve 找到与问题最相关的知识,从而减少了上下文,压缩了 Prompt 的 token 数量。
上面两部分构成了 RAG 的基本架构,下面我们将使用 LangChain 来完整的实现一个 RAG 原型。
3.2 基于 LangChain 的 RAG 实现
为了方便我们对比效果,我们首先先实现一个直接将问题抛给大模型的流程,代码如下:
from langchain_community.llms import LlamaCpp
model_home = "~/models/mixtral-8x7b-instruct-v0.1.Q8_0.gguf"
llm_model = LlamaCpp(model_path=model_home)
prompt = "孙悟空几打白骨精?"
print(llm_model.invoke(prompt))
这里,我使用的是本地的大模型 mixtral-8x7b-instruct 8 位量化的版本,通过 LlamaCpp 框架进行加载。模型输出的答案为:
孙悟空与白骨精的第一次较量是在《西游记》第六回中发生的...
可以看到,模型给出的答案并不尽如人意。首先,'三打白骨精'这个故事并不是在原文第六回发生的,其次,给的答案并没有准确的回复'几打'这个问题。即便是 ChatGPT 3.5 也无法回答这样的问题。
我们尝试用 RAG 来解决这个问题。基于 RAG 的流程和架构,我们除了依赖大模型,还需要依赖一个用于向量存储和查询的引擎。为了方便,直接 follow 官方的样例,使用 Chroma。
对于非参数化记忆,我先后选择了目录、《三打白骨精》这章内容和《三打白骨精》概要。
下面的代码实现了 RAG 的离线过程:
from langchain_community.document_loaders import DirectoryLoader
from langchain_community.embeddings import LlamaCppEmbeddings
from langchain_text_splitters import RecursiveCharacterTextSplitter
from langchain_community.vectorstores import Chroma
loader = DirectoryLoader('/Users/trent/dev/data/rag', glob="**/*.txt")
docs = loader.load()
text_splitter = RecursiveCharacterTextSplitter(chunk_size=256, chunk_overlap=200)
splits = text_splitter.split_documents(docs)
embeddings = LlamaCppEmbeddings(model_path=model_home)
vectorstore = Chroma.from_documents(documents=splits, embedding=embeddings)
下面的代码实现了 RAG 的在线过程:
import os
from langchain import hub
from langchain_core.output_parsers import StrOutputParser
from langchain_core.runnables import RunnablePassthrough
os.environ["LANGCHAIN_TRACING_V2"] = "true"
os.environ["LANGCHAIN_API_KEY"] = "langSmith_api_key"
retriever = vectorstore.as_retriever()
prompt = hub.pull("rlm/rag-prompt")
def format_docs(docs):
return "\n\n".join(doc.page_content for doc in docs)
rag_chain = (
{"context": retriever | format_docs, "question": RunnablePassthrough()}
| prompt
| llm_model
| StrOutputParser()
)
我们以 RAG 的形式再次进行提问:
rag_chain.invoke("孙悟空几打白骨精?")
非参数化记忆的不同,得到的答案也不尽相同。对于这个问题,概要作为非参数化记忆,得到的答案最为准确。以下是 LangSmith 中对利用三个外部文件进行试验的结果。
这里要推荐一下 LangSmith 这个可观测性组件,可以清晰的追踪到 RAG 的流程。以下图为例,既可以看到一次 Q&A 的全过程,又可以观测到 Retriever 的输入输出。
以上就是用 LangChain 实现的一个简单 RAG 流程。
Retriever 这个组件的引入可以有效的增强 LLM 的能力,但也会带来新的挑战:
- 外部的知识如何选择,不同的外部知识会带来不一样的效果表现,这就要具体问题具体分析了;
- 外部的知识如何进行处理,chunk 如何切分,chunk size 如何设置等等;
- 提问的模板如何设置,好的提问模板可以充分利用 LLM 的能力,从工程上来讲,Context 的长度也需要尽可能的精简。
这些问题,需要在具体的场景中进行具体的分析,同时也需要有合适的机制通过不断的反馈来积累最佳实践。
4. RAG 优化策略与最佳实践
在实际落地过程中,为了进一步提升 RAG 系统的效果,可以从以下几个维度进行优化:
4.1 数据分块策略
分块(Chunking)是 RAG 中最关键的环节之一。如果分块过大,会引入过多噪声,导致检索精度下降;如果分块过小,可能会丢失上下文语义,导致检索结果不完整。
- 固定大小分块:适合结构规整的文本,但容易切断句子或段落。
- 递归字符分块:LangChain 提供的
RecursiveCharacterTextSplitter 默认按段落、句子等层级递归分割,能更好地保持语义完整性。
- 元数据过滤:在分块时保留原始文件的元数据(如文件名、页码),便于后续溯源和权限控制。
建议根据实际业务场景调整 chunk_size 和 chunk_overlap。通常 chunk_overlap 设置为 chunk_size 的 10%-20% 有助于保留上下文连贯性。
4.2 检索器调优
默认的 as_retriever() 可能无法满足所有需求,可以通过配置参数提升性能:
- top_k:增加检索回来的文档数量,让 LLM 有更多参考依据,但会增加 Token 消耗。
- score_threshold:设置相似度阈值,过滤掉不相关的文档,减少噪声干扰。
- retriever_type:尝试
similarity_search_with_score 或 mmr(最大边际相关性),后者能在保证相关性的同时增加多样性。
4.3 提示词工程
Prompt 的质量直接影响生成效果。除了使用官方推荐的模板,还可以根据业务定制:
- 角色设定:明确告诉 LLM 它是某个领域的专家。
- 约束条件:要求 LLM 仅基于提供的上下文回答,若不知道则说明不知道,减少幻觉。
- Few-Shot:在 Prompt 中加入少量示例,引导模型遵循特定的输出格式。
通过 LangSmith 等工具持续监控 RAG 链路的各个环节,收集 Bad Case 并进行针对性优化,是构建高质量 RAG 系统的关键路径。