三亩地 三亩地SAN MU DI · CODE DIARY
ARTICLE DETAIL

日记详情

真实记录编程学习的某一天,欢迎挑你感兴趣的翻一翻。

从零构建RAG系统:基于向量检索与大模型的事实问答实战

从零构建RAG系统:基于向量检索与大模型的事实问答实战

1. 项目概述:从一个简单案例切入RAG

最近和不少朋友聊起大模型应用,发现大家普遍有个困惑:都说RAG(检索增强生成)是解决大模型“幻觉”和知识过时的利器,但看了一堆架构图和技术名词,像“向量化”、“召回”、“重排序”,感觉云里雾里,真到自己动手时,还是不知道从哪开始。这感觉我特别理解,新技术刚出来时,文档往往偏向理论,缺的就是那临门一脚的实战感。

今天,我就想抛开那些复杂的框图,用一个你绝对能亲手复现的简单案例,把RAG的里里外外讲明白。我们不用什么复杂的业务系统,就做一个最经典的“事实问答”应用:你给它一段文本资料(比如一篇技术文章或产品说明书),然后向它提问,它能从资料里找到准确信息来回答你。这个案例麻雀虽小,五脏俱全,涵盖了从原始文本处理到最终智能回答的全流程。无论你是想快速理解RAG核心思想的开发者,还是计划构建自己知识库的创业者,跟着这个案例走一遍,你都能对“知识切片、向量化、多路召回、重排序”这些听起来高大上的概念,建立起清晰、直观的认知。我们会用到一些主流且易上手的工具,但重点不在于工具本身,而在于理解每一步“为什么”要这么做,以及不同选择背后的权衡。

2. 案例设计与核心思路拆解

2.1 案例场景定义:为什么选择“事实问答”?

我们选择“事实问答”作为入门案例,原因在于它的目标极其纯粹和可衡量:答案必须严格来源于给定的文本资料,并且回答要精准。这直接命中了RAG要解决的核心痛点——可控性与准确性

想象一下,你是一家科技公司的客服主管,手里有一份最新的、长达50页的产品故障排查手册(PDF格式)。当客户咨询“设备型号ABC在低温环境下启动报错代码E102该如何处理?”时,你面临两个选择:

  1. 让客服人员人工翻阅50页PDF,效率低下且可能遗漏。
  2. 将PDF丢给一个通用大模型(比如ChatGPT),它可能基于训练数据泛泛而谈,甚至“捏造”一个不存在的处理步骤,风险极高。

而RAG方案是:先将这50页手册“喂”给系统,系统理解并存储后,当用户提问时,它不是凭空生成,而是去存储的知识里精准查找相关片段,然后基于这些确凿的证据来组织语言回答。这样,答案的源头被锁定在了手册内,既高效又可靠。我们这个简单案例,就是模拟这一过程,只不过我们把手册换成了一篇易于管理的短文。

2.2 技术方案选型与工具链

为了快速实现并聚焦原理,我们选择一条轻量、主流的技术路径:

  • 大语言模型(LLM):选用开源且API友好的DeepSeekQwen系列。它们性能优秀,并且提供了易于使用的接口,让我们不必在本地部署上耗费精力。关键在于,我们需要使用其“对话”或“补全”能力,根据我们提供的“上下文”来生成答案。
  • 向量数据库与嵌入模型:这是RAG的“记忆”核心。我们将使用ChromaDB,因为它简单易用,无需复杂配置,适合原型验证。与之配套的,我们需要一个“嵌入模型”将文本转换为向量。这里选择BAAI/bge-small-zh-v1.5,这是一个在中文文本上表现优异的小规模模型,足够我们案例使用。
  • 文本处理与检索框架:虽然LangChainLlamaIndex这类框架能极大简化流程,但为了彻底理解底层机制,在第一个案例中,我们刻意不使用它们。我们将用纯Python代码手动实现核心步骤,包括文本分割、调用嵌入模型、操作向量数据库、组织提示词。这就像学开车先弄懂离合器、油门和方向盘的关系,而不是直接依赖自动驾驶。

这个选择背后的逻辑是:避免黑盒。很多初学者卡在RAG效果不好,却不知问题出在“向量化不准确”、“检索策略不当”还是“提示词没写对”。我们从零搭建,就能清晰地感知每一个环节的影响。

2.3 整体工作流程预览

我们的案例将严格按照以下流水线执行,这也是一个标准RAG系统的核心骨架:

  1. 文档加载与预处理:读取我们的示例文本文件。
  2. 文本分割(知识切片):将长文本切割成语义连贯的小片段(chunks)。这是关键的第一步,分割的好坏直接影响后续检索的精度。
  3. 向量化(嵌入):使用嵌入模型,将每一个文本片段转换为一个高维向量(一组数字),并存入向量数据库。这个向量代表了该片段的“语义”。
  4. 检索(召回):当用户提出问题时,同样使用嵌入模型将问题转换为向量。然后在向量数据库中,计算问题向量与所有文本片段向量的“相似度”(如余弦相似度),找出最相似的几个片段。这就是“召回”阶段。
  5. 生成:将用户问题和召回到的相关文本片段,一起组合成一个详细的“提示词”,发送给大语言模型。指令通常是:“请严格根据以下上下文信息回答问题,如果上下文不包含答案,请说‘根据已知信息无法回答’。” LLM据此生成最终答案。

接下来,我们就进入实操环节,看看每一步具体怎么做,又会遇到哪些坑。

3. 核心环节实现与实操详解

3.1 环境准备与依赖安装

首先,我们创建一个干净的Python环境(推荐使用conda或venv),并安装必要的库。这里不追求最新版本,而是以稳定、兼容为首要目标。

# 创建并激活虚拟环境(以conda为例) conda create -n rag-demo python=3.10 conda activate rag-demo # 安装核心依赖 pip install chromadb # 向量数据库 pip install sentence-transformers # 用于加载BGE等嵌入模型 pip install openai # 我们将使用其兼容的API格式调用DeepSeek pip install pypdf2 # 如果后续处理PDF,可先安装。本次案例用txt,可暂不装。

注意sentence-transformers库默认会下载模型。BAAI/bge-small-zh-v1.5模型大约几百MB,请确保网络通畅。如果下载慢,可以考虑先通过其他方式(如Hugging Face镜像)下载模型文件到本地,然后从本地路径加载。

3.2 文本分割的艺术与陷阱

我们准备一个名为knowledge.txt的示例文本,内容如下:

RAG(检索增强生成)是一种用于提升大语言模型在特定领域任务上准确性和可靠性的架构。它的核心思想是将外部知识库与LLM的生成能力相结合。工作流程通常包括:将文档切分为片段,将片段向量化并存储,检索与问题相关的片段,最后将片段作为上下文提供给LLM生成答案。这种方法能有效减少模型幻觉,并使其能够利用训练时未见过的最新或专有信息。文本分割的策略直接影响检索效果,过大的片段会引入噪声,过小的片段则可能丢失关键上下文。常见的分割方法有按固定长度重叠分割、按句子分割、按自然段落分割等。

现在,我们来分割它。最朴素的方法是按固定字符数分割。但这里有个大坑:直接切断句子或段落会破坏语义

# 不推荐的简单分割方式 def naive_split(text, chunk_size=100, chunk_overlap=20): chunks = [] start = 0 text_length = len(text) while start < text_length: end = start + chunk_size chunk = text[start:end] chunks.append(chunk) start += chunk_size - chunk_overlap # 设置重叠以避免信息在边界丢失 return chunks with open('knowledge.txt', 'r', encoding='utf-8') as f: raw_text = f.read() naive_chunks = naive_split(raw_text, chunk_size=150, chunk_overlap=30) for i, chunk in enumerate(naive_chunks): print(f"Chunk {i}: {chunk[:80]}...")

你会发现,输出的片段可能在句子中间被截断,例如“结合。”后面直接跟“工作流程”,阅读起来不连贯,作为检索单元效果会很差。

更优的做法是使用基于语义的分割器。我们可以用一个简单的规则改进:优先在句号、感叹号、问号、换行符等处进行分割。

# 改进的、基于标点的分割方式 def sentence_aware_split(text, chunk_size=200, overlap_sentences=1): import re # 简单的句子分割(中文句号、感叹号、问号、换行) sentence_endings = r'([。!?\n])' parts = re.split(sentence_endings, text) # 将分隔符重新拼接回句子 sentences = [] for i in range(0, len(parts)-1, 2): if i+1 < len(parts): sentences.append(parts[i] + parts[i+1]) else: sentences.append(parts[i]) if len(parts) % 2 == 1: sentences.append(parts[-1]) sentences = [s.strip() for s in sentences if s.strip()] # 基于句子构建块 chunks = [] current_chunk = [] current_length = 0 for sentence in sentences: sent_length = len(sentence) # 如果当前块为空,或者加上新句子后不超过chunk_size,则添加 if current_length + sent_length <= chunk_size or not current_chunk: current_chunk.append(sentence) current_length += sent_length else: # 保存当前块 chunks.append(''.join(current_chunk)) # 创建新块,并考虑重叠(保留前overlap_sentences个句子) current_chunk = current_chunk[-overlap_sentences:] + [sentence] current_length = sum(len(s) for s in current_chunk) if current_chunk: chunks.append(''.join(current_chunk)) return chunks chunks = sentence_aware_split(raw_text, chunk_size=200, overlap_sentences=1) for i, chunk in enumerate(chunks): print(f"\n--- Chunk {i} (长度: {len(chunk)}) ---") print(chunk)

这次,每个chunk都是由完整的句子组成,语义更完整。重叠句子(overlap_sentences=1)的策略是为了防止关键信息恰好落在两个chunk的边界而被割裂,在检索时能提高召回相关上下文的概率。

实操心得:文本分割是RAG的“地基”,地基不牢,后续检索再强也白搭。对于不同格式(PDF、HTML、Markdown)和不同领域(法律条文、技术文档、对话记录)的文本,最优的分割策略可能不同。生产系统中,往往会结合多种策略,例如先按章节/标题分割,再在章节内按语义或固定长度分割。我们的简单规则足以入门,但你需要意识到这是可以深度优化的关键点。

3.3 向量化与向量数据库存储

有了文本片段,下一步就是将它们转换为向量。我们使用sentence-transformers加载BGE模型。

from sentence_transformers import SentenceTransformer import chromadb from chromadb.config import Settings # 1. 加载嵌入模型 # 首次运行会下载模型,请耐心等待。也可以指定本地路径 `model_path=‘./local_model’` print("正在加载嵌入模型...") embed_model = SentenceTransformer('BAAI/bge-small-zh-v1.5') print("模型加载完毕。") # 2. 为所有文本片段生成向量 print("正在生成文本向量...") chunk_embeddings = embed_model.encode(chunks, normalize_embeddings=True) # normalize有助于相似度计算 print(f"已生成 {len(chunk_embeddings)} 个向量,每个向量维度为 {chunk_embeddings.shape[1]}") # 3. 初始化ChromaDB客户端和集合(Collection) # 持久化存储到磁盘,这样下次运行无需重新计算 client = chromadb.PersistentClient(path="./rag_demo_db") # 创建一个集合,类似数据库的表 collection = client.get_or_create_collection( name="demo_knowledge", metadata={"hnsw:space": "cosine"} # 使用余弦相似度进行搜索 ) # 4. 将数据存入集合 # 我们需要ID、向量本身和原始的文本内容(作为元数据或直接存储) doc_ids = [f"doc_{i}" for i in range(len(chunks))] # ChromaDB 可以自动存储关联的文档内容,这里我们把文本放在 `documents` 字段 collection.add( embeddings=chunk_embeddings.tolist(), # 转换为列表 documents=chunks, # 原始文本 ids=doc_ids # 唯一ID ) print(f"已成功将 {len(chunks)} 个文本片段存入向量数据库。")

关键点解析

  • normalize_embeddings=True:将向量归一化为单位长度。这样,向量之间的点积就等于余弦相似度,计算更高效,也是语义相似度搜索的常用方法。
  • hnsw:space: “cosine”:指定ChromaDB使用余弦相似度作为向量间的距离度量方式,这与我们归一化的操作是一致的。
  • 存储内容:我们不仅存储了向量(embeddings),还存储了原始文本(documents)。这是因为检索时,我们首先通过向量找到最相似的片段ID,然后需要根据ID取出对应的原始文本,才能送给LLM作为上下文。

3.4 检索与重排序策略初探

现在,模拟用户提问:“RAG如何减少模型幻觉?”

# 1. 将用户问题转换为向量 query = "RAG如何减少模型幻觉?" query_embedding = embed_model.encode([query], normalize_embeddings=True)[0] # 注意是单条,取第一个 # 2. 在向量数据库中进行相似性搜索(初步召回) results = collection.query( query_embeddings=[query_embedding.tolist()], n_results=5 # 召回最相似的5个片段 ) print("=== 初步召回结果 ===") retrieved_docs = results['documents'][0] retrieved_distances = results['distances'][0] for i, (doc, dist) in enumerate(zip(retrieved_docs, retrieved_distances)): print(f"\n[Top {i+1}, 相似度距离: {dist:.4f}]") print(doc[:200] + "...") # 打印前200字符

你会看到返回了与问题最相关的几个文本片段。注意,distances是距离值(余弦距离,1-余弦相似度),越小表示越相似。

然而,简单的向量相似度检索(称为“稠密检索”)有时会漏掉一些关键词匹配但语义稍远的片段。例如,问题中的“幻觉”在文中是“模型幻觉”,但稠密检索模型可能对同义词“虚构答案”也有一定理解。为了提升召回率,成熟的RAG系统会引入“多路召回”“重排序”

  • 多路召回:同时使用多种检索方式。例如:
    • 稠密检索(Dense Retrieval):就是我们刚才做的,基于语义向量。
    • 稀疏检索(Sparse Retrieval):如BM25算法,基于关键词匹配。对于包含特定术语(如“幻觉”、“减少”)的问题,BM25可能非常有效。
    • 混合检索(Hybrid Retrieval):将稠密检索和稀疏检索的结果合并。
  • 重排序(Re-ranking):从多路召回中可能得到10-20个候选片段,但并非所有都真正相关。这时可以使用一个更精细但计算成本也更高的“重排序模型”(如BGE-reranker),对这批候选片段与问题的相关性进行精细打分,重新排序,只保留最顶部的几个(如3个)送给LLM。这能显著提升上下文的精准度。

由于是入门案例,我们暂不实现复杂的多路召回和重排序,但你需要知道,这是工业级RAG系统提升效果的关键环节。我们的简单检索,可以看作是单路(稠密)召回且无重排序的版本。

3.5 提示词工程与LLM生成

这是最后一步,也是将检索结果转化为最终答案的“临门一脚”。提示词的质量直接决定LLM是否“听话”。

import openai # 假设我们使用DeepSeek的API,其接口与OpenAI兼容 client = openai.OpenAI( api_key="your_deepseek_api_key_here", # 请替换为你的真实API Key base_url="https://api.deepseek.com" # DeepSeek的API端点 ) # 构建上下文 context = "\n\n---\n\n".join(retrieved_docs[:3]) # 取前3个最相关的片段作为上下文 # 精心设计的提示词 prompt = f"""你是一个专业的问答助手。请严格根据以下提供的上下文信息来回答问题。 如果上下文中的信息不足以回答这个问题,请直接说明“根据已知信息无法回答此问题”,不要编造信息。 上下文信息: {context} 问题:{query} 请根据上下文给出准确、简洁的答案:""" print("=== 发送给LLM的提示词 ===") print(prompt[:500] + "...\n") # 调用LLM response = client.chat.completions.create( model="deepseek-chat", # 或你选择的其他模型 messages=[ {"role": "system", "content": "你是一个严谨的助手,只根据提供的事实回答问题。"}, {"role": "user", "content": prompt} ], temperature=0.1, # 低温度值使输出更确定、更专注于上下文 max_tokens=500 ) answer = response.choices[0].message.content print("=== LLM生成的答案 ===") print(answer)

提示词设计要点

  1. 明确指令:开头就强调“严格根据以下提供的上下文信息”,这是约束LLM行为的核心。
  2. 清晰的结构:用“上下文信息:”和“问题:”清晰分隔,便于模型理解。
  3. 设置安全边界:明确告知“如果上下文中的信息不足以回答...不要编造信息”,这是防止模型在检索失败时产生“幻觉”的最后一道防线。
  4. 系统消息辅助:在messages参数中设置system角色,可以进一步强化助手的角色定位。
  5. 参数调优temperature=0.1使得生成结果更稳定、更可预测,更适合事实性问答。

运行这段代码,你将得到一个基于我们提供的knowledge.txt内容生成的、关于“RAG如何减少模型幻觉”的答案。由于上下文明确提到了“这种方法能有效减少模型幻觉”,LLM应该能准确地引用并解释这一点。

4. 效果评估、常见问题与进阶思考

4.1 如何评估这个简单RAG的效果?

对于一个事实问答系统,最直接的评估方法是“答案准确性”“引用忠实度”

  • 准确性:LLM的答案是否与文档中的事实一致?你可以人工核对。
  • 忠实度:答案中的每一个关键主张,是否都能在提供的上下文片段中找到依据?这可以通过让LLM在生成答案时同时引用片段ID,或者事后进行“引用溯源”来检查。

在我们的案例中,你可以尝试问几个问题来测试:

  1. 答案明确在文中:“RAG的核心思想是什么?”(应能准确回答)
  2. 答案需要归纳:“RAG的工作流程包括哪些步骤?”(需要从上下文中提取并串联多个点)
  3. 文中未提及:“RAG是由谁在什么时候提出的?”(理想回答应为“根据已知信息无法回答”)

通过这些问题,你就能切身感受到RAG的优势和当前简单实现的局限性。

4.2 常见问题与排查清单

在实践过程中,你几乎一定会遇到以下问题。这里提供一个排查思路:

问题现象可能原因排查与解决思路
答案与文档内容不符(幻觉)1. 检索到的上下文不相关。
2. 提示词约束力不够。
3. LLM的temperature参数过高。
1. 检查检索结果:打印出retrieved_docs,看是否真的包含答案。如果不包含,优化文本分割策略或尝试多路召回。
2. 强化提示词,加入更严格的指令和惩罚性描述(如“严禁编造”)。
3. 将temperature调至0.1或更低。
答案说“无法回答”,但文档中明明有1. 检索失败,未召回相关片段。
2. 相关片段信息表述不直接,LLM未能理解。
1. 同上,检查检索结果。考虑增加召回数量(n_results),或使用更精细的嵌入模型。
2. 尝试在提示词中要求LLM进行推理,或提供更详细的上下文(增加召回片段长度或数量)。
答案冗长、包含无关信息1. 上下文片段过多或包含无关内容。
2. 提示词未要求“简洁”。
1. 引入重排序模型,筛选最相关的3个片段,而非前5个。
2. 在提示词中明确要求“给出准确、简洁的答案”。
处理长文档速度慢1. 嵌入模型编码耗时。
2. 向量数据库检索未优化。
1. 对于批处理,确保使用encode的批处理功能。考虑使用更快的轻量级模型。
2. 确保向量数据库使用了索引(如HNSW)。对于超大规模知识库,需考虑分片和分布式部署。
中文专有名词或新词检索效果差嵌入模型对特定领域词汇的语义捕捉能力有限。1. 尝试领域内微调嵌入模型(进阶操作)。
2. 在稀疏检索(如BM25)中加强这些关键词的权重,采用混合检索策略。

4.3 从案例到项目:工程化与进阶方向

我们这个案例实现了RAG最核心的闭环。但要将其变成一个健壮、可用的系统,还需要大量的工程化工作:

  1. 文档解析与预处理流水线:支持PDF、Word、PPT、HTML、Markdown等多种格式,处理表格、图片中的文字(OCR),清洗无关字符和广告。
  2. 分块策略优化:不再是简单的按句分割。需要根据文档结构(标题、章节)进行递归分割,可能结合语义分割模型,确保块内语义完整。
  3. 检索系统增强
    • 多路召回:集成BM25等稀疏检索,与向量检索结果融合。
    • 重排序:引入交叉编码器(Cross-Encoder)对召回结果进行精排。
    • 元数据过滤:在检索时加入来源、日期、作者等元数据条件。
  4. 查询理解与改写:在用户提问后,先对查询进行扩展、改写或生成假设性答案(HyDE),再用改写后的问题去检索,能显著提升召回率。
  5. 迭代检索与Agentic RAG:让LLM自主判断初次检索结果是否足够,若不够,则生成新的搜索词进行多轮检索,直到收集到满意信息再生成最终答案。这使RAG系统具备了“思考”和“主动探索”的能力。
  6. 评估与监控:建立自动化评估体系,监控回答的准确性、忠实度、延迟等指标,持续优化各个环节。

通过这个简单的案例,你已经亲手搭建了一个RAG系统的原型,并理解了数据流经的每一个环节及其重要性。接下来,你可以沿着上述任何一个方向进行深化,比如尝试集成LangChain来简化流程,或者加入BM25实现混合检索,每一步的探索都会让你对如何构建一个可靠的AI应用有更深的体会。记住,RAG不是一个魔法黑盒,而是一个由数据准备、检索、生成等多个可优化模块组成的系统工程,它的效果,最终取决于你对每个模块细节的把握。

← 返回列表