首页 / 文章

从零构建RAG:分块、嵌入、存储、检索,并结合上下文给出答案

使用 LangChain 和 FAISS 逐步讲解 RAG 流程:分块、嵌入、检索、提示词关联、常见错误及后续改进方向。

发布者:REACTAPP.TOP阅读约 8 分钟1915 词

来源: RAG, Explained the Way I Wish Someone Had Explained It to Me

询问聊天模型某家公司的退款政策内容,它很少会承认不知情。更多时候,它会生成一段流畅且看似可信的文字,但实际上与真实政策毫无关联。这并非模型存在缺陷——语言模型只需训练一次后就固定不变了。它从未阅读过任何私人PDF、内部维基或Notion工作空间中的内容,对其训练截止日期之后的所有信息也一无所知。

检索增强生成技术,通常简称为RAG,是解决这一问题的标准方法,其原理其实简单得令人惊讶。系统并不指望模型记住答案,而是先找到相关文本并将其直接放入提示词中,再让模型阅读并作出回应。其余部分则属于确保该检索过程可靠的工程实现。下方的示例就是一个能够根据公司政策PDF回答问题的客服机器人。

两名学生,同一场考试

想象有两个人参加同样的测试。第一个人几个月前就背下了教科书,于是凭记忆作答。如果书本已经过时,或者有章节记不清了,他的答案虽然自信满满,却是错误的。第二个人则可以翻阅教科书。在作答前,他们会翻到相应的页面,阅读内容,再将这些信息与自己的推理相结合。

通常第二个人会获胜,而这并非因为他们更聪明。在发言前先查阅资料只是更有效的作答方式。在RAG系统中,语言模型扮演第二个人的角色,公司数据相当于那本可以翻阅的教科书,而向量数据库则起到索引的作用,指示该翻到哪一页。

两个流程,而非一个

无论多么复杂的RAG系统,本质上都是由两个独立的流程在中间相连而成的。

  • 索引构建在离线状态下进行,初始生成一次,之后文档有变动时还会重新生成。原始文件会被转化为可搜索的索引。
  • 每次提问时都会进行检索与内容生成操作:系统会搜索索引,选出最佳内容并传递给模型。

初学者常常会试图想象一个单一的长流程,这反而会让人困惑。将其分为两个部分会更清晰,因为这两部分在不同时间运行,解决不同的问题。

构建索引

加载文档

首先需要将原始内容转换为纯文本。PDF、Word文件、网页以及导出的笔记,都需要使用能够返回文本的加载工具。

from langchain_community.document_loaders import PyPDFLoader

loader = PyPDFLoader("company_policy.pdf")
documents = loader.load()

print(f"Loaded {len(documents)} pages")

文本分块

文本分块值得比通常更多的重视。一个非常常见的初始错误是将整份文档作为一个巨大的整体直接放入向量存储中。嵌入模型会将一段文字压缩成固定大小的向量;如果输入二十页内容,得到的结果只是所有内容的模糊平均值,虽然能表明文件大致涉及公司政策,却无法体现其中关于三十天退款期限的特定段落。

解决办法是将文档切割成相互重叠的小块。重叠是必要的,因为恰好在段落边界处切分的句子会失去两侧的上下文,而保留约百分之十到二十的比例作为缓冲区,可以有效避免这一问题。

from langchain.text_splitter import RecursiveCharacterTextSplitter

splitter = RecursiveCharacterTextSplitter(
    chunk_size=500,      # characters per chunk
    chunk_overlap=80,    # overlap between consecutive chunks
    separators=["\n\n", "\n", ". ", " "]
)

chunks = splitter.split_documents(documents)
print(f"Split into {len(chunks)} chunks")

作为参考,过大的文本块会导致检索结果模糊,而过小的文本块则会丢失上下文信息。初始范围设定在300到800个字符左右较为合理,之后可根据实际检索结果进行调整。

嵌入每个文本块

嵌入模型会将文本转换为一系列数字,这些数字反映的是文本的含义而非仅仅是字面表述。即使两句话完全没有任何相同的词汇,表达相同意思的句子也会生成彼此相近的向量。

from langchain_openai import OpenAIEmbeddings

embedding_model = OpenAIEmbeddings(model="text-embedding-3-small")

# under the hood, this is what happens per chunk:
vector = embedding_model.embed_query("30-day refund window for annual plans")
print(len(vector))   # e.g. 1536 numbers representing this sentence's meaning

可以将其想象成一张平面地图,相关的概念会聚集在特定的区域里。

        "refund policy" •
                          \
                           • "money-back guarantee"
   "vacation days" •
                     \
                      • "paid time off"

“退款政策”这类短语会与“退款保证”出现在相近位置,尽管用词不同,这正是语义搜索的精髓所在。这也解释了为何RAG能优于传统的Ctrl+F关键词匹配方式。

存储向量

每个向量都会连同原始文本一起存入向量数据库中,而那个数据库就是学生可以翻阅的索引。

from langchain_community.vectorstores import FAISS

vectorstore = FAISS.from_documents(chunks, embedding_model)
vectorstore.save_local("faiss_index")

FAISS是个不错的入门选择,因为它免费且可在本地机器上运行,Chroma也同样简单易用。当需要处理大规模数据时,Pinecone或Weaviate这类托管服务则更为合适。各方案的核心概念并无差异,仅有存储方式和基础设施有所不同。

索引构建工作现已完成。这样就能从文档中创建出可搜索的索引,且只需在文档内容发生变化时重新构建,无需针对每个用户的查询都重新处理。

回答问题

每当有人输入查询时,这一部分就会被执行。

使用相同模型嵌入问题

query = "Can I get a refund on my annual subscription?"

查询时必须使用与索引时完全相同的模型。人们常常会犯这个错误。如果索引是用某种嵌入模型构建的,而查询时却使用另一种模型,那么向量就会处于不同的语义空间中,相似度搜索就会失效。虽然不会出现错误提示,但查询结果会很差,因此原则是始终使用同一模型。

获取最相似的片段

retriever = vectorstore.as_retriever(search_kwargs={"k": 4})
relevant_chunks = retriever.invoke(query)

for chunk in relevant_chunks:
    print(chunk.page_content[:100], "...")

在内部,FAISS会执行相似度搜索。它会计算问题对应的向量与每个存储的文本片段向量之间的距离,通常使用余弦相似度,然后返回距离最近的那些片段。这其实就是“地图上的聚类”概念的逆向应用,用来找出问题所属的聚类。

将文本片段粘贴到提示词中

这就是让RAG发挥作用的关键步骤:检索到的文本会被直接插入到提示词中。

from langchain_core.prompts import ChatPromptTemplate

prompt = ChatPromptTemplate.from_template("""
Answer the question using ONLY the context below.
If the answer isn't in the context, say "I don't have that information."

Context:
{context}

Question: {question}
""")

要求仅依据所提供的上下文进行回答,这一规则能让模型保持务实,避免凭空编造内容。它将允许即兴发挥的开卷考试与封闭式考试区分开来。

将其串联成链

现在这些组件已被整合成RAG链,也就是人们谈论构建RAG系统时所指的内容。在LangChain中,通常使用管道操作符来表示这种连接,即将左侧的输出传递给右侧的步骤。

from langchain_openai import ChatOpenAI
from langchain_core.runnables import RunnablePassthrough
from langchain_core.output_parsers import StrOutputParser

llm = ChatOpenAI(model="gpt-4o-mini")

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
    | StrOutputParser()
)

answer = rag_chain.invoke("Can I get a refund on my annual subscription?")
print(answer)

从左到右阅读,整个流程会自行展开。问题被输入后,相关内容会被提取并格式化为上下文,而问题本身则保持不变;随后提示模板被填充,模型被调用,最后从模型的回复中解析出纯文本。

运行该系统后,得到的不再是凭空猜测的答案,而是类似“是的,根据退款政策,年度套餐在购买后30天内可享受全额退款”的结果。这样的答案有依据且具体,可追溯到PDF中的真实段落。

为何不把所有内容都放进去呢?

一个合理的反对意见是,现在的上下文窗口已经很大了。为何不在每个提示中都放入整个文档,从而省去向量搜索的步骤呢?

  • 成本与延迟。每发送一个标记都需要花费金钱并耗费时间。对于一个包含500页内容的知识库,如果答案仅存在于一个段落中,却将所有相关内容都发送出去,显然是浪费资源。
  • 检索效果下降。即便使用较大的上下文窗口,模型对嵌入在提示词中较深位置的信息的利用效率,仍明显低于直接且简短提供的信息。
  • 扩展性问题。一旦知识库的规模超出任何给定的上下文窗口范围,比如数千份PDF文件、整个公司的维基页面或数年的支持工单,直接粘贴所有内容的方式就会彻底失效。

RAG技术使得系统能够在提示词长度不变的情况下,从单份文档扩展为整个知识库。只有检索阶段会处理全部数据集,而提示词只会看到少量相关内容片段。

早期就会出现的错误

  • 糟糕的分块方式会破坏检索效果,与模型质量无关。如果表格被一分为二,或者句子在表达中途中断,后续流程无法修复这些问题。在指责模型之前,应先检查检索质量。
  • 不匹配的嵌入模型会让相似度得分失去意义。用 text-embedding-3-small 构建的索引无法用其他模型进行查询。
  • 检索的块数过少或过多都会带来问题:k=1 可能会遗漏真实答案,而 k=20 时模型会淹没在大量噪声中,导致成本上升。建议从 3 到 5 开始,再根据结果进行调整。
  • 如果没有针对未知情况的备用方案,即使相关上下文就在眼前,模型仍会编造答案。需明确规定当上下文无法提供答案时应如何处理。

下一步该做什么

当基本流程能够正常运行后,接下来的自然升级方向有三种。

  • 混合搜索将向量相似度与关键词匹配相结合,因为某些查询,如精确的产品代码或名称,通过关键词能得到更好的处理效果。
  • 重新排序机制会先获取更广泛的候选项,然后再让第二个更精确的模型对它们进行重新排序,之后语言模型才会查看这些候选项。
  • 元数据过滤会将日期、来源或部门等详细信息附加到每个数据块上,这样在启动相似度搜索之前就能缩小检索范围。

只要“数据块划分、嵌入、存储、检索和生成”这一基本流程能够正常运行,上述这些方法都不重要。真正理解它的最快方法就是加载一份重要的PDF文件,故意制造一些问题,比如使用不匹配的嵌入模型或没有重叠的数据块,然后观察回答质量如何下降。通过RAG,故障模式带来的启示往往比正常运行时的情况更多。

相关阅读

  • 实用笔记:Google Cloud RAG的三大层级——向量搜索、RAG引擎与智能体检索 — 详细讲解实用笔记内容:Google Cloud RAG的三大层级,包括向量搜索、RAG引擎及智能体检索,并提供相关代码模板、检查项及可直接使用的代码片段。
  • Databricks上的状态型RAG:借助Lakebase与LangGraph实现多轮对话记忆 — 为Databricks的RAG智能体赋予真正的对话记忆功能:解析PDF文件、构建向量搜索系统、在Lakebase Postgres中保存对话记录,最后通过MLflow和模型服务框架进行部署。
  • 无需计算机科学学位,如何在2026年成为AI工程师 —— 一份针对没有计算机科学学位的人在2026年成为AI工程师的从零到就业计划:七个技能领域、小型项目、实际工作经验以及高效的人脉建立。