1. 为什么RAG的数据导入阶段直接决定检索效果上限
做RAG(检索增强生成)项目的人,十有八九会在模型选型、Embedding调优、向量库搭建上反复折腾,却很少有人认真对待前面那一步——数据怎么进来、怎么解析、怎么结构化。我见过太多团队,把一堆txt文档直接丢给切片器,切完塞进向量库,然后对着检索结果骂模型效果差。说句不好听的,那不是模型的问题,是你喂给它的饲料本来就是一团浆糊。
先说清楚RAG的完整链路:原始文档 -> 解析与清洗 -> 结构化解 -> 切片(Chunking)-> 向量化 -> 检索 -> 注入Prompt -> 生成回答。这条链路上,前两步(解析清洗、结构化解)几乎没有人愿意花时间深究,但它们恰恰决定了后面所有环节的天花板。向量化讲究“语义密度”,检索讲究“相关性召回”,可如果你的切片里一半是乱码、页眉页脚、重复空白,另一半是把两个无关段落硬切在一起,那检索阶段再怎么调参也找不回丢失的语义边界。
这也是我想写这个系列的原因。标题里写了“从txt到Markdown”,意思是这一篇先解决最基础的场景:你手上有一大堆txt文件——可能是爬虫抓的网页转文本、老旧系统的导出记录、OCR输出的原始文本、甚至是小说网站下下来的整本书——我们要把这些杂乱的非结构化文本,变成干净的、带层级结构的Markdown格式,为后续切片和向量化铺路。
为什么Markdown是RAG场景下的理想中间格式?理由很简单:Markdown本身就是一种轻量级结构化表示,它有明确的标题层级(#/##/###)、列表、表格、代码块、引用块,这些元素天然就是“语义边界”。切片器可以顺着标题把文档切成有逻辑的块,向量化之后检索到的片段自带上下文结构,大模型在生成回答时也能更容易判断这段内容的来源和定位。
需要用这套流程的人,我总结下来大致三类:一是正在搭建企业知识库问答系统的工程师,手里有大量遗留txt数据;二是做爬虫和内容聚合的个人开发者,需要把网页或文本批量结构化;三是对RAG原理感兴趣、想亲手跑通全流程的爱好者。这篇文章会从实操角度,把txt读取、编码识别、清洗、结构化转换、切片这几个环节一个个拆开讲,附带可以直接改来用的Python代码。
顺便说一句,这一篇是系列的第一篇,后面我会继续写PDF、Word、HTML表格这类复杂格式的处理,以及图片型文档的OCR接入。但不管你最终要处理什么格式,txt这一关的清洗与结构化思路都是地基,值得先花时间夯实。
2. txt数据读取与编码识别:第一道坎比想象中深
2.1 编码问题:你永远猜不到用户手里的txt是什么编码
txt文件大概是所有格式里最“朴实无华”的,但也正因如此,它几乎没有自带任何关于自身编码的元信息。UTF-8、GBK、GB2312、BIG5、UTF-16——你拿到的txt文件可能是其中任何一种。最常见的悲剧是:用Python的open()直接以UTF-8读取一个GBK编码的文件,程序不报错,但输出全是�这种替换字符;更糟的是有些编辑器会“帮你”转换多次,最后文件里混了多种编码的残留,字符乱成一团。
我自己处理过最离谱的一个文本文件,同一个文档里前半段是UTF-8,后半段是GBK,据说是当年某个导数据脚本分批次写入导致的。这种情况下,任何单一编码方案都救不了,只能靠分段检测。
处理编码问题的标准姿势是两步走:
- 用编码检测库探测文件最可能的编码;
- 以探测结果打开文件,读取后统一转换为UTF-8存储到干净的中转结构里。
常用Python库有两个:chardet和charset-normalizer。chardet是老牌工具,蘑菇街开源,识别率高但速度慢;charset-normalizer是后起之秀,速度快,而且作者声称对中文场景做了优化。实测下来,处理中文txt时charset-normalizer的准确性确实不差,而且性能优势明显,处理大文件时差别能到几倍甚至十几倍。
下面是我长期在用的一个读取函数,它同时处理了BOM(字节顺序标记)和编码探测:
import charset_normalizer from pathlib import Path def smart_read_text(file_path: str) -> str: """ 通用txt读取:先探测编码,带BOM则去除BOM,统一返回UTF-8文本 """ raw_bytes = Path(file_path).read_bytes() # 优先处理BOM,有BOM就按BOM声明编码读,最可靠 if raw_bytes.startswith(codecs.BOM_UTF8): text = raw_bytes.decode("utf-8-sig") elif raw_bytes.startswith(codecs.BOM_UTF16_LE) or raw_bytes.startswith(codecs.BOM_UTF16_BE): text = raw_bytes.decode("utf-16") else: # 没有BOM,走编码探测 detected = charset_normalizer.from_bytes(raw_bytes).best() text = str(detected) return text注意一个细节:utf-8-sig这个编码方案读取带BOM的UTF-8文件时,会自动去掉头部的\ufeff字符。很多人用utf-8直接读,结果文本最前面多了一个肉眼看不见的零宽字符,喂给模型后经常在检索结果里制造一个幽灵token,排查半天找不到原因。
2.2 大文件分批读取:一次性读入内存的隐患
有一种常见场景是处理几MB的小文档,这种直接read_bytes()没有任何问题。但如果你要处理的是整本小说级txt(几十MB甚至上百MB),或者一个目录下几千个txt,就得考虑内存和数据读入的稳定性。
我建议做两层防护:
第一,对单文件设置读取上限,比如超过50MB的文件不一次性全量读入,而是按行或按固定大小分块处理。原因很简单:清洗和结构化阶段往往需要正则做全局替换,大文件一次性展开会导致内存暴涨,在服务器环境里容易触发OOM。
第二,在读取阶段就做行数统计和基础的“文件健康检查”,比如检测空文件、检测全是二进制乱码的文件、检测首行是否就是异常数据(比如爬虫没清理干净的HTML标签残骸)。这些检查会在后面的清洗阶段省你大量时间。
一个简单的健康检查示例:
def inspect_txt_file(file_path: str) -> dict: text = smart_read_text(file_path) lines = text.splitlines() return { "path": file_path, "size_kb": Path(file_path).stat().st_size / 1024, "line_count": len(lines), "char_count": len(text), "has_bom": text.startswith("\ufeff"), "contains_null": "\x00" in text, "first_5_lines": lines[:5], }contains_null这个字段是很多人忽略的。有些工具导出的txt会在行尾、字段间掺杂\x00空字符,这种文件直接进清洗流程,正则很容易匹配不到预期内容,切片时会把一个完整段落拦腰截断。早发现、早处理,比事后排查强得多。
2.3 文件编码错误导致的“假乱码”如何二次抢救
更让人头疼的是乱码之后再次“加工”过的文件。举个例子:一个原本是GBK编码的txt,被人用UTF-8编码方式打开后另存为,文件里的汉字被“翻译”成了一堆å¼这种垃圾字符。这种乱码有个特点:它是可逆的,只要你用正确的编码“反向翻译”回去,就能恢复原始文本。
我把这个场景做成一个恢复函数,放在脚本里,当正常清洗之后发现文本里含大量Ã、Â、â€这类异常字符时自动触发:
def repair_mojibake(text: str) -> str: """ 处理UTF-8文本被错误按Latin-1读出后再保存,导致的二次乱码。 典型特征: 文本中出现å¼这类字符组合。 """ if text.count("Ã") > 0 or text.count("Â") > 0 or text.count("â€") > 0: try: return text.encode("latin-1", errors="ignore").decode("utf-8", errors="ignore") except Exception: return text return text这个函数的核心思路是:错误用latin-1编码解释UTF-8字节流,再把得到的字节流正确解码为UTF-8。这不是什么高深技术,就是一个编码“乾坤大挪移”,但非常实用。遇到那种怎么都读不对的乱码txt,先用这个函数碰运气,能救回来一大半。
3. 文本清洗与噪声过滤:决定切块质量的隐性工序
3.1 清洗不是在“整理”,而是在保护语义边界
很多人觉得清洗就是去掉空行、去掉空格,随便写个正则搞定。我实际做下来发现,清洗的目标是保护文本的语义边界。一个切片器判断“这段应该单独作为一个块”的依据,除了显式的标题之外,很大一部分看的是空行、换行、段落完整性。如果原始文本里到处都是凌乱的双换行、中间夹着无意义的URL、时间戳、页眉页脚,切片器就会在错误的位置断开,把上下文的连贯性破坏掉。
我用Actual的视角总结清洗至少要覆盖这几类噪声:
| 噪声类型 | 常见形态 | 对RAG的影响 |
|---|---|---|
| 控制字符 | \x00、\x01、\x08等 | 规则匹配失效,切片断裂 |
| 零宽字符 | \ufeff、\u200b、\u200c | 向量化时产生多余空位,降低语义密度 |
| 连续空白 | 大量空格、tab、全角空格 | 干扰分词,浪费token |
| 爬虫残留 | HTML标签、&等实体、JS脚本 | 高噪声,扰乱向量 |
| 页眉页脚 | "第1页"、网站名、"版权所有" | 重复内容污染检索结果 |
| 孤立标点/装饰线 | ———、======、··· | 会产生无意义chunk |
3.2 一套可复用的清洗规则与Python实现
我的清洗脚本按顺序执行以下步骤,顺序非常重要,乱序会导致某些规则互相干扰。比如先移除控制字符再做HTML实体解码,避免解码出来的新字符又混入后续处理。
import re import html def clean_text(raw_text: str) -> str: text = raw_text # 1. 去除BOM零宽字符 text = text.replace("\ufeff", "").replace("\u200b", "").replace("\u200c", "") # 2. 去除控制字符(保留换行和tab) text = re.sub(r"[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]", "", text) # 3. HTML实体解码(处理& <等) text = html.unescape(text) # 4. 移除HTML标签残留 text = re.sub(r"<[^>]+>", "", text) # 5. 统一换行符 text = text.replace("\r\n", "\n").replace("\r", "\n") # 6. 合并连续空行(保留段落间的一个空行) text = re.sub(r"\n{3,}", "\n\n", text) # 7. 清理行首行尾多余空白 lines = [line.strip() for line in text.split("\n")] text = "\n".join(lines) # 8. 去除页眉页脚常见的无效行(可根据实际文件定制) text = re.sub(r"(?m)^\s*(第\s*\d+\s*页|下一页|上一页)\s*$", "", text) # 9. 去除装饰性分割线 text = re.sub(r"(?m)^\s*[-—=_*]{4,}\s*$", "", text) # 10. 再次压缩多余空行 text = re.sub(r"\n{3,}", "\n\n", text) return text.strip()看到第8步和第9步时你可能觉得“这也太定制了”。没错,这两步属于“项目专用规则”。真实项目里不会有万能清洗正则,因为你处理的文本类型决定了页眉页脚长什么样。最稳妥的做法是先跑一轮基础清洗,然后抽样打印100行清洗后的文本,肉眼看看还剩哪些烦人的重复模式,再针对性加正则。我在做爬虫数据清洗时,至少有一半的规则都是这样“看”出来的。
3.3 清洗到什么时候算够?用“切片试切”来验证
清洗没有绝对标准,但有可量化的验证手段。我会对清洗前后的文本各做一次按空行/段落切分,统计两个指标:平均块长度、块长度的变异系数。
如果一个规则执行后,块长度变异系数从3.2降到了1.8,说明文本分布变得更整齐,语义边界变清晰;如果几乎没变化,说明规则可能没作用到点上,或者命中的噪声本来就不影响切片结构。
另一个更直观的验证是“圆润度检测”:把清洗后的文本按每段100字切成滑动窗口,随机挑30个窗口,人工阅读判断“这段看起来像一个完整的话题”。如果超过20%的窗口读起来像是被腰斩的段落,说明清洗规则还不够,需要继续加码。
很多人会在这里停下来,觉得“文本干净了,可以喂给模型了”。但干净只是第一步,下一步的结构化转换才是从“文本”升级为“可检索语义单元”的关键。
4. 从txt到Markdown的结构化转换:让大模型真正读懂你的文档
4.1 为什么非要Markdown?直接给txt不行吗
这可能是整个系列里被问最多的问题。重复说明一下:不是不能给txt,而是给txt之后的效果天花板很低。
原始txt是一个扁平的字符流,它的段落之间没有层级关系。你想象一下一本没有目录、没有章节标题、只有连续文字的书——人读起来都会觉得吃力,指望向量检索在它上面召回高质量的上下文块,更难。
Markdown则不同,它给了你一套轻量的“语义标签”:#、##、###代表标题层级,-、1.代表列表,|代表表格,```代表代码块。这些标签既简单到任何解析器都能处理,又强大到可以表达大多数文档的层级结构。对RAG而言,最有价值的是标题层级,因为标题天然定义了“主题”,而主题就是最理想的切片边界。
举一个具体的例子,同样是这段内容:
第一章 产品概述 1.1 设计理念 本产品致力于解决中小企业...转成Markdown后是这样的:
# 第一章 产品概述 ## 1.1 设计理念 本产品致力于解决中小企业...切片器看到#和##之后,可以立刻构建出一棵“标题树”,知道1.1 设计理念是产品概述的下级。检索到1.1的内容时,这段内容的前缀路径就是“产品概述 > 设计理念”,这个路径可以作为元数据传给大模型,让回答更有上下文感。
4.2 规则识别 vs 大模型辅助:两条路线怎么选
从txt到Markdown的转换,业界主要有两条路线:规则识别和LLM辅助。两者的取舍不是“谁的准确率高”,而是“在什么场景下更靠谱”。
规则识别适合结构化特征明显的文档,比如论文、技术手册、规章制度。它们有明确的章节编号(第一章、第2章、1.1)、明确的列表符号(一、二、三;1. 2. 3.)、明确的表格线。这些特征写正则就能识别,速度快、可解释、成本几乎是零。
LLM辅助适合自由文本,比如访谈记录、会议纪要、小说片断。这类文档没有显式章节编号,得靠“语义理解”来划分主题。让大模型一次性读一段文本,输出建议的Markdown结构,效果很好,但如果你的语料量是几十万字,那调用成本和不稳定性会让你头皮发麻。
我的项目里通常是两条腿走路:先用规则把所有能识别的结构特征批量转换掉,把转换率跑高;剩下那些规则死活识别不了的段落,再抽出来交给LLM处理。这个策略在成本和质量之间取得了很好的平衡。
4.3 从txt到Markdown的规则转换实操
下面贴一个我用得非常顺手的章节标题识别函数,专门处理中文文档常见的章节命名方式:
def detect_heading(line: str, current_level: int) -> str | None: """ 根据行内容判断是否应该转为Markdown标题,返回markdown标题行。 current_level用于处理 1.1 -> 一级 这类相对层级。 """ stripped = line.strip() # 匹配 "第一章" "第2章" "第五章" 等 chapter_match = re.match(r"^第[一二三四五六七八九十百0-9]+[章节部分篇]", stripped) if chapter_match: return f"# {stripped}" # 匹配 "1.1" "2.3.4" "10.2" 等数字编号 section_match = re.match(r"^\d+(\.\d+){1,3}\s+\S", stripped) if section_match: # 根据点号数量推层级 parts = stripped.split() num_part = parts[0] dots = num_part.count(".") level = min(dots + 1, 6) # markdown最多6级 return f"{'#' * level} {stripped}" # 匹配 "一、" "二、" 等中文序号 cn_num_match = re.match(r"^[一二三四五六七八九十]{1,3}[、.,]\s*\S", stripped) if cn_num_match: return f"## {stripped}" return None如果你处理的是英文文档,把Chapter、Section、1.1 Introduction之类的正则补进去,思路完全一样。
有了标题识别,转换流程就清晰了:
def txt_to_markdown(raw_text: str) -> str: md_lines = [] for line in raw_text.split("\n"): heading = detect_heading(line, current_level=None) if heading: md_lines.append(heading) elif line.startswith(" - ") or re.match(r"^\s*[-*]\s+", line): md_lines.append(line) elif re.match(r"^\s*\d+\.\s+", line): md_lines.append(line) else: md_lines.append(line) return "\n".join(md_lines)这里必须在清洗之后再调用txt_to_markdown,否则噪声字符会让正则匹配失败。我遇到过最典型的翻车案例:因为少了第2节的零宽字符清理,所有标题行首都有\u200b,导致^第这个锚点永远匹配不上,整个文档一个标题都没转换出来。所以顺序一定不能乱。
4.4 表格与代码块的识别:规则方法和注意事项
处理技术类txt时,表格和代码块是两块硬骨头。文本形式的表格在原始txt里通常长这样:
名称 价格 库存 苹果 3.5 120 香蕉 2.0 80这种对齐格式看起来可以切分,但问题在于txt格式的表格没有“分隔符”概念,完全依赖空格对齐。规则方案可以用“连续多个空白作为列分隔”来近似处理:
def detect_tabular_block(lines: list[str], start_idx: int) -> list[str]: """ 判断从start_idx开始的连续几行是否构成表格。 判断依据:至少2行,且每行都有连续2个以上空格作为列分隔。 """ table_lines = [] for i in range(start_idx, min(start_idx + 20, len(lines))): line = lines[i] if line.strip() and re.search(r"\s{2,}", line) and "|" not in line: table_lines.append(line) else: break if len(table_lines) >= 2: return table_lines return []转出来的Markdown表格要正确处理列数对齐:
def lines_to_md_table(table_lines: list[str]) -> str: rows = [] for line in table_lines: cols = re.split(r"\s{2,}", line.strip()) rows.append(cols) max_cols = max(len(r) for r in rows) # 统一填充列数 rows = [r + [""] * (max_cols - len(r)) for r in rows] md = [] md.append("| " + " | ".join(rows[0]) + " |") md.append("|" + "---|" * max_cols) for r in rows[1:]: md.append("| " + " | ".join(r) + " |") return "\n".join(md)代码块识别相对简单些,关键是识别“缩进代码”和“围栏代码”两种模式。txt里的代码通常以4空格或tab缩进开头,连续多行如此,可以考虑转成围栏代码块:
def convert_code_indent(text: str) -> str: lines = text.split("\n") result = [] in_code = False for line in lines: if re.match(r"^( {4}|\t)", line) and not in_code: result.append("\n```\n" + line) in_code = True elif not re.match(r"^( {4}|\t)", line) and in_code: result.append("\n```\n" + line) in_code = False else: result.append(line) return "\n".join(result)这块细节比较多,具体项目里还得分语言来定缩进阈值,例如Python代码动不动就是4空格缩进,但要是你处理的是配置类文本,可能整篇都缩进,转出来一个超级代码块。所以“缩进即代码”的规则要谨慎,我的做法是设置最小行数阈值(比如至少连续3行才转),避免误伤正文。
4.5 为什么要手动控制转换率:过犹不及的结构化
这是我想特别提醒的一点:结构化转换不是越彻底越好。
我曾经拿着一个规章制度文档测试,把所有列表项都转成Markdown列表,把所有缩进段落都转成引用块,结果文档“结构感”很强,但检索效果反而变差了。原因在于,过度结构化的文本会把原本连贯的语义单元切得过碎,向量化时每一个小块都缺少上下文,检索召回时命中率升了,但回答的上下文连贯性跌了。
实操中我给自己设了两条原则:
- 标题层级必然转换,因为这是最强的语义边界;
- 列表、表格、代码块只有在“批量出现且格式重复”时才转换,零散的偶尔出现的保持原样。
说白了,你要让解析器找到文档的“骨架”,而不是做一个“全格式装备大师”。骨架是检索想要的,全格式是排版想要的,两者目标不同。
5. 结构化之后的切片策略与元数据设计
5.1 从“按固定字数切”到“按标题树切”
很多人做切片,第一反应就是“每500个token切一块”。这个思路最简单,效果也最普通,因为它完全无视了语义边界。一段被Word硬切成两半的句子,在向量化时会产生两个残缺的语义单元,检索时无论命中哪一半,回答质量都要打折扣。
结构化转换成Markdown之后,我们就可以做“标题感知切片”。算法逻辑很简单:按Markdown的标题层级构建一棵树,每个叶子节点是一段没有下级标题的正文,切片时从根到叶递归,直到切出的内容块达到目标长度上限;如果一个叶子节点本身就超长,再用滑动窗口细切。
这个思路的代码实现很清晰:
class MarkdownChunker: def __init__(self, max_chunk_size: int = 800, overlap: int = 100): self.max_chunk_size = max_chunk_size self.overlap = overlap def build_tree(self, md_text: str): """把markdown文本按标题层级解析成树结构""" tree = [] stack = [] # (level, text, children) for line in md_text.split("\n"): heading_match = re.match(r"^(#{1,6})\s+(.*)", line) if heading_match: level = len(heading_match.group(1)) text = heading_match.group(2).strip() node = {"level": level, "text": text, "children": []} while stack and stack[-1]["level"] >= level: stack.pop() if stack: stack[-1]["children"].append(node) else: tree.append(node) stack.append(node) elif line.strip(): # 正文段落,贴到当前节点下 content = line.strip() if stack: stack[-1].setdefault("content", []).append(content) else: # 无标题的纯文本段落 tree.append({"text": "", "level": 0, "content": [content], "children": []}) return tree def chunk_from_tree(self, node, prefix_path=""): """递归遍历树,输出切片""" chunks = [] path = f"{prefix_path} > {node['text']}" if node["text"] else prefix_path content = "\n".join(node.get("content", [])) # 如果有内容,先并入当前节点 if content: if len(content) <= self.max_chunk_size: chunks.append({"path": path, "content": content}) else: # 超长内容,用滑动窗口切 chunks.extend(self._sliding_window(path, content)) # 递归子节点 for child in node.get("children", []): chunks.extend(self.chunk_from_tree(child, path)) return chunks def _sliding_window(self, path: str, content: str): """对超长文本用重叠窗口切分""" chunks = [] start = 0 while start < len(content): end = start + self.max_chunk_size chunks.append({"path": path, "content": content[start:end]}) start = end - self.overlap return chunks5.2 元数据:检索之后最有价值的信息
切片的时候,别忘了把元数据一起带上。很多RAG项目失败,不是检索不到,而是检索到了但回答时缺乏上下文深度。元数据的作用就是给切片“贴标签”,让大模型在生成回答时知道这段内容来自哪里、属于哪个章节、和哪个父主题相关。
我最常用的元数据字段包括:
| 字段 | 来源 | 用途 |
|---|---|---|
| file_name | 文件名 | 标识来源 |
| file_path | 相对路径 | 溯源定位 |
| title_path | 从根到叶的标题路径 | 语义上下文 |
| chunk_index | 块序号 | 排序去重 |
| chunk_hash | 内容哈希 | 去重 |
| word_count | 字数 | 检索权重调节 |
这里有一个非常实用的技巧:把title_path(标题路径)拼在每个切片文本的最前面,再交给向量化。这样做有两个好处:一是向量化模型会把“产品概述 > 设计理念”这些语义路径也编码进向量里,检索时可以更精确地区分同名段落;二是注入Prompt时,带上标题路径能让大模型更清晰地理解上下文来源。
5.3 重叠窗口的参数怎么调:我的一手经验
滑动窗口切分时,overlap参数的选择直接影响检索效果。我做过一批对比实验,用同一份文档,在Embedding模型不变的情况下,调整window和overlap,检索出来的top-5命中率有10%左右的波动。
经验值是这样的:
- 如果你的文档是技术手册类,内容密度高,
window可以设小一点(600-800字符),overlap设100-200字符; - 如果你的文档是小说、访谈、叙事类,语义连续性更重要,
window调大到1000-1200字符,overlap也提升到200-300字符; - 如果你的文档里有大量列表和表格,切片时建议把表格行和正文分隔开,不要把表格切散,否则检索回来的片段永远是半个表格。
另外还有一个容易踩坑的点:计算切片长度时,很多代码直接数英文单词量,但RAG检索中文文档时,建议按“字符数”或“中文字数”来切。因为中文里一个汉字语义密度远高于一个英文单词,按词数切会导致中文块过长、英文块过短。我的脚本统一用字符数,并用len(text)判断。
5.4 父子切片:解决“检索粒度”和“上下文粒度”的冲突
上面的“标题树切分”解决的是“一个主题一块”,但实际场景里还存在一个经典矛盾:小粒度切片检索精度高,但不带上下文;大粒度切片上下文丰富,却容易捡到无关内容。
一个工程化的解决方案是“父子切片”(Parent-Child Chunking):把文档先切成较大的父块(比如整个章节),再从父块内部切成若干小的子块(比如段落),向量化时只向量化子块,但检索命中某个子块后,把它的父块内容一起丢给大模型。这样既满足了检索精度,又保证了回答上下文。
实现上,只需要在构建切片时,让每个子块带一个parent_id指针,父块单独存一份原始文本索引。检索时先查子块,再根据parent_id回表拿父块。这个方案在实践中效果非常明显,值得在项目中一试。
6. 实测效果对比与继续深入的方向
6.1 三种方案的真实对比
把我在这篇文章里讲的流程,放到一个真实的小型知识库上做了个对照测试。语料是100份技术手册类txt文档(约80万字),问的问题五花八门,从“某配置项的值”到“某章节的设计思路”都有。
三个对照组:
- A组:原始txt,直接按固定500字符无重叠切分;
- B组:清洗后的txt,按1500字符+200重叠切分;
- C组:转换成Markdown后,按标题树+元数据路径+父子切片(子块600字符,父块为章节)。
评估指标用了“人工判断题答案是否完全可定位到目标章节”的Top-1命中率:
| 组别 | Top-1命中率 | 回答完整度(人工打分) | 平均检索耗时 |
|---|---|---|---|
| A组 | 42% | 2.1/5 | 210ms |
| B组 | 61% | 3.3/5 | 230ms |
| C组 | 83% | 4.5/5 | 260ms |
C组多出来的几十毫秒是在元数据拼装和父子回表上,完全可以接受。而Top-1命中率的提升是断崖式的。所以我不太建议在RAG项目里跳过数据解析和结构化这个阶段——你后面花再大力气调Embedding模型,效果上限也就摆在那个位置。
6.2 进阶方向:从txt到其他格式,你还会遇到什么
这篇文章解决了txt,但真实RAG项目里,数据源远不止txt。根据我的经验,你会遇到几个典型的“硬茬子”:
PDF是最难处理的格式。PDF表面看起来有段落、有表格,但它内部是“排版坐标”,不是“语义结构”。扫描版PDF甚至只有图片,文字都得靠OCR现捞。处理PDF的常见路线是先转成纯文本或Markdown,再套用本文的清洗和结构化流程。好消息是,现在有pypdf、pdfplumber、marker这类工具,能帮你把PDF转成接近Markdown的中间格式。
Word文档(docx)反而是相对友好的,因为docx本质就是一个包含结构信息的XML压缩包,用python-docx可以比较准确地提取出标题、段落、表格。但docx的难点在于样式乱用——有人把正文加粗当标题,有人用首行缩进表示层级,规则识别经常被带偏。
HTML爬取数据的问题在于HTML的结构标签(div、span)没有语义,需要结合页面结构和正文提取算法(比如trafilatura、readability-lxml)先转成Markdown。
另一个方向是多模态。如果你的知识库里还有图片、扫描件、带图表的文档,那就要考虑OCR和视觉模型了。比如用PaddleOCR把扫描件识别成文本,再把图片放到专门的图床并用图片描述文本作为向量化内容。这一块内容已经横向扩展到多模态RAG,是另一个大话题。
6.3 系列后面的内容安排
这一篇写的是“通用文本与结构化解”的基础链路,后面我会按这个方向继续深入:
- 第二部分:PDF数据接入——解析工具选型、扫描版OCR接入、复杂表格提取;
- 第三部分:HTML/网页批量转Markdown——爬虫采集后的正文提取与去噪;
- 第四部分:docx与老旧格式——常见办公文档的结构化出路;
- 第五部分:清洗与切片的质量评估——如何自动化地评估你的RAG数据管线。
每篇都会采用和这篇一样的思路:先讲清楚“为什么这么做”,再给可以直接上手的代码和参数经验,最后附上我踩过的坑。数据解析这个环节在整个RAG项目里的存在感不强,但它一旦掉链子,后面所有环节都在做无用功。如果你也有过“模型没问题、数据拖后腿”的体验,应该能懂我说的这些有多重要。
最后分享一个我自己的习惯:每次拿到新的数据源,我会先写一个“数据巡检脚本”,把上面提到的inspect_txt_file、clean_text、txt_to_markdown串起来,跑完只看三样东西:清洗后的字符数损失率、标题识别数量、切片平均字数。这三个数字能快速告诉我这批数据的“健康度”,而不是等到检索质量崩了再去倒查数据管线。你如果从头搭RAG,这个习惯越早养成越好。