ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

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

2026/8/14 9:06:46 拓冰建站 浏览量
从零构建RAG系统:基于向量检索与大模型的事实问答实战 1. 项目概述从一个简单案例切入RAG最近和不少朋友聊起大模型应用发现大家普遍有个困惑都说RAG检索增强生成是解决大模型“幻觉”和知识过时的利器但看了一堆架构图和技术名词像“向量化”、“召回”、“重排序”感觉云里雾里真到自己动手时还是不知道从哪开始。这感觉我特别理解新技术刚出来时文档往往偏向理论缺的就是那临门一脚的实战感。今天我就想抛开那些复杂的框图用一个你绝对能亲手复现的简单案例把RAG的里里外外讲明白。我们不用什么复杂的业务系统就做一个最经典的“事实问答”应用你给它一段文本资料比如一篇技术文章或产品说明书然后向它提问它能从资料里找到准确信息来回答你。这个案例麻雀虽小五脏俱全涵盖了从原始文本处理到最终智能回答的全流程。无论你是想快速理解RAG核心思想的开发者还是计划构建自己知识库的创业者跟着这个案例走一遍你都能对“知识切片、向量化、多路召回、重排序”这些听起来高大上的概念建立起清晰、直观的认知。我们会用到一些主流且易上手的工具但重点不在于工具本身而在于理解每一步“为什么”要这么做以及不同选择背后的权衡。2. 案例设计与核心思路拆解2.1 案例场景定义为什么选择“事实问答”我们选择“事实问答”作为入门案例原因在于它的目标极其纯粹和可衡量答案必须严格来源于给定的文本资料并且回答要精准。这直接命中了RAG要解决的核心痛点——可控性与准确性。想象一下你是一家科技公司的客服主管手里有一份最新的、长达50页的产品故障排查手册PDF格式。当客户咨询“设备型号ABC在低温环境下启动报错代码E102该如何处理”时你面临两个选择让客服人员人工翻阅50页PDF效率低下且可能遗漏。将PDF丢给一个通用大模型比如ChatGPT它可能基于训练数据泛泛而谈甚至“捏造”一个不存在的处理步骤风险极高。而RAG方案是先将这50页手册“喂”给系统系统理解并存储后当用户提问时它不是凭空生成而是先去存储的知识里精准查找相关片段然后基于这些确凿的证据来组织语言回答。这样答案的源头被锁定在了手册内既高效又可靠。我们这个简单案例就是模拟这一过程只不过我们把手册换成了一篇易于管理的短文。2.2 技术方案选型与工具链为了快速实现并聚焦原理我们选择一条轻量、主流的技术路径大语言模型LLM选用开源且API友好的DeepSeek或Qwen系列。它们性能优秀并且提供了易于使用的接口让我们不必在本地部署上耗费精力。关键在于我们需要使用其“对话”或“补全”能力根据我们提供的“上下文”来生成答案。向量数据库与嵌入模型这是RAG的“记忆”核心。我们将使用ChromaDB因为它简单易用无需复杂配置适合原型验证。与之配套的我们需要一个“嵌入模型”将文本转换为向量。这里选择BAAI/bge-small-zh-v1.5这是一个在中文文本上表现优异的小规模模型足够我们案例使用。文本处理与检索框架虽然LangChain或LlamaIndex这类框架能极大简化流程但为了彻底理解底层机制在第一个案例中我们刻意不使用它们。我们将用纯Python代码手动实现核心步骤包括文本分割、调用嵌入模型、操作向量数据库、组织提示词。这就像学开车先弄懂离合器、油门和方向盘的关系而不是直接依赖自动驾驶。这个选择背后的逻辑是避免黑盒。很多初学者卡在RAG效果不好却不知问题出在“向量化不准确”、“检索策略不当”还是“提示词没写对”。我们从零搭建就能清晰地感知每一个环节的影响。2.3 整体工作流程预览我们的案例将严格按照以下流水线执行这也是一个标准RAG系统的核心骨架文档加载与预处理读取我们的示例文本文件。文本分割知识切片将长文本切割成语义连贯的小片段chunks。这是关键的第一步分割的好坏直接影响后续检索的精度。向量化嵌入使用嵌入模型将每一个文本片段转换为一个高维向量一组数字并存入向量数据库。这个向量代表了该片段的“语义”。检索召回当用户提出问题时同样使用嵌入模型将问题转换为向量。然后在向量数据库中计算问题向量与所有文本片段向量的“相似度”如余弦相似度找出最相似的几个片段。这就是“召回”阶段。生成将用户问题和召回到的相关文本片段一起组合成一个详细的“提示词”发送给大语言模型。指令通常是“请严格根据以下上下文信息回答问题如果上下文不包含答案请说‘根据已知信息无法回答’。” LLM据此生成最终答案。接下来我们就进入实操环节看看每一步具体怎么做又会遇到哪些坑。3. 核心环节实现与实操详解3.1 环境准备与依赖安装首先我们创建一个干净的Python环境推荐使用conda或venv并安装必要的库。这里不追求最新版本而是以稳定、兼容为首要目标。# 创建并激活虚拟环境以conda为例 conda create -n rag-demo python3.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_size100, chunk_overlap20): 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, encodingutf-8) as f: raw_text f.read() naive_chunks naive_split(raw_text, chunk_size150, chunk_overlap30) for i, chunk in enumerate(naive_chunks): print(fChunk {i}: {chunk[:80]}...)你会发现输出的片段可能在句子中间被截断例如“结合。”后面直接跟“工作流程”阅读起来不连贯作为检索单元效果会很差。更优的做法是使用基于语义的分割器。我们可以用一个简单的规则改进优先在句号、感叹号、问号、换行符等处进行分割。# 改进的、基于标点的分割方式 def sentence_aware_split(text, chunk_size200, overlap_sentences1): import re # 简单的句子分割中文句号、感叹号、问号、换行 sentence_endings r([。\n]) parts re.split(sentence_endings, text) # 将分隔符重新拼接回句子 sentences [] for i in range(0, len(parts)-1, 2): if i1 len(parts): sentences.append(parts[i] parts[i1]) 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_size200, overlap_sentences1) for i, chunk in enumerate(chunks): print(f\n--- Chunk {i} (长度: {len(chunk)}) ---) print(chunk)这次每个chunk都是由完整的句子组成语义更完整。重叠句子overlap_sentences1的策略是为了防止关键信息恰好落在两个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_embeddingsTrue) # 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( namedemo_knowledge, metadata{hnsw:space: cosine} # 使用余弦相似度进行搜索 ) # 4. 将数据存入集合 # 我们需要ID、向量本身和原始的文本内容作为元数据或直接存储 doc_ids [fdoc_{i} for i in range(len(chunks))] # ChromaDB 可以自动存储关联的文档内容这里我们把文本放在 documents 字段 collection.add( embeddingschunk_embeddings.tolist(), # 转换为列表 documentschunks, # 原始文本 idsdoc_ids # 唯一ID ) print(f已成功将 {len(chunks)} 个文本片段存入向量数据库。)关键点解析normalize_embeddingsTrue将向量归一化为单位长度。这样向量之间的点积就等于余弦相似度计算更高效也是语义相似度搜索的常用方法。hnsw:space: “cosine”指定ChromaDB使用余弦相似度作为向量间的距离度量方式这与我们归一化的操作是一致的。存储内容我们不仅存储了向量embeddings还存储了原始文本documents。这是因为检索时我们首先通过向量找到最相似的片段ID然后需要根据ID取出对应的原始文本才能送给LLM作为上下文。3.4 检索与重排序策略初探现在模拟用户提问“RAG如何减少模型幻觉”# 1. 将用户问题转换为向量 query RAG如何减少模型幻觉 query_embedding embed_model.encode([query], normalize_embeddingsTrue)[0] # 注意是单条取第一个 # 2. 在向量数据库中进行相似性搜索初步召回 results collection.query( query_embeddings[query_embedding.tolist()], n_results5 # 召回最相似的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 {i1}, 相似度距离: {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_keyyour_deepseek_api_key_here, # 请替换为你的真实API Key base_urlhttps://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( modeldeepseek-chat, # 或你选择的其他模型 messages[ {role: system, content: 你是一个严谨的助手只根据提供的事实回答问题。}, {role: user, content: prompt} ], temperature0.1, # 低温度值使输出更确定、更专注于上下文 max_tokens500 ) answer response.choices[0].message.content print( LLM生成的答案 ) print(answer)提示词设计要点明确指令开头就强调“严格根据以下提供的上下文信息”这是约束LLM行为的核心。清晰的结构用“上下文信息”和“问题”清晰分隔便于模型理解。设置安全边界明确告知“如果上下文中的信息不足以回答...不要编造信息”这是防止模型在检索失败时产生“幻觉”的最后一道防线。系统消息辅助在messages参数中设置system角色可以进一步强化助手的角色定位。参数调优temperature0.1使得生成结果更稳定、更可预测更适合事实性问答。运行这段代码你将得到一个基于我们提供的knowledge.txt内容生成的、关于“RAG如何减少模型幻觉”的答案。由于上下文明确提到了“这种方法能有效减少模型幻觉”LLM应该能准确地引用并解释这一点。4. 效果评估、常见问题与进阶思考4.1 如何评估这个简单RAG的效果对于一个事实问答系统最直接的评估方法是“答案准确性”和“引用忠实度”。准确性LLM的答案是否与文档中的事实一致你可以人工核对。忠实度答案中的每一个关键主张是否都能在提供的上下文片段中找到依据这可以通过让LLM在生成答案时同时引用片段ID或者事后进行“引用溯源”来检查。在我们的案例中你可以尝试问几个问题来测试答案明确在文中“RAG的核心思想是什么”应能准确回答答案需要归纳“RAG的工作流程包括哪些步骤”需要从上下文中提取并串联多个点文中未提及“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最核心的闭环。但要将其变成一个健壮、可用的系统还需要大量的工程化工作文档解析与预处理流水线支持PDF、Word、PPT、HTML、Markdown等多种格式处理表格、图片中的文字OCR清洗无关字符和广告。分块策略优化不再是简单的按句分割。需要根据文档结构标题、章节进行递归分割可能结合语义分割模型确保块内语义完整。检索系统增强多路召回集成BM25等稀疏检索与向量检索结果融合。重排序引入交叉编码器Cross-Encoder对召回结果进行精排。元数据过滤在检索时加入来源、日期、作者等元数据条件。查询理解与改写在用户提问后先对查询进行扩展、改写或生成假设性答案HyDE再用改写后的问题去检索能显著提升召回率。迭代检索与Agentic RAG让LLM自主判断初次检索结果是否足够若不够则生成新的搜索词进行多轮检索直到收集到满意信息再生成最终答案。这使RAG系统具备了“思考”和“主动探索”的能力。评估与监控建立自动化评估体系监控回答的准确性、忠实度、延迟等指标持续优化各个环节。通过这个简单的案例你已经亲手搭建了一个RAG系统的原型并理解了数据流经的每一个环节及其重要性。接下来你可以沿着上述任何一个方向进行深化比如尝试集成LangChain来简化流程或者加入BM25实现混合检索每一步的探索都会让你对如何构建一个可靠的AI应用有更深的体会。记住RAG不是一个魔法黑盒而是一个由数据准备、检索、生成等多个可优化模块组成的系统工程它的效果最终取决于你对每个模块细节的把握。