从零构建RAG系统:文档预处理、向量化与智能问答实战指南
1. 项目概述从文档到智能问答的RAG实战最近在后台和社群里看到不少朋友对如何让大模型“读懂”并“活用”自己的文档特别感兴趣。无论是想用AI快速分析上百页的行业报告还是想搭建一个能回答公司内部知识库问题的智能助手核心都绕不开一个技术RAG检索增强生成。这听起来有点学术但说白了就是让AI在回答你问题前先像人一样去翻翻“参考资料”——这些资料就是你的PDF、Word文档或者网页内容。我花了差不多两周时间从零开始把一堆散落在各处的合同、产品手册、技术博客文章整合成了一个能对答如流的智能知识库。这个过程里踩了不少坑也总结出了一套比较顺滑的流程。今天这篇我就来拆解一下如何从最原始的PDF、Word、网页这些常见格式的文档出发一步步构建一个真正能用、好用的RAG系统。这不是一个简单的“调用API”教程我会重点讲清楚每个环节背后的“为什么”以及那些只有实际动手才会遇到的“坑”和技巧。2. 核心思路与方案选型为什么是“分块-向量化-检索”这条路在开始动手之前我们得先想明白RAG到底在干什么。大模型本身知识渊博但它并不知道你电脑里那份《2024年Q3销售数据分析报告.pdf》里写了啥。RAG的核心目标就是建立一条从你的私有文档到模型知识的“专属通道”。这个通道的构建业界普遍采用“分块 - 向量化 - 检索 - 增强生成”的流水线。为什么是这套方案我对比过几种思路方案一把整个文档喂给模型。听起来很直接但问题立刻来了主流大模型有上下文长度限制比如128K tokens。一份几十页的PDF轻松就能超过这个限制。更致命的是即使上下文窗口足够大如某些百万token模型模型对超长文本中细节信息的定位和提取能力也会急剧下降俗称“中间迷失”现象。答案可能就在文档中间但模型就是找不到。方案二训练或微调一个专属模型。这相当于为了你的文档专门培养一个“专家”。效果理论上最好但成本极高需要大量的标注数据、强大的算力和深厚的算法功底不适合绝大多数开发者和业务团队快速启动。方案三检索增强生成RAG。这正是我们采用的路径。它的聪明之处在于“按需取用”当用户提问时系统不是把整个文档塞给模型而是先根据问题从文档库中快速检索出最相关的几个片段比如几个段落或表格只把这些精华片段和问题一起交给模型来生成答案。这完美解决了上下文长度限制和知识更新问题只需更新文档库无需重新训练模型。所以我们的技术路线非常明确首先把各种格式的文档“拆解”成适合检索的小块分块然后把这些文本块转换成计算机能理解的“数学向量”向量化/嵌入接着构建一个能快速找到相关向量的“图书馆”向量数据库最后在用户提问时完成“检索-拼接-生成”的流程。工具选型上我倾向于“轻量、可控、易集成”的组合。本次实践我主要使用LangChain作为编排框架Chroma作为向量数据库OpenAI的text-embedding-3-small模型做向量化GPT-4o-mini做生成。选择它们的原因是LangChain封装了繁杂的流程让我们能聚焦业务逻辑Chroma轻量且可本地运行隐私有保障OpenAI的嵌入模型在通用文本上表现稳定且性价比高。当然这套组合可以灵活替换比如向量数据库换成Qdrant或Weaviate嵌入模型换成开源的BGE或Jina系列生成模型换成DeepSeek或Qwen核心架构是不变的。3. 文档预处理实战破解格式提取与智能分块的难题一切始于你的原始文档。PDF、Word、网页每种格式都是一座需要被“开采”的矿山里面除了我们需要的文本还混杂着版式、图片、无关元素等“杂质”。3.1 多格式文档的文本提取PDF提取警惕“复制粘贴”的陷阱很多人第一反应是直接从PDF里复制文字。但这样做风险很大。许多PDF是扫描件图片型或者文字虽然可选但布局复杂如多栏排版、表格、页眉页脚。直接复制会导致文本顺序错乱丢失大量信息。我的做法是使用专门的解析库。对于可读的PDFPyPDF2或pdfplumber是基础选择但更强大的是pymupdf (fitz)和pdfminer.six。pymupdf速度极快能较好地保留文本位置信息pdfminer.six对复杂布局的解析能力更强。对于扫描件就必须借助OCR了paddleocr或tesseract是主流选择但需要额外处理识别准确率和版面分析问题。注意PDF解析没有银弹。对于关键项目我通常会先用pymupdf快速提取然后人工抽样检查复杂页面如带表格、多栏的页面的提取效果。如果问题多再换用pdfminer.six或引入OCR流程。一个常见的坑是解析出来的文本会包含大量的换行符因为PDF中每行结尾都换行需要在后续清洗中合并。Word文档提取深入结构内部Word文档.docx本质是一个ZIP压缩包里面包含了XML格式的文本和样式。使用python-docx库可以非常精准地按段落、表格、标题等结构提取内容这是它的巨大优势。from docx import Document def extract_from_docx(file_path): doc Document(file_path) full_text [] for para in doc.paragraphs: if para.text.strip(): # 忽略空段落 full_text.append(para.text) # 还可以进一步处理表格 for table in doc.tables: for row in table.rows: row_text [cell.text for cell in row.cells] full_text.append(\t.join(row_text)) # 用制表符暂隔表格内容 return \n.join(full_text)网页内容提取聚焦主体剔除噪音网页的噪音最大导航栏、广告、侧边栏、评论等都不是我们需要的。简单的正则表达式或字符串查找很难应对千变万化的网页结构。这里必须使用HTML解析器如BeautifulSoup或lxml。更高级的做法是使用readability这类算法的库如dragnet,trafilatura它们能智能识别并提取网页正文内容。import trafilatura def extract_from_html(url): downloaded trafilatura.fetch_url(url) # extract_only_with_metadata 可以只提取正文效果非常好 text trafilatura.extract(downloaded, output_formattext, include_linksFalse) return text if text else 3.2 文本清洗与规范化为分块打下坚实基础提取出的原始文本通常很“脏”直接分块效果很差。清洗流程包括去除多余空白将连续的换行符、空格、制表符标准化。处理特殊字符和编码统一转为UTF-8处理乱码。识别并处理无关内容如PDF中的页眉页脚通常包含重复的标题和页码、Word文档中的批注如果不需要、网页的版权声明等。这部分通常需要结合规则如正则匹配特定模式和启发式方法如判断段落位置和长度。清洗后的文本应该是一个连贯的、以自然段落为单位的字符串。这一步的质量直接决定了后续分块和向量化的效果。3.3 文本分块策略平衡信息完整性与检索精度这是RAG预处理中最关键、最富技巧性的一环。分块太大检索出的块可能包含太多无关信息干扰模型分块太小一个完整的语义可能被割裂模型看不到上下文。1. 固定大小重叠分块最常用也最基础这是LangChain等框架的默认方法。例如设置块大小chunk_size为500字符块重叠chunk_overlap为100字符。它像一把滑动窗口沿着文本移动确保边界的信息不会完全丢失。优点简单通用对大多数叙述性文本有效。缺点可能粗暴地切断句子、段落甚至表格破坏语义完整性。2. 基于分隔符的分块尊重文档结构我们可以指定分隔符如\n\n双换行通常代表段落、##Markdown二级标题等。RecursiveCharacterTextSplitter就是这种策略它会优先按最大分隔符如\n\n分如果块还是太大再按次一级分隔符如\n分如此递归。优点尽可能保持自然段落和章节的完整性。缺点对于结构不清晰或分隔符使用混乱的文本效果会打折扣。3. 语义分块更智能的边界这是更高级的方法利用嵌入模型或句法分析在语义发生较大转变的地方进行切分。例如计算相邻句子或小段落的向量相似度在相似度低于某个阈值时切分。优点分块边界更符合人类对“话题”转换的感知检索精度可能更高。缺点计算成本高实现复杂且依赖于嵌入模型的质量。我的实操心得与混合策略在实际项目中我很少只依赖一种策略。我的常用做法是“结构优先固定大小保底”首先尝试基于分隔符的分块对于Word、Markdown、结构清晰的HTML利用其固有的标题#,##、段落分隔来分块。这能最大程度保留文档的逻辑单元。对于无结构或结构混乱的纯文本采用固定大小重叠分块。但这里有个关键技巧优先在句子边界处切分。我会先使用句子分割器如nltk的sent_tokenize或sentence_transformers的SentenceTransformer将文本分成句子列表然后再在这些句子基础上组合成指定大小的块。这样可以避免一个句子被拦腰截断。对于特定内容类型比如代码按函数或类分块比如论文按摘要、引言、方法、结论分块。这需要定制化的分隔符规则。from langchain.text_splitter import RecursiveCharacterTextSplitter, SentenceTransformersTokenTextSplitter # 方法1递归字符分割基于分隔符 text_splitter_recursive RecursiveCharacterTextSplitter( chunk_size500, chunk_overlap50, separators[\n\n, \n, 。, , , , , , ] # 中文分隔符 ) # 方法2句子感知分割更推荐 from langchain_experimental.text_splitter import SemanticChunker # 或者更手动但可控的方式 sentences sent_tokenize(long_text, languagechinese) # 假设已分句 # 然后手动将句子合并成目标大小的块 chunks [] current_chunk [] current_size 0 for sent in sentences: sent_size len(sent) if current_size sent_size chunk_size and current_chunk: chunks.append(.join(current_chunk)) current_chunk [] current_size 0 current_chunk.append(sent) current_size sent_size if current_chunk: chunks.append(.join(current_chunk))分块完成后一定要人工抽样检查随机看几个块的内容检查边界是否合理有没有把一个问题和对它的回答切开有没有把一个完整的操作步骤拆散。这是保证后续RAG效果的基础中的基础。4. 向量化与存储构建文档的“记忆核心”文本变成计算机可理解、可计算的形式靠的是向量化Embedding。这个过程把一段文字映射到一个高维空间比如1536维中的一个点语义相近的文本其向量在空间中的距离也更近。4.1 嵌入模型的选择与调优OpenAI的text-embedding-3-small/large、Cohere的嵌入模型、以及开源的BGE智源、Jina Embeddings、Snowflake Arctic Embed都是很好的选择。闭源 vs 开源OpenAI/Cohere的API简单稳定效果有保障但会产生持续费用且数据需出境。开源模型可以私有化部署数据安全但需要自己管理模型服务和计算资源。维度与性能更高维度的向量通常包含更丰富的语义信息但也会占用更多的存储和计算资源检索更慢。text-embedding-3-small在性能和成本间取得了很好的平衡。指令感知一些新模型如BGE是“指令感知”的这意味着在将查询query向量化时你可以为其添加指令如“为这个句子生成表示用于检索相关文档”从而得到更适合检索任务的向量。这能显著提升检索质量。在代码中使用LangChain可以轻松集成各种嵌入模型from langchain_openai import OpenAIEmbeddings from langchain_community.embeddings import HuggingFaceEmbeddings # 使用OpenAI Embeddings embeddings_openai OpenAIEmbeddings(modeltext-embedding-3-small) # 使用开源BGE模型需先下载模型 embeddings_hf HuggingFaceEmbeddings( model_nameBAAI/bge-small-zh-v1.5, # 中文小模型 model_kwargs{device: cpu}, # 或 cuda encode_kwargs{normalize_embeddings: True} # 归一化便于余弦相似度计算 )4.2 向量数据库的搭建与数据灌入向量数据库负责高效存储这些向量并在查询时进行快速的相似性搜索。我选择Chroma是因为它设计简洁可以内存或持久化模式运行非常适合原型开发和中小规模项目。from langchain_chroma import Chroma from langchain.docstore.document import Document # 假设我们已经有了清洗分块后的文本列表 text_chunks # 以及对应的元数据列表 metadatas如来源文件名、页码等 documents [Document(page_contentchunk, metadatameta) for chunk, meta in zip(text_chunks, metadatas)] # 创建并持久化向量库 vectorstore Chroma.from_documents( documentsdocuments, embeddingembeddings_openai, # 使用上面定义的嵌入模型 persist_directory./chroma_db # 指定持久化目录 ) # 之后加载 vectorstore Chroma(persist_directory./chroma_db, embedding_functionembeddings_openai)关键元数据设计 在创建Document对象时metadata字段至关重要。我强烈建议至少包含source: 文档来源如文件路径、URL。page或chunk_index: 块在原文档中的位置页码或序号。 这有两个巨大好处1) 在检索到相关块后可以追溯到原文出处方便核实2) 可以实现基于元数据的过滤检索例如“只从某份报告中检索”。4.3 检索器的配置与优化从向量库中查找相关文档靠的是检索器Retriever。最简单的就是“相似性搜索”但我们可以做得更好。# 基础检索器 retriever vectorstore.as_retriever(search_kwargs{k: 4}) # 检索最相似的4个块 # 更高级的检索器最大边际相关性MMR from langchain.retrievers import ContextualCompressionRetriever from langchain.retrievers.document_compressors import LLMChainExtractor # MMR在保证相关性的同时增加结果多样性避免返回内容过于同质。 retriever_mmr vectorstore.as_retriever( search_typemmr, search_kwargs{k: 6, fetch_k: 20, lambda_mult: 0.7} ) # fetch_k: 初步获取的文档数lambda_mult: 多样性权重1偏向相似0偏向多样检索后处理重排序 相似性搜索返回的Top-K文档是按与查询向量的余弦相似度排序的。但这不一定是最优的排序。一个更小、更精准的块其相似度分数可能低于一个更大、包含相关关键词但也包含很多噪音的块。因此引入一个重排序Re-ranking模型是提升RAG效果的大杀器。重排序模型如Cohere的Rerank开源的bge-reranker会将查询和每个候选文档作为输入输出一个更精细的相关性分数。虽然这会增加少量延迟和计算成本但对于最终答案的准确性提升往往是显著的。# 伪代码示例使用重排序 from langchain.retrievers import ContextualCompressionRetriever from langchain_cohere import CohereRerank compressor CohereRerank(cohere_api_keyyour_key, top_n3) # 从检索结果中重排选出Top3 compression_retriever ContextualCompressionRetriever( base_compressorcompressor, base_retrievervectorstore.as_retriever(search_kwargs{k: 10}) # 先检索10个 ) # 现在 compression_retriever 返回的是经过重排序后的Top3文档5. 问答链构建与提示工程让模型“善解人意”检索到了相关文档如何让模型利用它们生成高质量的答案这就需要精心设计提示词Prompt和问答链Chain。5.1 构建基础的检索问答链LangChain提供了RetrievalQA链它封装了“检索 - 格式化上下文 - 提问”的完整流程。from langchain.chains import RetrievalQA from langchain_openai import ChatOpenAI llm ChatOpenAI(modelgpt-4o-mini, temperature0) qa_chain RetrievalQA.from_chain_type( llmllm, chain_typestuff, # 最常用的类型将所有检索到的文档内容“塞”进提示词 retrieverretriever, # 使用我们配置好的检索器可带重排序 return_source_documentsTrue, # 非常重要返回源文档用于追溯 chain_type_kwargs{prompt: PROMPT} # 传入自定义的提示词模板 )chain_type主要有几种stuff: 将所有检索到的文档内容拼接后一次性输入给LLM。简单高效但受限于模型的上下文窗口。map_reduce: 先让LLM分别总结每个文档再总结这些总结。适合处理大量文档但可能丢失细节且调用API次数多。refine: 迭代式处理用第一个文档生成初始答案再用后续文档不断精炼。质量可能更高但速度慢。map_rerank: 让LLM对每个文档单独评分并生成答案最后选最高分的。成本高较少用。 对于大多数场景stuff足矣只要控制好检索块的数量和总长度。5.2 设计高效的提示词模板提示词是指导模型如何利用上下文的关键。一个糟糕的提示词会让模型忽略你精心检索的文档。一个强力的基础模板如下from langchain.prompts import PromptTemplate template 你是一个专业的问答助手。请严格根据以下提供的上下文信息来回答问题。如果上下文信息中没有明确包含答案或者信息不足以回答问题请直接说“根据提供的信息我无法回答这个问题”。不要编造任何信息。 上下文信息 {context} 问题{question} 请根据上下文提供准确、简洁的答案。如果答案涉及步骤或列表请清晰列出。 PROMPT PromptTemplate( templatetemplate, input_variables[context, question] )提示词设计核心要点明确角色“你是一个专业的问答助手”设定基调。强调依据“严格根据以下提供的上下文信息”是核心指令必须反复强调以抑制模型的幻觉。定义未知处理“如果...无法回答”给出了明确的兜底策略比让模型自己瞎编好得多。格式化上下文确保{context}在模板中清晰标示。在实际传入时LangChain会用检索到的文档内容通常用\n\n分隔替换它。要求结构化输出“如果涉及步骤或列表请清晰列出”引导模型给出更易读的答案。你可以根据领域进一步优化例如对于法律文档可以加入“请引用具体条款”对于技术支持可以加入“请分步排查”。5.3 实现带历史记忆的多轮对话基础的QA链是无状态的每次问答都是独立的。要实现连贯的多轮对话需要引入“记忆”机制。LangChain提供了多种记忆后端如ConversationBufferMemory。from langchain.memory import ConversationBufferMemory from langchain.chains import ConversationalRetrievalChain memory ConversationBufferMemory(memory_keychat_history, return_messagesTrue, output_keyanswer) qa_chain_with_memory ConversationalRetrievalChain.from_llm( llmllm, retrieverretriever, memorymemory, combine_docs_chain_kwargs{prompt: PROMPT}, return_source_documentsTrue ) # 使用方式 result qa_chain_with_memory.invoke({question: 我们公司今年的销售目标是多少}) print(result[answer]) # 后续问题可以指代上文 result2 qa_chain_with_memory.invoke({question: 相比去年增长了多少}) # 模型能理解“今年”“去年”的指代这里的记忆存储的是对话历史问答对。在生成新答案时系统会将历史对话也作为上下文的一部分通常放在问题前提供给模型从而实现连贯对话。需要注意的是记忆也会消耗token需要管理其长度避免超出上下文限制。6. 效果评估、迭代与高级优化技巧搭建完RAG系统只是第一步更重要的是评估其效果并持续优化。你不能等到用户投诉才发现答案全是胡编乱造。6.1 构建评估体系与测试集人工评估黄金标准 构建一个涵盖不同难度、不同类型事实型、归纳型、推理型的问题测试集QA pairs。每个问题都对应文档中确切的答案或明确的“无法回答”。然后人工检查系统输出的答案从以下几个维度评分如1-5分答案相关性答案是否直接针对问题上下文忠实度答案是否严格来源于提供的上下文有无幻觉信息完整性是否包含了上下文中的所有关键信息表达流畅性答案是否通顺、清晰自动评估辅助与监控 人工评估成本高可以辅以自动评估检索阶段评估计算检索到的文档中是否包含真实答案Answer Recall。生成阶段评估使用LLM作为裁判LLM-as-a-Judge给定问题、上下文和模型答案让一个更强的LLM如GPT-4从上述维度进行评分。虽然不完全可靠但可以作为快速迭代的参考。6.2 常见问题排查清单当你发现RAG系统效果不佳时可以按以下清单逐项排查问题现象可能原因排查方向与解决方案答案完全错误或胡编乱造幻觉1. 检索失败没找到相关文档。2. 提示词未强制模型依据上下文。3. 模型本身幻觉倾向强。1. 检查检索到的文档是否相关相似度分数是否过低。2. 强化提示词中的“严格依据上下文”指令并设置明确的拒答话术。3. 尝试降低生成模型的temperature参数如设为0或换用幻觉更少的模型。答案不完整遗漏关键信息1. 分块过大关键信息被淹没。2. 检索数量k值太小相关块没被召回。3. 重排序模型把关键文档排到了后面。1. 尝试减小分块大小或采用语义分块。2. 适当增加k值如从4调到6或8。3. 检查重排序模型的top_n设置或暂时关闭重排序看效果。答案包含无关信息1. 分块内容不纯净包含无关文本如页眉。2. 检索到的块虽然相关但包含多余背景。1. 加强文本清洗步骤去除噪音。2. 尝试使用LLMChainExtractor这类压缩器在检索后先让LLM提取每个文档中与问题最相关的句子。无法回答本应知道的问题1. 文档未成功提取或分块。2. 向量化模型不适合该领域文本。3. 问题表述与文档表述差异大。1. 检查原始文档的提取内容确认信息是否存在。2. 尝试使用在该领域如医学、法律微调过的嵌入模型。3. 考虑对用户查询进行查询重写或扩展。例如将“销量咋样”扩展为“销售额情况如何”。多轮对话中遗忘上下文或指代错误1. 记忆缓冲区长度不足或管理不当。2. 历史对话未有效融入当前查询。1. 检查记忆缓冲区的token数考虑使用ConversationSummaryMemory来压缩长历史。2. 在将历史对话输入模型前可以尝试让LLM先根据历史重写当前问题使其更独立、明确。6.3 高级优化技巧在基础流程跑通后这些技巧可以进一步提升系统性能1. 查询转换与扩展HyDE假设性文档嵌入让LLM根据问题先生成一个“假设的答案”然后用这个假设答案的向量去检索。这能拉近查询与文档在向量空间的距离。子问题查询对于复杂问题让LLM将其分解成多个子问题分别检索再综合答案。2. 混合检索 不要只依赖向量检索。结合关键词检索如BM25进行混合搜索。因为有些精确的术语、代号向量检索可能不敏感但关键词检索能精准命中。将两者的结果按分数融合能提高召回率。3. 元数据过滤 在检索时加入元数据过滤条件。例如用户问“财务部的规章制度”你可以让检索器只搜索source元数据包含“财务”且doc_type为“制度”的文档。这能极大提升检索精度。4. Agentic RAG智能体式RAG 这是更前沿的思路。让一个“智能体”来协调整个问答过程。它可以决定是否需要检索需要检索几次检索到的文档是否足够回答是否需要进一步追问用户以澄清问题这使RAG系统具备了更强的推理和交互能力。虽然实现复杂但这是让RAG更接近“智能”的关键方向。构建一个高效的RAG系统是一个典型的“迭代优化”过程。从最简单的流程开始然后通过评估发现问题定位到是检索、分块还是生成环节的问题再有针对性地优化。没有一劳永逸的配置最好的系统一定是根据你的具体文档和业务需求“调”出来的。