☰
RAG 数据导入与解析:从 txt 到 Markdown 的结构化重建指南
2026/10/5 5:16:45 网站建设 项目流程

上个月帮一个客户调 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 或者 XML?JSON 和 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 通用流水线:编码探测、清洗、归一化、边界识别

我常用的处理顺序是固定的,每一步都有明确目的:

  1. 字节读取,先做编码探测,再按探测结果解码。中文文本常见的是 UTF-8、GBK/GB18030、UTF-16,顺序错了直接乱码。
  2. 清洗:统一换行符,去掉行尾空白,把多个空行折叠成一个。注意“多个空行折叠”要放在“按行处理”之后,避免丢掉段落间的真实分隔意图。
  3. 归一化:全角数字和英文转半角,中文标点保留全角,因为中文文本里全角引号是合法内容,不能一刀切。
  4. 边界识别:用正则和启发式规则识别标题模式、表格行、列表项、引用块,把连续同类行合并成一个块。
  5. 映射到 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", errors="replace")

这里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 框架上跑通。如果你们在处理导入时遇到什么奇葩格式,也欢迎在评论区把样例贴出来,一起琢磨。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询