
RAG 系统落地时最容易被低估的环节不是向量检索也不是大模型选型而是数据导入与解析。很多人一上来就调 embedding 接口、搭向量库结果跑出来的回答驴唇不对马嘴回头一查——原始文档压根没解析干净。PDF 里的表格被拆成了乱码Markdown 的层级结构全丢了txt 文件里混着的特殊符号直接污染了切片。我前后经手过十几个 RAG 项目踩过的坑基本都集中在数据进库之前这一段。这篇就从最基础的 txt 和 Markdown 两种格式切入把通用文本与结构化文档的解析逻辑讲透后面再延伸到 PDF、HTML、Word 等复杂格式。1. 为什么 txt 和 Markdown 是 RAG 数据导入的起点1.1 纯文本不等于随便读进来就行很多人觉得 txt 文件最简单open().read()一把梭就完事了。但真正做过 RAG 的人知道纯文本的坑一点都不少。编码问题首当其冲——GBK、UTF-8、UTF-8 with BOM、Latin-1不同来源的 txt 编码五花八门。你直接按 UTF-8 读一个 GBK 编码的文件轻则乱码重则直接抛异常中断整个导入流程。更隐蔽的问题是文本中的控制字符和不可见字符。从网页复制粘贴保存的 txt经常夹杂着零宽空格U200B、零宽连字符U200D、软连字符U00AD这些东西。它们在肉眼看来完全正常但会严重影响 tokenizer 的分词结果进而导致 embedding 质量下降。我实测过一个案例同一段文本清理前后做 embedding余弦相似度差了 0.15检索召回率直接掉了两成。还有一个常被忽略的点txt 文件里的换行符。Windows 用\r\nLinux 用\n老 Mac 用\r。如果不统一处理切片时按\n\n分段就会出问题——\r\n\r\n和\n\n混在一起段落边界识别全乱套。1.2 Markdown 的结构信息是 RAG 的天然优势Markdown 相比纯 txt 最大的价值在于它自带结构标记。#表示标题层级-或*表示列表表示引用代码块用反引号包裹。这些标记在人类阅读时是排版工具在 RAG 系统里就是天然的语义边界信号。举个例子一篇技术文档用 Markdown 写成有三级标题结构。如果你按固定字符数切片很可能把一个完整的概念从中间截断前半段在 chunk A后半段在 chunk B。检索时只召回了 chunk A大模型拿到的上下文是残缺的回答自然不完整。但如果你先解析 Markdown 的标题层级按章节切分每个 chunk 就是一个语义完整的段落检索质量会有质的提升。我在一个知识库项目里做过对比测试同一份 Markdown 文档固定 512 token 切片 vs 按标题层级切片在 200 条测试 query 上的召回准确率分别是 61% 和 83%。差距就是这么明显。1.3 通用文本解析的边界在哪里所谓通用文本解析指的是不依赖特定领域知识、对大多数纯文本和轻量标记文本都适用的处理流程。它的边界在于不处理二进制格式PDF、Word、Excel不处理复杂嵌套结构HTML、XML、JSON不处理多媒体内容图片、音频、视频。但通用解析是后续所有复杂解析的基础。你把 txt 和 Markdown 的解析逻辑吃透了再去做 PDF 解析、HTML 解析会发现核心思路是一致的识别结构、提取内容、清理噪声、保留语义边界。区别只在于结构识别的复杂度不同。2. 编码检测与文本清洗的实操细节2.1 编码检测不能只靠 chardetchardet和charset-normalizer是 Python 里最常用的编码检测库但它们不是万能的。短文本的检测准确率很低一段 50 个字符的中文文本chardet 可能给出三四种可能的编码置信度都不高。我的做法是分层处理先用charset-normalizer做初步检测拿到候选编码列表然后按优先级依次尝试解码UTF-8 GBK GB18030 Latin-1哪个能成功解码就用哪个如果都失败用errorsreplace强制解码同时记录警告日志后续人工介入。from charset_normalizer import from_bytes def detect_and_decode(raw_bytes): results from_bytes(raw_bytes) best results.best() if best and best.encoding: return str(best), best.encoding # 兜底按优先级尝试 for enc in [utf-8, gbk, gb18030, latin-1]: try: return raw_bytes.decode(enc), enc except (UnicodeDecodeError, LookupError): continue return raw_bytes.decode(utf-8, errorsreplace), utf-8-replace注意GB18030 是 GBK 的超集优先用 GB18030 能覆盖更多中文字符。但如果你明确知道文件是 GBK直接用 GBK 更稳妥避免 GB18030 把某些字节序列解释成生僻字。2.2 不可见字符的清理清单下面这些字符在 RAG 数据清洗中必须处理我整理了一张对照表字符名称Unicode 码点常见来源处理方式零宽空格U200B网页复制直接删除零宽连字符U200D网页复制直接删除零宽非连字符U200C网页复制直接删除软连字符U00ADPDF 提取直接删除字节顺序标记UFEFFWindows 记事本仅保留文件头部的其余删除不间断空格U00A0网页复制替换为普通空格全角空格U3000中文排版替换为普通空格制表符U0009代码/表格根据上下文决定保留或替换清理逻辑用正则一次性搞定import re def clean_invisible_chars(text): # 删除零宽字符和软连字符 text re.sub(r[\u200b\u200c\u200d\u00ad], , text) # BOM 只保留第一个 if text.startswith(\ufeff): text \ufeff text[1:].replace(\ufeff, ) else: text text.replace(\ufeff, ) # 不间断空格和全角空格替换为普通空格 text text.replace(\u00a0, ).replace(\u3000, ) return text2.3 换行符统一与段落边界识别换行符统一很简单text.replace(\r\n, \n).replace(\r, \n)。但段落边界识别就没那么直接了。纯文本里段落通常用空行分隔但空行的定义很模糊——一个\n\n是空行\n \n中间有空格算不算\n\t\n呢我的经验是先把所有行做rstrip()然后连续两个及以上的\n视为段落分隔。但这里有个陷阱代码块里的空行不应该被视为段落分隔。如果你处理的 txt 里包含代码片段需要先识别代码区域通常有缩进或特定标记在代码区域内保留原始换行。def normalize_paragraphs(text): lines text.split(\n) normalized [] blank_count 0 for line in lines: stripped line.rstrip() if stripped : blank_count 1 else: if blank_count 2: normalized.append() # 保留一个空行作为段落分隔 elif blank_count 1: normalized.append() # 单个空行也保留 blank_count 0 normalized.append(stripped) return \n.join(normalized)3. Markdown 结构化解析的核心逻辑3.1 标题层级树的构建方法Markdown 解析的第一步是把扁平的文本变成树形结构。#是一级标题##是二级以此类推。解析时维护一个栈遇到新标题就根据层级关系确定它的父节点。import re def parse_markdown_headings(text): heading_pattern re.compile(r^(#{1,6})\s(.)$, re.MULTILINE) headings [] for match in heading_pattern.finditer(text): level len(match.group(1)) title match.group(2).strip() position match.start() headings.append({ level: level, title: title, position: position }) return headings拿到标题列表后每个标题到下一个同级或更高级标题之间的内容就是该标题对应的正文。这样切出来的每个块都自带层级路径比如1. 项目概述 1.1 核心需求解析检索时可以把层级路径作为上下文一起喂给大模型显著提升回答的准确性。3.2 代码块、表格、引用的特殊处理Markdown 里的代码块 包裹、表格| 分隔、引用 开头需要特殊对待。代码块内的#不是标题表格里的|不是普通字符引用块可能包含嵌套结构。解析时先用状态机标记出这些特殊区域在后续的标题解析和段落切分中跳过它们def mark_special_regions(text): regions [] lines text.split(\n) in_code_block False code_start 0 for i, line in enumerate(lines): if line.strip().startswith(): if not in_code_block: in_code_block True code_start i else: in_code_block False regions.append((code, code_start, i)) return regions表格的解析更复杂一些需要识别表头分隔行|---|---|然后把每行按|拆分。但要注意单元格内容里可能包含转义的\|不能简单 split。3.3 从 Markdown 到 chunk 的映射策略有了标题树和特殊区域标记就可以设计 chunk 切分策略了。我的推荐方案是一级和二级标题作为主要切分点每个二级标题下的内容作为一个 chunk如果某个二级标题下的内容超过 1000 token按三级标题进一步切分如果三级标题下还是太长按段落切分但保证每个 chunk 至少包含一个完整段落代码块和表格不拆分作为独立 chunk 或附加到最近的文本 chunk每个 chunk 的 metadata 里要记录所属标题路径、在原文中的位置、chunk 类型文本/代码/表格、前后 chunk 的 ID。这些 metadata 在检索和后处理阶段非常有用。4. 文本切片策略从固定长度到语义感知4.1 固定长度切片的适用场景与局限固定长度切片是最简单的方案按 token 数或字符数切每 N 个 token 一个 chunk相邻 chunk 之间留 M 个 token 的重叠。它的优点是实现简单、chunk 大小均匀、便于批量处理。但它的问题也很明显不考虑语义边界可能把一个完整的句子、一个完整的论点从中间截断。对于结构松散的纯文本这个问题尤其严重。我见过最离谱的案例是一个 chunk 里前半段在讲数据库索引后半段突然跳到菜谱做法因为原文这两段恰好挨着固定切片把它们切到了一起。固定切片适合什么场景适合那些本身就没有明显结构、内容粒度均匀的文本比如日志文件、聊天记录、简单的问答对。对于有结构的文档还是应该优先用语义感知的切片。4.2 基于段落和标题的语义切片语义切片的核心思想是在语义边界处切分而不是在固定位置切分。对于 Markdown语义边界就是标题对于纯文本语义边界是段落。具体做法是先按语义边界把文本切成小块然后合并相邻的小块直到达到目标 chunk 大小。如果单个语义块就超过了目标大小再考虑在块内部按句子切分。def semantic_chunking(paragraphs, max_tokens512, overlap_tokens50): chunks [] current_chunk [] current_size 0 for para in paragraphs: para_size estimate_tokens(para) if current_size para_size max_tokens and current_chunk: chunks.append(\n\n.join(current_chunk)) # 保留最后一段作为重叠 if overlap_tokens 0: overlap_text current_chunk[-1] current_chunk [overlap_text] current_size estimate_tokens(overlap_text) else: current_chunk [] current_size 0 current_chunk.append(para) current_size para_size if current_chunk: chunks.append(\n\n.join(current_chunk)) return chunks4.3 重叠窗口的大小怎么定重叠窗口的作用是防止关键信息恰好落在两个 chunk 的边界上导致检索时两边都召回不完整。但重叠太大会导致冗余信息增多检索结果重复浪费上下文窗口。我的经验值是重叠 10% 到 20% 的 chunk 大小。如果 chunk 是 512 token重叠 50 到 100 token 比较合适。对于结构紧密的技术文档可以取小一点10%对于叙述性的内容可以取大一点20%。还有一个技巧重叠部分不要机械地取最后 N 个 token而是取最后一个完整段落。这样重叠的内容本身也是语义完整的不会出现半句话的情况。5. 元数据提取与存储设计5.1 每个 chunk 应该带哪些元数据元数据是 RAG 系统的隐形骨架。没有元数据你只能做纯向量检索有了元数据你可以做过滤、排序、重排、溯源。每个 chunk 至少应该包含以下字段字段名类型说明chunk_idstring全局唯一标识doc_idstring所属文档 IDsource_pathstring原始文件路径chunk_indexint在文档中的序号contentstring文本内容heading_pathstring标题层级路径chunk_typestringtext/code/table/quotetoken_countinttoken 数量prev_chunk_idstring前一个 chunk 的 IDnext_chunk_idstring后一个 chunk 的 IDcreated_attimestamp创建时间heading_path特别重要。检索时如果召回了某个 chunk把它的 heading_path 一起展示给大模型大模型能更好地理解这段内容的上下文位置。比如3.2 代码块、表格、引用的特殊处理比单独一段代码更容易被正确理解。5.2 文档级元数据与 chunk 级元数据的分工文档级元数据描述整个文档的属性标题、作者、创建时间、文件类型、总 chunk 数、语言等。chunk 级元数据描述单个切片。两者分开存储检索时先按文档级元数据做粗筛再按 chunk 级元数据做精排。比如用户问2023 年之后更新的 API 文档里关于认证的部分你可以先用文档级元数据过滤出 2023 年之后的 API 文档再在这些文档的 chunk 里检索认证相关内容。这比直接在全部 chunk 里做向量检索要精准得多。5.3 元数据存储的选型建议小规模场景几万 chunk用 SQLite 就够了简单、零依赖、单文件。中等规模百万级上 PostgreSQL配合 JSONB 字段存灵活的元数据。大规模千万级以上考虑专门的向量数据库比如 Milvus、Qdrant、Weaviate它们都支持元数据过滤和向量检索的混合查询。我个人的偏好是元数据存 PostgreSQL向量存专门的向量库两者用 chunk_id 关联。这样元数据的查询和更新用 SQL 更灵活向量检索的性能也不受影响。6. 实战中容易踩的坑与排查思路6.1 解析后文本看起来对但检索不对这是最让人头疼的问题。文本肉眼看着没问题但检索就是不准。排查思路是第一步检查 tokenizer 的分词结果。用和 embedding 模型相同的 tokenizer 把 chunk 编码一遍看看有没有异常多的 UNK token 或者异常的分词边界。中文文本如果被按字符切分说明 tokenizer 选错了。第二步检查 embedding 向量的分布。把所有 chunk 的 embedding 做 PCA 降到二维可视化如果所有点挤在一起说明 embedding 没有区分度可能是文本太短或太相似。如果出现明显的离群点检查那些 chunk 的内容是否有异常。第三步做检索测试。构造一批已知答案的 query看正确 chunk 的排名。如果正确 chunk 根本不在 top-K 里说明 embedding 质量或切片策略有问题。6.2 中文标点与英文标点混用的处理中文文本里混用英文标点是很常见的比如用英文逗号代替中文逗号用英文引号代替中文引号。这在检索时会造成问题用户用中文标点写的 query匹配不到用英文标点的文档。处理方式有两种一是统一转换为中文标点如果文档以中文为主二是建立标点映射表在检索时把 query 和文档都做归一化。我倾向于后者因为不破坏原文只在检索时做转换。PUNCT_MAP { ,: , .: 。, ?: , !: , :: , ;: , (: , ): , : , : , : , : } def normalize_punctuation(text): for en, cn in PUNCT_MAP.items(): text text.replace(en, cn) return text注意这个转换只用于检索匹配不要用在存储的原文上。原文保持原样检索时对 query 和索引都做归一化即可。6.3 大文件分块导入的内存控制处理大文件时不要一次性把整个文件读进内存。用流式读取逐行或逐块处理。Python 的open()返回的文件对象本身就是迭代器可以逐行读取。def stream_process(file_path, chunk_size8192): with open(file_path, r, encodingutf-8) as f: buffer while True: chunk f.read(chunk_size) if not chunk: break buffer chunk # 按段落处理 buffer paragraphs buffer.split(\n\n) buffer paragraphs.pop() # 最后一段可能不完整留到下次 for para in paragraphs: yield para if buffer: yield buffer这样内存占用只和 chunk_size 有关和文件大小无关。处理几个 GB 的文本文件也不会爆内存。6.4 解析失败的兜底与日志记录再完善的解析逻辑也会遇到异常文件。关键是异常发生时不能中断整个导入流程要记录日志、跳过问题文件、继续处理下一个。我的做法是给每个文件的解析包一层 try-except捕获所有异常记录文件名、异常类型、异常信息、处理进度。解析成功的文件记录 chunk 数量解析失败的文件单独存到一个失败列表后续人工排查。import logging logging.basicConfig(levellogging.INFO, format%(asctime)s - %(levelname)s - %(message)s) def safe_parse(file_path): try: content read_file(file_path) chunks parse_content(content) logging.info(fSuccess: {file_path}, {len(chunks)} chunks) return chunks except Exception as e: logging.error(fFailed: {file_path}, {type(e).__name__}: {e}) return None日志里一定要记录足够的信息方便复现问题。文件路径、异常堆栈、当时的处理参数这些都要有。我吃过亏有一次线上导入失败日志只记了解析失败排查了半天才发现是某个文件的编码检测出了问题。7. 从 txt/Markdown 到复杂格式的扩展思路7.1 PDF 解析的额外挑战PDF 比 txt 和 Markdown 复杂得多。PDF 本质上是排版格式不是语义格式。它只告诉你这个字符画在坐标 (x, y)不告诉你这是一个标题还是这是一段正文。解析 PDF 需要先做版面分析识别文本块、图片、表格、页眉页脚。然后根据字体大小、加粗、位置等特征推断标题层级。表格提取更是难点合并单元格、跨页表格、无边框表格每一种都有专门的算法。常用的工具链是pdfplumber做文本和表格提取PyMuPDF做版面分析camelot或tabula专门处理表格。但没有任何一个工具能 100% 搞定所有 PDF实际项目中往往需要多个工具组合加上人工校验。7.2 HTML 解析的结构化提取HTML 天生就是结构化的有 DOM 树。解析 HTML 的核心是识别主要内容区域去掉导航栏、广告、页脚这些噪声。BeautifulSoup和lxml是常用的解析库。先用 CSS 选择器或 XPath 定位主要内容容器比如article、main、div classcontent然后遍历子节点把h1到h6映射为标题层级p映射为段落pre和code映射为代码块table映射为表格。难点在于不同网站的 HTML 结构千差万别没有通用的选择器规则。实际项目中往往需要针对每个站点写适配规则或者用基于机器学习的正文提取算法如readability算法。7.3 统一解析框架的设计原则不管处理什么格式解析框架的设计原则是一致的输入层统一接收文件路径或字节流自动检测格式解析层每种格式一个解析器输出统一的中间表示标题树 段落列表 特殊块列表清洗层统一的文本清洗、编码归一化、标点归一化切片层统一的语义切片策略根据中间表示的结构信息切分输出层统一的 chunk 格式包含内容和元数据这样设计的好处是新增一种格式只需要写一个解析器后面的清洗、切片、存储逻辑完全复用。我在项目里用这套架构从支持 txt/Markdown 扩展到支持 PDF/HTML/Word只花了不到一周时间。8. 一些实测有效的参数与配置8.1 chunk 大小的经验值chunk 大小没有万能值但有一些经验范围纯问答对128 到 256 token因为每个问答本身就很短技术文档512 到 768 token能容纳一个完整的概念解释叙述性长文768 到 1024 token保证段落完整性代码文档按函数或类切分不按 token 数我一般先用 512 token 做基线测试然后根据检索效果调整。如果召回的内容经常不完整就增大 chunk如果召回的内容太杂就减小 chunk。8.2 embedding 模型的选择考量中文场景下BGE系列、M3E、text2vec都是不错的选择。英文场景text-embedding-3-small性价比很高。多语言场景考虑multilingual-e5或BGE-M3。选择时重点看三个指标检索准确率在你自己数据上的测试、推理速度、向量维度。维度越高检索越准但存储和计算成本也越高。768 维和 1024 维在实际效果上差距不大但存储成本差 33%。8.3 检索时的元数据过滤技巧元数据过滤能大幅提升检索精度但过滤太严会导致召回不足。我的策略是先用宽松的过滤条件缩小范围比如限定文档类型和时间范围再做向量检索最后用元数据做重排比如优先返回标题匹配度高的 chunk。过滤条件不要硬编码做成可配置的。不同场景需要不同的过滤策略硬编码会导致系统僵化。9. 写在最后的一些个人体会数据导入与解析这个环节投入产出比其实非常高。很多团队愿意花大力气调模型、调 prompt却不愿意在数据清洗上多花一天时间。但实际效果往往是数据清洗做好一点检索准确率提升 20% 到 30%比换一个更大的模型还管用。我自己的习惯是每接入一批新数据先抽样 20 到 30 个文件人工看一遍解析结果。重点看标题层级对不对、段落边界准不准、特殊字符清没清干净、代码块有没有被拆散。这几个问题解决了后面的检索和生成才有意义。还有一个建议解析和切片的结果一定要持久化不要每次检索都重新解析。解析是 CPU 密集型操作重复解析浪费资源。把 chunk 和元数据存到数据库里检索时直接查效率高得多。后续我还会继续写 PDF 解析、表格提取、多模态数据导入这些更复杂的场景。txt 和 Markdown 是基础基础打牢了复杂的格式无非是多几层结构识别和噪声过滤。