
1. 项目概述为什么RAG的“地基”比“大厦”更重要如果你最近在折腾大语言模型应用尤其是想让它“读懂”你自己的文档库并给出精准回答那你肯定绕不开RAG检索增强生成这个词。大家往往把目光聚焦在炫酷的生成效果和复杂的检索算法上但干了这么多项目我最大的体会是RAG系统的上限在模型选定那一刻就大致确定了而它的下限则完全由文档的存储与切割策略决定。一个糟糕的文档处理流程能让顶级的Embedding模型和重排序器都束手无策输出一堆胡言乱语或“根据上下文无法回答”。今天我们就抛开那些高大上的算法沉下心来聊聊RAG系统中最基础、最核心却也最容易被忽视的环节——文档的存储与切割。这就像盖房子向量检索和LLM生成是精装修而文档处理则是打地基。地基打歪了上面再怎么华丽都是危房。简单来说RAG的工作流是将你的文档PDF、Word、网页等处理成一段段文本转换成向量存入数据库当用户提问时从数据库中检索出最相关的文本片段连同问题一起交给大模型生成答案。这里面的“一段段文本”就是切割后的产物也叫“块”或“分片”。存储则决定了这些块如何被高效地组织、索引和召回。很多人以为切割就是简单按字数或段落切分存储就是扔进向量数据库实则不然。不同的文档类型、不同的业务场景需要完全不同的策略。一个法律合同检索系统和一个客服知识库其最优的切割方案可能天差地别。这篇文章我将结合多个实战项目的经验从最基础的规则切割讲起逐步深入到语义切割、多粒度索引、混合存储等进阶策略。我会详细解释每种方法背后的设计逻辑、适用场景、具体操作步骤以及我踩过的那些坑。无论你是刚开始接触RAG的新手还是正在为召回效果不佳而头疼的开发者相信都能从中找到解决问题的钥匙。2. 核心需求解析你的文档到底需要被怎样“对待”在动手写一行代码之前我们必须想清楚我们处理文档的最终目标是什么这个目标直接决定了所有技术细节的选择。脱离业务场景谈技术方案是工程师常犯的错误。2.1 理解RAG中“好”块的标准一个理想的文本块应该同时满足以下几个看似矛盾的需求信息完整性块内的内容应该尽可能自包含表达一个相对完整的语义单元。例如一个问题的答案、一个概念的定义、一个步骤的完整描述。如果切割点正好在一句话中间或者把一个图表和它的解释文字分开就会严重破坏语义。检索相关性块的大小要适中以便与用户查询进行精准的语义匹配。块太大会包含很多无关噪声拉低整体相关性分数块太小可能丢失关键上下文导致信息碎片化。上下文充足性当这个块被检索出来并作为上下文喂给大模型时它应该包含足够的信息让模型理解并生成连贯的答案。例如如果块里只提到“上述方法”但“上述方法”具体指什么在前一个块里那这个块对模型来说就是难以理解的。这三者构成了一个“不可能三角”。我们的切割策略本质上是在这个三角中寻找当前场景下的最优平衡点。例如对于技术手册可能更强调信息完整性保证一个完整的功能说明对于问答对资料则更强调检索相关性一个问题对应一个答案块。2.2 不同文档类型的核心挑战长文本文档如产品手册、电子书、研究报告最大的挑战是维持叙述的连贯性和逻辑结构。简单的按固定长度切割会切断章节、图表与正文的联系。需要识别标题、列表等结构元素。结构化文档如合同、财报、API文档通常有清晰的层级章、节、条、款、项。切割必须尊重这种结构否则会丢失重要的逻辑关系和法律/业务含义。对话与问答记录如客服日志、会议纪要需要将一轮完整的对话Q-A对保持在一起。错误的切割会导致问题与答案分离检索出无效信息。多媒体混合文档如带图表的PPT、有标注的PDF难点在于如何处理非文本信息。纯文本切割会丢失图片中的关键信息如图表标题、数据。需要OCR提取或依赖元数据。理解你的文档特性是选择切割策略的第一步。接下来我们将从最简单的工具开始逐步搭建我们的文档处理流水线。3. 基础切割策略从“能用”到“好用”对于大多数入门场景基于规则的切割方法已经能解决80%的问题。它们实现简单速度快是快速验证想法的不二之选。3.1 固定长度切割与滑动窗口这是最直接的方法设定一个固定的字符数例如500字符像切香肠一样切割文档。from langchain.text_splitter import CharacterTextSplitter text_splitter CharacterTextSplitter( separator “\n\n”, # 优先按双换行段落分割 chunk_size 500, # 每个块的最大字符数 chunk_overlap 50 # 块与块之间的重叠字符数 ) chunks text_splitter.split_text(long_text)关键参数解析chunk_size这是核心参数。设置多大一个经验法则是参考你所用大模型的上下文窗口。例如如果你的提示词模板会占用500 token你计划给模型喂3个检索块每个块理想大小可能在800-1200字符约300-500 token取决于中英文混合程度。起始建议值对于通用文本512-1024字符是一个不错的起点。chunk_overlap重叠是为了防止完整的句子或关键信息被硬生生切断。例如一个句子正好在第500个字符处开始没有重叠它就会被割裂到两个块中导致两个块的头尾都不完整。重叠50-150个字符可以有效缓解这个问题。separator优先使用\n\n段落分隔而不是空字符串这样能首先保证在段落边界处切割比纯粹按字符数切割更合理。实操心得不要迷信“黄金比例”。我曾在两个内容相似的项目中一个用600字符效果最好另一个却需要800字符。最好的方法是准备一组代表性的问题用不同的chunk_size如256, 512, 1024和overlap0, 50, 100进行切割然后跑一遍检索测试观察召回答案的准确率和完整性。这个过程虽然枯燥但一劳永逸。3.2 基于分隔符的递归切割固定长度切割太“硬”了我们可以让它更“软”一些——递归切割。它的逻辑是优先用最大的语义分隔符如\n\n来分如果分出来的块还是太大再用次一级的分隔符如\n 然后. 最后 继续分直到满足大小要求。from langchain.text_splitter import RecursiveCharacterTextSplitter text_splitter RecursiveCharacterTextSplitter( chunk_size 500, chunk_overlap 50, separators [“\n\n”, “\n”, “。 ”, “ ”, “ ”, “”, “”] # 分隔符优先级列表 )这种方法比单纯的固定长度切割更智能能更好地保持段落和句子的完整性。LangChain中的RecursiveCharacterTextSplitter是当前实践中的默认推荐选择因为它平衡了效果和复杂度。常见问题与排查问题切割后产生了大量只有几个字符或几十个字符的“碎片块”例如单独的页码“- 5 -”或页眉页脚。排查这通常是因为原始文档的格式噪音。在切割前必须进行严格的文本清洗。使用正则表达式过滤掉纯数字行、连续的符号、特定的页眉页脚模式。解决在切割后可以增加一个后处理步骤过滤掉长度小于某个阈值如30字符的块。但要注意有些短块可能是重要的标题或关键词不能一概而论。4. 进阶切割策略让机器理解文档的“意思”当基础规则无法满足需求时我们需要让切割过程具备一定的“语义理解”能力。这通常意味着引入机器学习模型。4.1 基于句子嵌入的语义切割核心思想计算文档中相邻句子或小段之间的语义相似度。在语义发生较大转变的地方就是理想的切割点。例如一段文字从介绍概念A突然切换到概念B这里的语义相似度会有一个明显的低谷。操作步骤预分割先将文档按句号、问号等标点分割成句子列表。嵌入计算使用一个轻量级的句子嵌入模型如all-MiniLM-L6-v2将每个句子转换为向量。相似度计算与切割点检测计算相邻句子向量的余弦相似度得到一条相似度曲线。通过寻找曲线中的“谷底”局部最小值来确定切割边界。可以使用滑动窗口计算窗口内句子的平均相似度当平均相似度低于某个阈值时进行切割。from sentence_transformers import SentenceTransformer import numpy as np model SentenceTransformer(‘all-MiniLM-L6-v2’) sentences [“句子1”, “句子2”, “句子3”, …] # 预分割后的句子列表 embeddings model.encode(sentences) # 计算相邻句子相似度 similarities [] for i in range(len(embeddings)-1): sim np.dot(embeddings[i], embeddings[i1]) / (np.linalg.norm(embeddings[i]) * np.linalg.norm(embeddings[i1])) similarities.append(sim) # 简单的阈值法寻找切割点 (相似度低于0.3) chunks [] current_chunk [] threshold 0.3 for i, sentence in enumerate(sentences): current_chunk.append(sentence) if i len(similarities) and similarities[i] threshold: chunks.append(” “.join(current_chunk)) current_chunk [] if current_chunk: chunks.append(” “.join(current_chunk))适用场景非常适合处理流畅的长篇叙述文如博客文章、新闻稿、小说章节能很好地跟随作者的思路进行切分。注意事项计算开销相比规则切割需要运行嵌入模型速度慢很多。适合对切割质量要求高、文档数量相对固定的预处理场景。阈值调优相似度阈值需要根据语料特点进行调整。可以人工标注一些文档的理想切割点反向推导出合适的阈值范围。4.2 利用文档结构解析器对于高度结构化的文档我们有更强大的武器文档解析库。它们能直接解析出文档的层级树状结构。对于PDF/Word使用PyMuPDF(fitz)、pdfplumber、python-docx等库可以提取出字体大小、样式、布局信息。一个简单的启发式规则是将字体明显更大、加粗且居中的文本识别为标题并根据标题级别构建层级。对于Markdown/HTML这本身就是结构化的。我们可以直接根据标题标签#,##,h1,h2进行切割。每个标题及其下属内容自然形成一个语义块。# 以Markdown为例的简单结构切割 import re def split_by_markdown_headers(text): # 正则匹配各级标题 pattern r‘(?m)^(#{1,6})\s(.)$’ chunks [] last_end 0 for match in re.finditer(pattern, text): header_start match.start() if header_start last_end: chunk text[last_end:header_start].strip() if chunk: chunks.append(chunk) last_end header_start # 添加最后一部分 final_chunk text[last_end:].strip() if final_chunk: chunks.append(final_chunk) return chunks进阶工具Unstructured库是一个集大成者它集成了多种解析器能对PDF、PPT、HTML、Email等格式进行智能解析识别标题、列表、表格等元素并输出结构化的JSON数据。基于这个输出你可以编写更复杂的切割逻辑例如“将每个二级标题下的所有内容作为一个块”。踩坑实录我曾处理过一份扫描版PDF合同直接用pdfplumber提取文本切割后效果很差。后来改用UnstructuredOCR模式它不仅能提取文字还保留了粗体、下划线等格式线索。我利用“加粗文本多为条款项”这一特征成功地将合同按法律条款进行了完美切割检索准确率大幅提升。结论是对于复杂文档投资一个强大的解析器是值得的。5. 存储策略不仅仅是向量数据库切割好的文本块我们需要存储起来以备检索。存储策略决定了检索的效率和灵活性。5.1 向量化与元数据附着存储的第一步是将文本块转换为向量嵌入。这里的关键是元数据。元数据是描述这个块的“标签”信息它本身不参与语义搜索但用于后过滤。必须考虑的元数据字段source: 文档来源文件名、URL。page_num: 在原文档中的页码对于PDF至关重要。section_title: 所属的章节标题。chunk_index: 块在文档中的顺序。doc_type: 文档类型手册、合同、邮件。last_updated: 最后更新时间。在检索时我们可以先进行向量相似度搜索再用元数据进行过滤。例如“在最近三个月更新的产品手册中搜索与‘故障代码301’相关的内容。” 这需要向量数据库支持元数据过滤。主流向量数据库选型对比数据库核心优势适用场景注意事项Chroma轻量、易用、Python原生开发体验好快速原型验证中小规模项目学习入门生产环境稳定性、分布式支持待考量Pinecone全托管云服务无需运维性能稳定追求开发效率无运维团队云原生项目有成本数据需上传至云端Weaviate功能全面支持混合搜索向量关键词GraphQL接口需要复杂过滤、融合搜索的中大型应用自部署需要一定运维知识Qdrant性能强劲Rust编写过滤功能设计优秀Docker部署简单对性能和过滤有高要求的生产环境社区相对较新但发展迅速PGVectorPostgreSQL插件与现有关系型数据生态无缝集成企业内已有PG数据库需强事务和关联查询需要自行管理PostgreSQL选型建议初期验证用Chroma上生产且团队运维能力弱用Pinecone需要复杂查询和混合搜索用Weaviate或Qdrant系统重度依赖现有PostgreSQL则用PGVector。5.2 多粒度索引与混合检索这是应对“不可能三角”的终极策略之一。我们不再追求一个“完美”的块大小而是同时存储多种粒度的块。具体操作生成多粒度块对同一份文档用不同的策略或参数生成两套或更多块。粗粒度块例如按章节切割每个块约2000字符。信息完整上下文足。细粒度块例如按段落或固定500字符切割。检索精度高。分别建立索引将粗粒度块和细粒度块分别存入向量数据库或同一数据库的不同集合并建立关联例如通过parent_id字段指明细粒度块属于哪个粗粒度块。混合检索第一步用用户查询去检索细粒度索引找到最相关的几个小片段。第二步根据小片段的parent_id找到对应的粗粒度块。第三步将粗粒度块或粗粒度块触发检索的细粒度块作为上下文发送给大模型。这样做的好处是利用细粒度块实现精准的初步召回再利用粗粒度块为模型提供丰富的上下文一举两得。当然存储和计算成本会翻倍。5.3 图存储与知识关联对于文档内部存在复杂关联的场景例如技术文档中大量的交叉引用、学术论文的参考文献可以考虑引入图数据库。思路将每个文本块作为图中的一个节点。除了文本和向量节点还包含类型如概念、方法、案例。然后根据文档内容建立边边的类型可以是“引用”、“属于”、“举例说明”等。检索时先通过向量检索找到核心节点再通过图查询扩展出相关联的节点将这些关联节点的文本作为补充上下文。这能极大地提升回答复杂、多跳问题的能力。例如“文档中提到的A方案与B方案相比有什么优劣” 系统可能需要先找到A和B两个概念的节点再找到它们之间用于“比较”的边所连接的文本块。这种方案架构复杂适用于对知识深度和关联性要求极高的专业领域如法律、医疗、学术研究。6. 完整实操流程构建一个生产级文档处理流水线理论说了这么多我们来看一个从原始文档到可检索索引的完整、健壮的流水线设计。假设我们处理的是混合格式PDF、MD的技术文档。6.1 步骤一文档加载与统一化使用LangChain的DocumentLoader生态它能统一不同格式的加载接口。from langchain.document_loaders import PyPDFLoader, UnstructuredMarkdownLoader, DirectoryLoader from langchain.schema import Document def load_documents(data_dir): documents [] # 加载PDF pdf_loader DirectoryLoader(data_dir, glob“**/*.pdf”, loader_clsPyPDFLoader) documents.extend(pdf_loader.load()) # 加载Markdown md_loader DirectoryLoader(data_dir, glob“**/*.md”, loader_clsUnstructuredMarkdownLoader) documents.extend(md_loader.load()) return documents raw_docs load_documents(“./my_tech_docs/”)6.2 步骤二文本清洗与预处理这是提升后续所有环节质量的关键却常被忽视。import re def clean_text(text): # 1. 去除多余的换行和空格 text re.sub(r‘\s’, ‘ ‘, text) # 2. 去除特定的页眉页脚需要根据文档样式调整正则 text re.sub(r‘^.*机密.*$\n?’, ‘’, text, flagsre.MULTILINE) text re.sub(r‘第\s*\d\s*页’, ‘’, text) # 3. 处理乱码和特殊字符 text text.encode(‘utf-8’, ‘ignore’).decode(‘utf-8’) # 4. 标准化标点可选 # text text.replace(‘’, ‘…’).replace(‘——’, ‘—’) return text.strip() for doc in raw_docs: doc.page_content clean_text(doc.page_content) # 同时可以在这里补充元数据 doc.metadata[“clean_version”] “v1.0”6.3 步骤三智能切割与多粒度生成我们结合递归切割和结构感知并生成双粒度索引。from langchain.text_splitter import RecursiveCharacterTextSplitter, MarkdownHeaderTextSplitter # 策略1: 对普通文本使用递归切割细粒度 fine_splitter RecursiveCharacterTextSplitter( chunk_size500, chunk_overlap80, separators[“\n\n”, “\n”, “。 ”, “ ”, “ ”, “ ”, “ ”, “”] ) # 策略2: 对Markdown按标题切割粗粒度 headers_to_split_on [(“#”, “H1”), (“##”, “H2”), (“###”, “H3”)] md_splitter MarkdownHeaderTextSplitter(headers_to_split_onheaders_to_split_on) all_fine_chunks [] all_coarse_chunks [] for doc in raw_docs: source doc.metadata.get(“source”, “unknown”) content doc.page_content # 根据文件类型选择策略 if source.endswith(“.md”): # Markdown用标题切割 coarse_chunks md_splitter.split_text(content) for cc in coarse_chunks: cc.metadata.update(doc.metadata) cc.metadata[“granularity”] “coarse” all_coarse_chunks.append(cc) # 对每个粗粒度块内部再递归切割生成细粒度块 fine_chunks_in_coarse fine_splitter.split_documents([cc]) for fc in fine_chunks_in_coarse: fc.metadata[“granularity”] “fine” fc.metadata[“parent_id”] len(all_coarse_chunks) - 1 # 关联父块索引 all_fine_chunks.append(fc) else: # 其他格式如PDF文本直接递归切割生成细粒度块 fine_chunks fine_splitter.split_documents([doc]) for fc in fine_chunks: fc.metadata[“granularity”] “fine” all_fine_chunks.append(fc) # 同时也可以尝试按“章节”通过字体大小推断生成粗粒度块这里简化处理 # 可以将每N个细粒度块合并为一个粗粒度块 # ...6.4 步骤四向量化与存储选择Qdrant作为向量数据库存储双粒度索引。from langchain.embeddings import HuggingFaceEmbeddings from langchain.vectorstores import Qdrant from qdrant_client import QdrantClient import uuid # 初始化嵌入模型 embed_model HuggingFaceEmbeddings(model_name“BAAI/bge-small-zh-v1.5”) # 中文优选 # 初始化Qdrant客户端 client QdrantClient(host“localhost”, port6333) collection_name_fine “tech_docs_fine” collection_name_coarse “tech_docs_coarse” # 为细粒度块创建集合和索引 fine_texts [c.page_content for c in all_fine_chunks] fine_metadatas [c.metadata for c in all_fine_chunks] fine_vectors embed_model.embed_documents(fine_texts) # 使用客户端API直接插入以便更精细控制 client.recreate_collection( collection_namecollection_name_fine, vectors_configVectorParams(sizelen(fine_vectors[0]), distanceDistance.COSINE) ) # 批量插入点 points [] for i, (vector, meta) in enumerate(zip(fine_vectors, fine_metadatas)): points.append(PointStruct( idstr(uuid.uuid4()), vectorvector, payloadmeta # 元数据存储在payload中 )) client.upsert(collection_namecollection_name_fine, pointspoints) # 同理存储粗粒度块...6.5 步骤五检索与上下文组装实现一个支持多粒度检索的查询函数。def hybrid_retrieval(query, embed_model, client, top_k_fine5, top_k_coarse2): # 1. 将查询语句向量化 query_vector embed_model.embed_query(query) # 2. 在细粒度集合中检索 fine_results client.search( collection_namecollection_name_fine, query_vectorquery_vector, limittop_k_fine, with_payloadTrue # 返回元数据 ) # 3. 获取关联的粗粒度块ID coarse_ids_to_fetch set() for res in fine_results: parent_id res.payload.get(“parent_id”) if parent_id is not None: coarse_ids_to_fetch.add(parent_id) # 4. 获取粗粒度块内容 (这里简化假设通过ID能直接获取文本) # 实际中你可能需要另一个通过ID查询的接口或事先建立了ID到文本的映射 coarse_contexts [] for cid in list(coarse_ids_to_fetch)[:top_k_coarse]: # 取前N个 # 这里演示从之前存储的all_coarse_chunks列表中获取 if cid len(all_coarse_chunks): coarse_contexts.append(all_coarse_chunks[cid].page_content) # 5. 组装最终上下文细粒度结果精准 关联的粗粒度上下文完整 fine_context “\n\n”.join([res.payload.get(“text”, “”) for res in fine_results]) # 假设payload里有text字段 coarse_context “\n\n”.join(coarse_contexts) final_context f“【相关细节】\n{fine_context}\n\n【背景信息】\n{coarse_context}” return final_context这个流水线涵盖了从加载到检索的核心步骤并融入了多粒度索引的思想。在实际生产中你还需要加入错误处理、日志、批处理优化和监控。7. 效果评估与持续迭代如何判断你的策略是否有效策略实施后不能“撒手不管”。需要建立评估机制持续优化。7.1 构建评估测试集不要凭感觉。建立一个包含20-50个典型问题的测试集QA对。问题应覆盖事实型直接在文档中有明确答案。概括型需要总结多个段落。多跳推理型需要结合文档中不同部分的信息。否定型询问文档中未提及的内容。对于每个问题人工标注出文档中能回答该问题的标准文本片段即理想的检索结果。7.2 核心评估指标检索召回率对于每个问题系统检索出的前k个块中是否包含了标准文本片段计算包含的比例。这是衡量切割和检索效果的核心。检索精度检索出的块中与问题真正相关的块所占的比例。高召回但低精度意味着噪声多。答案准确性将检索到的上下文喂给LLM生成答案与标准答案对比可以用ROUGE、BLEU分数或GPT-4等高级模型进行评判。这是衡量端到端效果的终极指标。上下文利用率观察LLM生成的答案是否真正用到了你提供的上下文。有时模型会“无视”上下文凭自身知识生成。这提示你可能上下文不够相关或者提示词需要优化。7.3 A/B测试与迭代尝试不同的切割策略例如只改变chunk_size或overlap在同一个测试集上运行对比上述指标。记录下最佳参数组合。随着文档库的更新和扩大这个测试集也需要定期更新和扩充。文档存储与切割不是一劳永逸的设置而是一个需要随着业务理解和数据变化而不断调优的动态过程。它没有银弹最好的策略永远是那个最理解你数据的人所设计出来的策略。