
上个月帮一个客户调 RAG demo文档倒进去之后效果惨不忍睹问合同条款答出来的是另一份文档的报价问型号对应价格模型直接编了个数字。查了一圈最后定位在导入阶段——他们导入的是 txt一份从 PDF 转出来的纯文本章节标题、表格、编号全被压成平铺的字符串。不是模型不行是数据进库之前就烂了。今天这篇是“RAG 数据导入与解析全攻略”的第一篇只讲一件事怎么把散装 txt 处理成带结构的 Markdown顺便把“数据导入与解析”这一步背后真正的逻辑讲透。不管你是准备用 LangChain、LlamaIndex 还是手搓 RAG这层基础都躲不开——你在导入阶段省掉的功夫后面检索阶段都会加倍讨回来。这篇适合正在搭 RAG 知识库、但检索质量一直上不去的同学也适合那些把 txt 当万能格式、却不知道它到底丢了多少结构信息的人。1. 为什么“数据导入与解析”是 RAG 的隐形瓶颈1.1 先看清 RAG 的完整链路RAG 的标准流程大致是文档导入 → 解析清洗 → 分块 → 向量化 → 写入向量库 → 检索 → 重排 → 交给大模型生成。很多人把注意力全砸在模型选型、Prompt 调优上却忽略了一个事实检索质量的上限在数据入库那一刻就定死了。向量化决定“能不能找到语义相近的文本”分块决定“找回的文本边界是否完整”而解析清洗决定“分块拿到的是不是干净、有语义边界的文本”。三层环环相扣最底层的解析出问题上层做得再花哨也白搭。我接触过的 RAG 项目里至少一半的“幻觉”案例根子不在大模型而在召回文本本身包含冲突信息或截断信息。比如把“不含税价”和“含税价”两段文字切进了同一个块检索时一块召回模型当然可能算错。解析这一步的价值就是尽量让每个被召回的小块内部语义自洽而不是把锅甩给模型。1.2 txt 的真正问题信息折叠与语义边界消失热搜里有一堆“shp转txt”、“bin文件怎么转换成txt”、“番茄小说怎么下载成txt”这样的词大家习惯把 txt 当传输中间格式但它恰恰是最不适合直接入库的中间格式。txt 本身没有结构层没有标题级别、没有列表层级、没有表格概念、没有代码块边界。原本在 Word 或 PDF 里用大纲表达清楚的“第 1 章 / 1.1 节 / 1.1.1 小节”转成 txt 后全部变成扁平字符串标题和正文混在一起表格变成一行行空格对齐的碎片。这不是格式美观问题而是语义边界丢失问题。一段小说文本里章节标题是读者定位叙事的锚点一份产品手册里“技术参数”标题决定了后面那张表格的语境。一旦标题和正文被同样对待分块器就只能靠字符数硬切结果就是一个 chunk 里有半个章节的标题、半张表格的三行、上一节的结尾和下一节的开头。这种混合块进向量库之后检索时语义不聚焦召回评分虚高但内容错位回答质量自然上不去。1.3 为什么偏偏选 Markdown 当结构化载体有人会问结构化解为什么不用 JSON 或者 XMLJSON 和 XML 确实结构化得很彻底但有两个问题第一它们是把“块”嵌套进标签里解析成本高人和模型读起来都费劲第二大多数文本天然不具备强 Schema硬套 JSON 会逼你发明一堆半真半假的字段反而掺入噪音。Markdown 的好处是轻量级、有层级、人类可读、大模型也熟。Markdown 用#表达章节层级用|表达表格用-表达列表用表达代码块。这些符号既是展示格式又是语义边界标注。分块器看到## 1.1就知道这里是一个新语义单元的开头看到| 价格 |就知道这是结构化数据应该整块保留。对 RAG 来说Markdown 是成本和收益最平衡的中间表示——这也是我把这一篇的落点定为“从 txt 到 Markdown”的原因。2. 从 txt 到 Markdown 的解析策略2.1 解析的本质不是换格式而是信息重建很多由 PDF 或网页另存为的 txt并不是“原始文本”而是已经过一次有损转换的产物目录页和正文叠在一起全角半角混用行尾有各种空白表格拆散了标题前的序号丢失。所以你要做的不是“把 .txt 后缀改成 .md”而是从扁平文本里把丢失的结构重新推断出来。这个心态很重要。我见过不少同学写完一段f.read()就扔给 splitter然后疑惑为什么 RAG 效果不好。真正的解析是先识别文件的编码和噪音再识别哪里是标题、哪里是正文、哪里是表格最后才把识别结果映射成 Markdown 语法。每一步都是对文本的“信息重建”。2.2 三层结构化字符层、块级层、属性层我把解析的目标拆成三层方便你对照自己的实现缺了哪层。第一层是字符层。包括 BOM 头的清理、\r\n和\r的统一换行、全角空格和全角标点处理、不可见控制字符剔除。这层不做干净后面所有正则都会被干扰。第二层是块级层。把文本切分成标题、段落、列表、表格、引用、代码块并标记它们的层级关系。这是 Markdown 转换的核心也是分块器最依赖的部分。第三层是属性层。给每个块打标签比如来源文件名、章节路径、语种、表格字段名这些属性最终会作为元数据随向量一起入库检索时可以用来过滤或上下文拼接。一句话总结字符层决定你能不能用块级层决定你分得好不好属性层决定你查得准不准。2.3 通用流水线编码探测、清洗、归一化、边界识别我常用的处理顺序是固定的每一步都有明确目的字节读取先做编码探测再按探测结果解码。中文文本常见的是 UTF-8、GBK/GB18030、UTF-16顺序错了直接乱码。清洗统一换行符去掉行尾空白把多个空行折叠成一个。注意“多个空行折叠”要放在“按行处理”之后避免丢掉段落间的真实分隔意图。归一化全角数字和英文转半角中文标点保留全角因为中文文本里全角引号是合法内容不能一刀切。边界识别用正则和启发式规则识别标题模式、表格行、列表项、引用块把连续同类行合并成一个块。映射到 Markdown把识别结果按“标题加井号、表格加竖线、列表加短横线”的规则输出最终得到一份干净的 .md 文件。这套流水线看着简单实际操作里每一步都有细节。比如编码探测我见过有人只测utf-8和gbk结果遇到 UTF-16 的 txt 直接放弃全库导完发现一批乱码文档检索质量崩得莫名其妙。2.4 结构化粒度选择纯度优先还是上下文优先结构化解完 Markdown接下来面临一个经典问题分块时是让每个块“纯”到只含一个三级标题下的内容还是保留父标题作为上下文前缀我的建议是默认用“标题路径前缀 小标题内容”的模式。即每个最低层级的块前面拼上它所属的一级、二级、三级标题路径构成h1 / h2 / h3的前缀再接正文。这样既保证了块内语义聚焦又没丢失这个块在整篇文档中的位置信息。比如检索到“价格1200元”时模型还能看到它属于“产品A / 配置B / 报价表”这个上下文而不是光秃秃一句价格。如果你追求极致纯度每个三级小标题单独成块也行但代价是检索时可能漏掉父标题的限定信息召回后需要额外做上下文拼接工程复杂度更高。权衡之下标题路径前缀是性价比最高的策略。3. 实操Python 实现 txt 到 Markdown 的通用解析3.1 工程结构下面这份代码我按“可直接抄作业”的粒度写不依赖重型框架只用到标准库加少量正则。整体分四个模块编码探测、清洗归一化、结构识别、Markdown 输出。你完全可以按自己的文件类型裁剪。from pathlib import Path import re def read_text_with_fallback(file_path: str) - str: raw Path(file_path).read_bytes() # 先处理 UTF-16通常带 BOM 或者大量 \x00 if raw[:2] in (b\xff\xfe, b\xfe\xff) or raw.count(b\x00) 0: for enc in (utf-16, utf-16-le, utf-16-be): try: return raw.decode(enc) except UnicodeDecodeError: continue # 常见中文文本顺序UTF-8 - GB18030 - Big5 - latin1 兜底 for enc in (utf-8-sig, utf-8, gb18030, big5, latin-1): try: return raw.decode(enc) except UnicodeDecodeError: continue return raw.decode(latin-1, errorsreplace)这里gb18030是国标全集向下兼容 GBK 和 GB2312遇到老系统导出的 txt 比单独试 UTF-8 保险得多。latin-1是兜底任何字节都能解码虽然结果可能是噪音但至少不会让整个流程崩溃。3.2 清洗与归一化def normalize_whitespace(text: str) - str: text text.replace(\r\n, \n).replace(\r, \n) lines text.split(\n) lines [line.rstrip() for line in lines] cleaned [] blank_count 0 for line in lines: if not line.strip(): blank_count 1 if blank_count 1: continue else: blank_count 0 cleaned.append(line) return \n.join(cleaned).strip() def normalize_fullwidth(text: str) - str: # 全角数字、英文字母转半角中文标点保留 # 节省篇幅只演示常见范围 result [] for ch in text: code ord(ch) if 0xFF10 code 0xFF19: # 全角数字 result.append(chr(code - 0xFEE0)) elif 0xFF21 code 0xFF3A: # 全角大写字母 result.append(chr(code - 0xFEE0)) elif 0xFF41 code 0xFF5A: # 全角小写字母 result.append(chr(code - 0xFEE0)) elif ch \u3000: # 全角空格 result.append( ) else: result.append(ch) return .join(result) def clean_text(text: str) - str: text normalize_whitespace(text) text normalize_fullwidth(text) # 去掉控制字符保留换行和制表符 text .join(ch for ch in text if ch or ch in \n\t) return text有几个细节值得注意。第一normalize_whitespace一定要在按行处理时同步折叠空行否则后续正则按空行切分段落时会拿到一堆空段。第二全角转半角只处理数字和字母中文标点不要动比如全角逗号、句号是合法内容转了反而破坏语感。第三控制字符过滤保留\n和\t因为后面识别表格可能依赖制表符不能提前删掉。3.3 标题层级识别这是整个结构化过程中最关键的一步。txt 里的标题没有#只能靠模式猜。我总结了几类最常见的标题写法中文数字序号一、概述、第一章 前言、一背景阿拉伯数字层级1 简介、1.1 安装、1.1.1 配置英文模式Chapter 1、Section 3纯文本标题短行、无句号结尾、后面紧跟空行或正文HEADING_PATTERNS [ (1, r^第[一二三四五六七八九十百千\d][章卷篇部].*$), (2, r^\s*[一二三四五六七八九十][、.](?!\d).*$), (3, r^\s*[(][一二三四五六七八九十][)].*$), (1, r^\s*(?:chapter|part|section)\s\d[\:\]?\s*.*$, re.IGNORECASE), (3, r^\s*\d(?:\.\d)*[、.]?\s\S.*$), ] def detect_heading_level(line: str) - int: # 跳过目录页干扰 if .... in line or … in line or ... in line: return -1 for level, pattern in HEADING_PATTERNS: if re.match(pattern, line.strip(), re.IGNORECASE if len(pattern) 2 else 0): return level return 0 def convert_to_markdown_headings(lines: list[str]) - list[str]: md_lines [] for line in lines: stripped line.strip() if not stripped: md_lines.append() continue level detect_heading_level(stripped) if level 0: md_lines.append(f{# * level} {stripped}) else: md_lines.append(line) return md_lines这套规则肯定不完美但能覆盖大多数规范文本。实战中我会加一步**“标题密度过滤”**如果一页 200 行里检测出 50 个“标题”那多半是目录页或书名页这种段落整体降级为正文。判断标准很简单——真实标题行下面的正文块通常有一定长度目录行后面跟着的往往是页码或圆点。3.4 表格识别与转换txt 里的表格有三种常见形态用|分隔、用制表符分隔、用多个空格对齐。第三种最恶心因为空格数量不固定我一般建议先尝试前两种第三种能识别多少算多少。TABLE_LINE_RE re.compile(r^\s*\|.*\|\s*$) TSV_LINE_RE re.compile(r^[^\t]\t[^\t](\t[^\t])*$) def is_table_line(line: str) - bool: return bool(TABLE_LINE_RE.match(line) or TSV_LINE_RE.match(line)) def convert_lines_to_markdown_table(table_lines: list[str]) - str: rows [] for raw_line in table_lines: raw_line raw_line.strip() if raw_line.startswith(|): cells [c.strip() for c in raw_line.strip(|).split(|)] else: cells [c.strip() for c in raw_line.split(\t)] rows.append(cells) if not rows: return col_count max(len(r) for r in rows) rows [r [] * (col_count - len(r)) for r in rows] out [] header rows[0] out.append(| | .join(header) |) out.append(| | .join([---] * col_count) |) for row in rows[1:]: out.append(| | .join(row) |) return \n.join(out)注意Markdown 表格要求表头行下面必须紧跟|---|分隔行否则渲染器不认。我上面把第一行当表头剩下的全当数据行。如果某份 txt 的第一行其实是列注释那就需要你根据字段名特征做判断比如第一个单元格是否形如“序号/名称/说明”。表格识别还有个大坑段落里的“价格100 | 数量2”也会被误判成表格。所以识别逻辑里要加连续行校验至少连续两行满足表格模式才启用转换。3.5 按标题分块输出把 Markdown 文本按标题层级分块并保留标题路径前缀def split_md_by_headings(md_text: str) - list[dict]: lines md_text.split(\n) blocks [] h1, h2, h3 , , current_buf: list[str] [] def flush(): nonlocal current_buf if not current_buf: return text \n.join(current_buf).strip() if text: blocks.append({ heading_path: /.join(p for p in [h1, h2, h3] if p), text: text, }) current_buf [] heading_re re.compile(r^(#{1,4})\s(.*)$) for line in lines: m heading_re.match(line) if m: flush() level len(m.group(1)) title m.group(2).strip() if level 1: h1, h2, h3 title, , elif level 2: h2, h3 title, elif level 3: h3 title current_buf.append(line) else: current_buf.append(line) flush() return blocks这个函数返回的每个块自带heading_path就是上一节说的“标题路径前缀”。向量化时你可以把heading_path直接拼进正文也可以单独存成 metadata检索后拼进 Prompt。两种方式都有效我个人推荐前者更稳因为向量本身已经包含结构上下文检索匹配时语义更聚焦。4. 踩坑实录与排查速查4.1 编码的坑UTF-16 文本被误解成乱码有一次导入政府公开的 txt 数据打开文件看前面几行正常后面全是NUL字符。排查发现文件其实是 UTF-16 编码且没有 BOM被系统默认按 ANSI 读取了。后来我在read_text_with_fallback里加了raw.count(b\x00) 0判断只要字节流里空字节占比过高就先试 UTF-16。这个方法对大部分 UTF-16 文件都有效成本极低。另一个常见的是GB18030 vs Big5。同一份繁体文档用 GB18030 解出来是乱码用 Big5 解出来正常。所以兜底顺序不能把 Big5 放在 GB18030 前面否则简体文档会被误伤。我的习惯是 GB18030 优先失败再 Big5。4.2 假标题和目录页干扰长文档转 txt 后最前面往往跟着一页目录里面的“第1章……3”被我的标题正则命中导致后面正文里的真实标题反而无法成块。处理方式有几种如果检测到连续多处“标题行 点线 页码”的模式就把这段整体标记为目录并跳过或者先按页码特征排除目录行——但 txt 里不一定保留页码所以更稳的办法是依赖“真实标题行后面跟正文”的上下文特征。我实际用的规则是一行被判定为标题后继续看下一行是否也是同类标题。如果连续三行都是“标题”那它们极可能来自目录页或大纲页整段降级处理。如果标题行后面紧跟非空正文行则认定为真实标题。这套上下文判断比单独看一行准得多。4.3 Markdown 转义问题把 txt 原样转成 Markdown 时正文里的特殊字符可能破坏结构。比如正文出现|符号刚好这行又被误判为表格行就会生成一个残缺表格比如正文里有反引号可能开启代码块。转换前做好两件事一是 Markdown 特殊字符先转义二是表格识别必须要求“连续两行 行内分割符数量一致”宁可漏识别不可错识别。漏识别顶多算普通行错识别会把整段内容变成错乱表格。4.4 表格数据串行我遇到过最典型的问题是源文件里的表格单元格本身包含换行。转成 txt 后一个单元格的文字被拆成两行识别时第二行被当成新表格行列数对不上。处理办法是不要看单行而是按“分隔符号一致且连续”的规则合并行并允许在合并后重新对齐列数。如果某行单元格数少于表头用空字符串补齐多于表头则多半是源数据里有嵌套换行需要把多余部分拼回上一行。4.5 问题排查速查表症状可能原因排查方向全文乱码编码探测顺序不对优先检查是否有 BOM 或 NUL 字节标题全部没分块标题正则没覆盖该文件风格手工抽看前 200 行补充模式多个章节被分进一块标题行被误判成普通文本检查标题是否带前导空格或特殊符号表格支离破碎源文件表格用空格对齐先转为 TSV再转 Markdown一个 chunk 同时出现多个话题标题路径前缀没拼进去打开 blocks 输出看 heading_path 是否为空检索出完全无关的段落清洗阶段把换行全删了检查是否把段落间换行合并成一行导致语义边界消失这张表我贴在了团队 wiki 里每次有人抱怨“RAG 效果不好”先跑这六个检查能解决大部分问题。5. 关于结构化、图片与知识库边界的澄清5.1 RAG 知识库到底能不能“存图片”几乎每次讲 RAG 数据导入都有人问知识库能不能直接存图片这里得说清楚一个概念主流 RAG 架构里的向量库存的是“嵌入向量”和元数据不是原始文件。图片要进入 RAG有两条路一是用多模态 embedding 模型比如 CLIP 类模型把图片转成向量和文本向量放进同一向量空间检索时文本也能匹配图片二是先对图片做 OCR 或 caption 生成把图片内容转成文字描述再走文本链路。第二种做法在工程上更常见因为它能让文本检索模型直接工作不需要额外维护多模态向量索引。但代价是信息损失图片里的版式、色彩、空间关系都丢了。所以别期待“把图片文件扔进知识库”就能被 RAG 使用至少要经过“转文字描述”或“多模态向量化”这一步。这和把 txt 转成 Markdown 是同一类思维——先做表示转换再做检索。5.2 普通 RAG、知识图谱、结构化知识库怎么选热词里出现了“kg知识库、rag知识库和结构知识库区分”这其实是做数据导入前必须想清楚的问题。普通 RAG 适合的场景是文档量大、语言表达灵活、答案藏在段落里。它像一个图书馆检索员帮你把相关段落搬到模型面前模型再组织语言。知识图谱GraphRAG适合的是实体和关系密集的数据比如“某公司投资了哪些公司”“这些公司之间什么关系”图结构天然能沿着边做多跳推理。结构化知识库SQL 或规范 Schema适合的是精确值查询比如“上季度销售额是多少”这类问题不适合扒段落适合直接在库里算。三者的导入逻辑完全不同。普通 RAG 要保语义边界知识图谱要抽实体关系结构化库要严格校验字段。如果你还没弄清需求就在那堆“结构化解”的功夫很可能白忙活。我的判断标准很简单如果用户的问题大多数是“XX是什么”“XX怎么做”选 RAG如果是“A 和 B 什么关系”“谁影响了谁”选图谱如果是“具体数值是多少”“按某字段统计”选结构化查询。当然现实项目往往是混合的这时候先做 RAG 再叠加图谱扩展通常是成本最低的起步路径。结尾按老规矩最后分享一点个人体会做数据导入这东西千万别想着一步到位。我现在的习惯是任何一批新数据入库前先随机抽 50 个文件跑一遍完整流程然后肉眼检查生成的 Markdown 输出——看标题层级对不对、表格有没有错位、分块边界是否合理确认没问题再全量导入。这个习惯救了我很多次因为格式识别的问题往往藏在某个完全没想到的文件里。这批 txt 处理完Markdown 只是第一步后面还有分块粒度、向量化、检索重排的细节。下一篇我准备讲讲怎么把不同来源的文档PDF、Word、HTML统一成同一套 Markdown 中间表示再挂到常见的 RAG 框架上跑通。如果你们在处理导入时遇到什么奇葩格式也欢迎在评论区把样例贴出来一起琢磨。