
1. 从“文档切片”到“向量化”RAG架构的基石工程如果你最近在折腾大模型应用尤其是想让它“读懂”你自己的文档库那你大概率绕不开“RAG”这个词。RAG检索增强生成听起来高大上但它的地基其实就两件事怎么把文档切好以及怎么把切好的文本变成机器能理解的数字。前者叫文档切片后者就是向量化。这俩活干得好不好直接决定了你的AI应用是“智能助理”还是“人工智障”。我见过太多项目模型选型顶尖前端界面酷炫但一问答就露馅——要么答非所问要么胡言乱语。追根溯源十有八九是文档预处理这块埋了雷。切片切得支离破碎上下文丢失向量化模型选得不合适语义“味道”全变了。结果就是大模型拿到的“参考资料”本身就是错的它再聪明也无力回天。所以今天我们不谈那些花哨的架构图就沉下心来把“文档切片向量化”这个看似基础、实则至关重要的环节掰开揉碎了讲清楚。我会结合最新的技术动态比如SigLIP-2这类多模态向量模型以及RAG中多路召回、重排序的实战需求告诉你为什么不能随便切、不能随便“向量”以及到底该怎么操作。这是一项脏活、累活但也是决定成败的“手艺活”。2. 文档切片不是“切菜”而是“保意”很多人把文档切片理解成按固定长度“切段”比如每500个字符一刀。这种做法简单粗暴但后患无穷。想象一下你正在读一份技术合同关键的一句“除上述情况外本合同不可撤销”刚好被从中间切断前半句在上一段后半句在下一段。当用户问“合同在什么情况下可撤销”时系统检索到的片段是不完整的模型很可能给出完全相反的答案。2.1 核心原则维护语义完整性切片的核心目的是在将长文档拆分为适合向量模型处理的大小通常512或1024个token的同时最大限度地保留完整的语义单元。一个语义单元可能是一个段落、一个列表项、一个代码块或者一个完整的问答对。基于语义的切片策略通常包括递归字符切片这是基础。设定一个目标长度如1000字符和重叠长度如200字符。先按目标长度切然后检查切分点是否在句子中间。如果在就向前或向后找到最近的句号、换行符等自然边界进行调整。重叠部分是为了防止语义在边界处被割裂确保上下文连贯。基于标记器的切片对于像Transformer这类模型直接按token数切分更准确。使用与后续向量化模型相同的tokenizer如tiktokenfor GPTsentencepiecefor BGE按token数进行切割和重叠能确保切片长度严格符合模型输入限制避免意外截断。基于文档结构的切片这是高级策略。对于PDF、Word、Markdown等格式文档本身带有结构信息标题、段落、列表。我们应该利用这些结构标题感知切片确保切片不以低于某个级别如H3的标题开头。一个切片应尽可能包含一个主干标题下的完整内容。段落聚合不拆散单个段落。如果段落太长再考虑用递归字符法在段落内寻找句子边界进行拆分。表格/代码块保持完整将整个表格或代码块作为一个不可分割的单元。即使它很短也不与其他文本合并即使它很长也尽量保持在一起或寻找表内逻辑分界点如代码中的函数定义。2.2 实操中的细节与陷阱在实际操作中有几个细节决定了切片质量重叠长度的选择重叠不是越大越好。通常20%左右的重叠是一个不错的起点。例如目标长度1000字符重叠200字符。重叠太小可能无法桥接关键信息重叠太大会增加存储和检索成本并可能引入冗余噪声。对于技术文档或法律文书可以适当增大重叠如25%-30%以确保关键条款的上下文完整。分隔符的优先级定义你的分隔符优先级列表。例如[\n\n, \n, 。, . , ?, !, ,, , ;]。切割时从目标分割点向前或向后查找优先使用优先级高的分隔符。对于中文要特别注意全角标点。元数据的保留切片时一定要把来源信息原文档名、路径、在原文中的位置页码、起始行、所属的章节标题等作为元数据metadata附加到每个切片上。这在后续检索后用于向用户展示引用来源时至关重要。一个完整的切片对象应该包含{“text”: “切片内容”, “metadata”: {“source”: “xxx.pdf”, “page”: 5, “section”: “2.1 安装步骤”}}。注意对于非常长的段落或没有标点的文本如某些日志文件语义切片可能失效。此时可以考虑在切片后通过小模型或规则为每个切片生成一个“摘要”或“关键问题”作为该切片的补充查询字段辅助检索。3. 向量化模型给文本装上“语义GPS”切片完成后一堆文本需要被转化为向量一组高维数字。这个过程就是向量化其核心是嵌入模型。这个模型决定了文本在向量空间中的“位置”相似的文本距离近不相似的文本距离远。3.1 模型选型从通用到专用从纯文本到多模态过去我们可能直接用OpenAI的text-embedding-ada-002但它按token收费且有速率限制。现在开源社区的选择非常丰富通用文本嵌入王者**BGEBAAI General Embedding**系列。例如BGE-large-zh-v1.5在中文语义相似度任务上表现非常出色完全免费可私有化部署。对于中文场景这通常是首选。新一代多模态悍将SigLIP-2。这是最近的热点。传统的CLIP模型对比图像和文本而SigLIP-2是一种Sigmoid Loss训练的视觉语言模型效率更高。关键在于它产生的向量是跨模态对齐的。这意味着什么如果你的文档库里有大量图文并茂的资料如产品手册、学术论文、带图报表的报告使用SigLIP-2可以将图片和其周围的描述文本映射到同一个向量空间。当用户用文字提问时不仅能召回相关文本片段还能直接召回相关的图片这为RAG打开了新的大门。轻量化与速度之选Sentence Transformers库中的all-MiniLM-L6-v2。这个模型只有22M参数速度极快在保证不错质量的前提下是处理海量文档或对延迟要求极高场景的首选。领域化微调模型如果你的文档非常垂直如医学病历、法律条文、金融报告使用在领域数据上微调过的嵌入模型效果会远超通用模型。你可以用BGE或Sentence Transformers模型作为基础在自己的领域语料上继续训练。选型决策逻辑纯文本中文为主选BGE-large-zh。纯文本中英混合或英文为主选BGE的英文版或text-embedding-3-small如果可用且成本可接受。文档含大量图片需图文联合检索重点评估SigLIP-2。你需要将图片通过视觉编码器也转化为向量和文本向量存在同一个向量库中。超大规模文档延迟敏感选all-MiniLM-L6-v2或类似小模型。专业领域效果至上准备数据微调一个领域专用的BGE模型。3.2 向量化实操批处理、归一化与维度选好模型后操作上也有讲究# 以 Sentence Transformers 库为例 from sentence_transformers import SentenceTransformer import numpy as np # 1. 加载模型 model SentenceTransformer(BAAI/bge-large-zh-v1.5) # 2. 准备切片文本列表 chunk_texts [chunk1, chunk2, chunk3, ...] # 3. 批处理生成向量 # 设置一个合理的batch_size取决于你的GPU内存 batch_size 32 embeddings model.encode(chunk_texts, batch_sizebatch_size, normalize_embeddingsTrue, # 关键步骤 show_progress_barTrue) # encode函数返回的embeddings已经是numpy数组这里有两个技术要点批处理永远不要for循环单条编码。使用批处理能极大利用GPU/CPU的并行计算能力速度可能提升数十倍。batch_size需要根据你的硬件和模型大小调整通常从32或64开始尝试。归一化normalize_embeddingsTrue这个参数至关重要。它会将每个向量转化为单位向量模长为1。这样做之后向量之间的余弦相似度计算就简化为点积。因为对于单位向量u和vcosine(u, v) u·v。这不仅能加速计算还是很多向量数据库如Milvus, Pinecone的默认要求和最佳实践。维度一致性不同模型的输出维度不同如BGE-large是1024维MiniLM是384维。这决定了你后续向量数据库索引的维度设置必须完全匹配。4. 为“多路召回”与“重排序”铺路现在你的文档已经被切成了语义完整的片段并转化成了一堆高维向量。但这只是开始。在真实的RAG系统中直接使用向量相似度搜索即“单路召回”往往不够鲁棒。先进的架构会采用多路召回与重排序策略而这反过来对前面的切片和向量化提出了新的要求。4.1 切片策略如何支持多路召回多路召回是指从不同角度、使用不同方法进行检索然后合并结果。常见的“路”有向量相似度召回语义召回这就是我们上面做的核心工作。它负责捕捉语义层面的相似性。关键词召回稀疏检索例如使用BM25算法。它更关注字面匹配对于术语、缩写、特定名称的查找非常有效。为了支持关键词召回我们在切片时就不能只保留纯文本。我们需要提取关键词为每个切片自动提取若干关键词或关键短语存入该切片的元数据中。可以使用jieba.analyse中文或KeyBERT等工具。保留原始文本显然BM25需要原始文本进行计算。这样当用户查询时系统可以并行执行一路用查询语句的向量去向量数据库搜索另一路用查询语句作为关键词在切片的关键词或全文上进行BM25检索。最后将两路结果合并。4.2 向量化如何适配重排序合并后的候选切片列表可能很多比如50个直接全部塞给大模型会超出上下文窗口且质量参差不齐。这时需要重排序模型对候选片段进行精细打分只保留Top-K个最相关的。重排序模型如BGE-reranker比嵌入模型更复杂、计算代价更高但它比较的是“查询-段落”对的匹配程度。我们的向量化阶段需要为此做准备吗需要生成高质量的“正样本”在构建向量索引时我们可以有意识地构造一些训练数据来微调我们的嵌入模型使其产生的向量在空间分布上更有利于区分“强相关”、“弱相关”和“不相关”的文本。例如对于一个切片我们可以人工或启发式地生成几个不同相关度的问题。这能提升第一轮向量召回的精度减轻重排序模型的压力。为切片添加“摘要”或“问题”字段除了全文向量化还可以为每个切片生成一个简短的摘要或一个可能的问题并将其单独向量化。这相当于为切片建立了多个“检索入口”。在召回时可以同时计算查询与“全文向量”、“摘要向量”、“问题向量”的相似度加权求和作为最终得分这本身就是一种轻量级的重排序。5. 全流程实战一个可复现的Pipeline示例让我们串联起所有步骤构建一个从原始文档到可检索向量的完整Pipeline。这里以处理一批中文PDF技术手册为例。5.1 步骤一文档解析与提取import pymupdf # PyMuPDF for PDF from typing import List, Dict import json def parse_pdf(pdf_path: str) - List[Dict]: 解析PDF提取带结构的文本和元数据。 返回一个字典列表每个字典代表一个语义块如段落、标题。 doc pymupdf.open(pdf_path) blocks [] for page_num, page in enumerate(doc): # 获取页面的文本块并保留位置、字体等基础信息 page_blocks page.get_text(dict)[blocks] for b in page_blocks: if b[type] 0: # 文本块 text .join([line[spans][0][text] for line in b[lines]]) if text.strip(): # 过滤空文本 block_info { text: text.strip(), page: page_num 1, bbox: b[bbox], # 边界框可用于判断是否是标题如字体大小 source: pdf_path } # 简单启发式根据字体大小判断是否为标题需根据实际文档调整 if b[lines] and b[lines][0][spans]: font_size b[lines][0][spans][0][size] if font_size 13: # 假设正文字体大小为11 block_info[type] heading else: block_info[type] paragraph blocks.append(block_info) return blocks5.2 步骤二基于结构的语义切片def semantic_chunking(blocks: List[Dict], max_chunk_size: int 1000, overlap: int 200) - List[Dict]: 基于解析出的块进行语义切片。 chunks [] current_chunk [] current_chunk_text current_section for i, block in enumerate(blocks): block_text block[text] block_type block.get(type, paragraph) # 规则1遇到新标题强制开启新切片除非当前切片为空 if block_type heading and current_chunk_text: # 先保存当前切片 if current_chunk: save_chunk(chunks, current_chunk, current_chunk_text, current_section) # 重置新切片以这个标题开始 current_chunk [block] current_chunk_text block_text current_section block_text continue # 规则2如果加入当前块会超过最大长度且当前块不是标题 # 则先保存当前切片并考虑重叠 if len(current_chunk_text) len(block_text) max_chunk_size and block_type ! heading: if current_chunk: save_chunk(chunks, current_chunk, current_chunk_text, current_section) # 创建重叠从当前切片末尾取overlap长度的文本作为新切片开头 # 这里简化处理将上一个切片的最后一个块作为重叠部分 if current_chunk: current_chunk current_chunk[-1:] # 最后一个块作为重叠 current_chunk_text current_chunk[-1][text] if current_chunk else else: current_chunk [] current_chunk_text # 将当前块加入新切片它可能仍然很大会在下次循环被处理 if block_text: current_chunk.append(block) current_chunk_text block_text else: # 规则3正常添加块到当前切片 current_chunk.append(block) current_chunk_text block_text # 处理最后一个切片 if current_chunk: save_chunk(chunks, current_chunk, current_chunk_text, current_section) return chunks def save_chunk(chunks_list, chunk_blocks, chunk_text, section): 整理切片信息并保存 if not chunk_text.strip(): return metadata { source: chunk_blocks[0][source], pages: f{chunk_blocks[0][page]}-{chunk_blocks[-1][page]}, section: section, block_count: len(chunk_blocks) } chunks_list.append({ text: chunk_text, metadata: metadata, original_blocks: chunk_blocks # 可选保留原始块信息 })5.3 步骤三向量化与索引构建from sentence_transformers import SentenceTransformer import chromadb # 以ChromaDB为例 from chromadb.config import Settings # 初始化模型和客户端 model SentenceTransformer(BAAI/bge-large-zh-v1.5) client chromadb.PersistentClient(path./vector_db) # 获取或创建集合表。指定向量维度。 collection client.get_or_create_collection( nametech_manual, metadata{hnsw:space: cosine} # 使用余弦相似度 ) all_chunks [] # 假设这里已经装满了上一步生成的所有切片 batch_size 32 for i in range(0, len(all_chunks), batch_size): batch_chunks all_chunks[i:ibatch_size] batch_texts [chunk[text] for chunk in batch_chunks] batch_ids [fchunk_{ij} for j in range(len(batch_chunks))] batch_metadatas [chunk[metadata] for chunk in batch_chunks] # 生成向量 batch_embeddings model.encode(batch_texts, batch_sizebatch_size, normalize_embeddingsTrue, show_progress_barTrue) # 添加到向量数据库 collection.add( embeddingsbatch_embeddings.tolist(), # ChromaDB需要list documentsbatch_texts, # 存储原始文本 metadatasbatch_metadatas, idsbatch_ids ) print(f已插入批次: {i//batch_size 1})5.4 步骤四检索测试与迭代优化构建完成后必须进行检索测试。# 一个简单的检索测试函数 def test_retrieval(query, collection, model, top_k5): # 将查询语句向量化 query_embedding model.encode([query], normalize_embeddingsTrue)[0] # 在向量库中搜索 results collection.query( query_embeddings[query_embedding.tolist()], n_resultstop_k, include[documents, metadatas, distances] ) print(f查询: {query}) print(- * 50) for i, (doc, meta, dist) in enumerate(zip(results[documents][0], results[metadatas][0], results[distances][0])): print(f结果 {i1} (距离: {dist:.4f}):) print(f 来源: {meta.get(source, N/A)}, 章节: {meta.get(section, N/A)}) print(f 文本预览: {doc[:200]}...) # 预览前200字符 print()用一系列问题测试观察召回结果是否相关、完整。如果发现召回结果不相关可能需要调整切片大小或检查向量化模型是否适合你的领域。关键信息被切碎需要减小切片大小或增加重叠或优化基于标题的切片逻辑。召回结果冗余可能需要增大切片大小或减少重叠或在检索后引入去重步骤。这个过程需要反复迭代。没有一劳永逸的“银弹”参数必须根据你的文档特性和查询需求进行调试。6. 避坑指南那些我踩过的“坑”最后分享几个在实际项目中容易忽略但一旦发生就非常头疼的问题。坑一编码与字符问题处理中文文档时确保从文件读取、解析到处理的整个链路都使用UTF-8编码。PDF解析器有时会产出乱码或“□□□”这可能是因为PDF内嵌了非常用字体。对于复杂排版的PDFpymupdf可能不够可以尝试pdfplumber或商业OCR引擎如阿里云、百度云的OCR服务进行高精度文本提取虽然成本更高。坑二切片长度与模型上下文窗口的混淆你的切片长度如1000字符和向量化模型的最大序列长度是两回事。比如BGE-large模型最大支持512个token。如果你的1000字符的文本经过它的tokenizer后超过了512个token模型会自动截断这会导致信息丢失。因此切片的目标长度应该基于目标模型的token数来设定。先用模型的tokenizer测试你的文本找到字符数与token数的大致比例。坑三向量数据库的“距离”陷阱不同的向量数据库和索引对“距离”的定义可能不同。常见的有余弦相似度、内积、L2欧氏距离。余弦相似度和归一化后的内积是等价的。但如果你在生成向量时做了归一化normalize_embeddingsTrue在查询时就必须告诉数据库使用内积或余弦相似度作为距离度量。在ChromaDB中创建集合时设置metadata{hnsw:space: cosine}就是做这个。如果设置不匹配检索结果会完全错误。坑四元数据膨胀为了方便有人会把整段原文、图片base64都塞进元数据。这会导致单个向量记录非常大严重影响批量插入和查询性能。元数据应只存储用于过滤和展示的轻量级关键信息如ID、来源、页码、章节。原文本身应存在documents字段或另外的键值存储中。坑五静态索引的更新文档库不是一成不变的。当新增、删除或修改文档时全量重建向量索引的成本很高。你需要设计一个增量更新策略。例如为每个切片计算一个基于内容的哈希值如MD5。当文档更新时重新解析并切片计算新切片的哈希只删除旧哈希对应的向量插入新向量。这要求你的向量数据库支持高效的按元数据哈希值删除操作。