RAG 系统里最不起眼、但最容易翻车的环节,不是向量检索,也不是大模型选型,而是数据导入与解析。我做过不下十个知识库项目,几乎每一个在 demo 阶段跑得飞起,一上真实数据就出问题——PDF 里的表格变成一坨乱码、Markdown 的标题层级全丢、txt 文件里混着各种编码。检索效果差,十有八九不是 embedding 模型不行,而是喂进去的"料"本身就是碎的。
这篇主要聊 RAG 数据导入链路里最基础的一段:纯文本(txt)和结构化文本(Markdown)怎么解析、怎么切、怎么保住结构信息。别看这两种格式简单,它们恰恰是很多知识库的主力数据源——技术文档、产品手册、会议纪要、小说语料,大量都是 txt 或 Markdown。把这两种吃透,后面处理 PDF、Word、HTML 就有底子了。适合正在搭 RAG 知识库、被数据清洗折磨过的同学,也适合刚接触 RAG、想知道"数据到底怎么进库"的新手。
1. 为什么 txt 和 Markdown 值得单独拎出来讲
1.1 它们看着简单,坑却最隐蔽
很多人觉得 txt 就是纯文本,读进来直接切块就完事了。真上手才发现,一个 txt 文件可能藏着 GBK、UTF-8、UTF-8 BOM 三种编码,甚至同一个文件里中英文段落编码还不一致。你按 UTF-8 硬读,中文全变问号,切出来的 chunk 全是乱码,向量化之后检索命中率直接归零。
Markdown 更微妙。它表面是纯文本,实际上携带了层级结构——#是一级标题、##是二级、列表项、代码块、表格、引用块。这些结构信息对 RAG 极其宝贵:标题能告诉你这段内容属于哪个主题,代码块不该被从中间切断,表格拆散就失去意义。如果你把 Markdown 当普通 txt 一刀切,等于把一份带目录的书撕成等长的纸条,检索时根本拼不回上下文。
1.2 结构信息是 RAG 检索质量的隐形杠杆
我做过一个对比实验,同一份技术文档,两种处理方式:
| 处理方式 | 切块策略 | 检索命中率(Top3) |
|---|---|---|
| 粗暴切分 | 按 500 字符硬切 | 约 52% |
| 结构感知 | 按标题层级切,保留标题路径 | 约 81% |
差距接近 30 个百分点。原因很简单:结构感知切分让每个 chunk 自带"上下文标签"。比如一个 chunk 开头带着产品手册 > 安装指南 > 环境要求,检索时即使正文没出现"环境"这个词,标题路径也能帮上忙。这就是为什么 Markdown 的解析不能只做文本提取,必须把结构一起抽出来。
1.3 这一篇在整个导入链路里的位置
RAG 数据导入完整链路大致是:数据采集 → 格式解析 → 清洗 → 切块 → 元数据标注 → 向量化 → 入库。这篇聚焦"格式解析 + 切块"这两步,针对 txt 和 Markdown。后面还会涉及 PDF、Word、HTML 等复杂格式,但那些格式的解析思路,本质上都是从这两种基础格式延伸出去的——PDF 解析完往往也要转成 Markdown 再处理,所以先把地基打牢。
2. txt 解析:编码、清洗与切块的三道关
2.1 编码识别:别信"默认 UTF-8"
txt 文件最大的坑就是编码。Windows 上很多老文件是 GBK 或 GB2312,Mac 和 Linux 默认 UTF-8,还有些文件带 BOM 头。你如果无脑用open(file, 'r')读,Python 在部分平台会按系统默认编码走,结果就是乱码。
我的做法是先探测再读取,用chardet或charset-normalizer库自动识别:
from charset_normalizer import from_path def read_txt_safely(path): result = from_path(path).best() if result is None: raise ValueError(f"无法识别编码: {path}") text = str(result) # 去掉 BOM 头 if text.startswith('\ufeff'): text = text[1:] return textcharset-normalizer比chardet更新、维护更活跃,识别准确率也更高,尤其是中英混排的文件。实测下来,对 GBK、UTF-8、UTF-16 的识别基本没出过错。
注意:编码识别不是 100% 可靠,尤其是短文件或纯 ASCII 文件。建议在识别后做一次"可读性校验"——统计一下非 ASCII 字符里有多少是常见汉字,如果比例异常低,说明可能识别错了,需要人工介入或尝试备选编码。
2.2 清洗:哪些字符必须干掉
txt 文件里常见的脏数据包括:零宽字符(\u200b)、不间断空格(\xa0)、各种控制字符、连续空行、行尾多余空格。这些字符肉眼看不见,但会污染 embedding,让语义向量偏移。
我一般做这几步清洗:
import re def clean_text(text): # 统一换行符 text = text.replace('\r\n', '\n').replace('\r', '\n') # 去掉零宽字符和 BOM text = re.sub(r'[\u200b\u200c\u200d\ufeff]', '', text) # 不间断空格转普通空格 text = text.replace('\xa0', ' ') # 去掉控制字符(保留换行和制表) text = re.sub(r'[\x00-\x08\x0b\x0c\x0e-\x1f]', '', text) # 连续空行压缩成一个 text = re.sub(r'\n{3,}', '\n\n', text) # 行尾空格去掉 text = re.sub(r'[ \t]+\n', '\n', text) return text.strip()这里有个经验:不要过度清洗。有些同学喜欢把标点符号也统一了,把全角转半角,结果把中文的语义节奏破坏了。清洗的目标是去掉"机器噪声",不是改写内容。标点、语气词、口语化表达都是语义的一部分,保留它们反而有助于检索。
2.3 切块:固定长度 vs 语义边界
txt 没有天然结构,切块只能靠策略。最常用的是递归字符切分(RecursiveCharacterTextSplitter),按优先级依次尝试分隔符:段落 → 换行 → 句号 → 逗号 → 空格。这样能尽量在语义边界处断开。
from langchain.text_splitter import RecursiveCharacterTextSplitter splitter = RecursiveCharacterTextSplitter( chunk_size=500, chunk_overlap=80, separators=["\n\n", "\n", "。", "!", "?", ";", ".", " ", ""], length_function=len, ) chunks = splitter.split_text(clean_text)参数怎么定?chunk_size=500是个经验值,对应中文大约 250-350 字,能容纳一个完整段落或几个短句。chunk_overlap=80是为了防止关键信息正好卡在切分点上被切断,重叠部分让相邻 chunk 有上下文衔接。
但固定长度切分有个硬伤:它不理解语义。一段讲"安装步骤"的文字,可能被从中间切开,前半段在 chunk A,后半段在 chunk B,检索时只命中一个,答案就不完整。对质量要求高的场景,我会用语义切分——先按句子切,再用 embedding 计算相邻句子的相似度,相似度骤降的地方就是切分点。
from langchain_experimental.text_splitter import SemanticChunker from langchain_openai import OpenAIEmbeddings semantic_splitter = SemanticChunker( OpenAIEmbeddings(), breakpoint_threshold_type="percentile", breakpoint_threshold_amount=95, ) chunks = semantic_splitter.split_text(clean_text)语义切分效果好,但慢、费 token。我的建议是:长文档、结构松散的用语义切分;短文档、结构清晰的用递归切分。别一上来就全用语义切分,成本扛不住。
3. Markdown 解析:把标题层级变成检索的导航图
3.1 为什么不能把 Markdown 当 txt 处理
Markdown 的价值全在结构里。#到######六级标题构成了一棵文档树,列表、代码块、表格、引用块是叶子节点。如果你把它当 txt 硬切,会丢掉三类关键信息:
- 标题路径:这段内容属于哪个章节,检索时能提供额外语义线索
- 块类型:这是代码还是正文,代码块不该被切碎,表格拆开就废了
- 层级关系:子章节和父章节的从属关系,决定了 chunk 的归属
我见过最典型的翻车案例:一份 API 文档,每个接口用##标题,参数说明用表格。粗暴切分后,表格被切成两半,检索"某接口的参数"时,命中的 chunk 只有半张表,模型只能瞎编。
3.2 用 MarkdownHeaderTextSplitter 保住标题路径
LangChain 提供了MarkdownHeaderTextSplitter,专门按标题层级切分,并把标题路径写进每个 chunk 的 metadata:
from langchain.text_splitter import MarkdownHeaderTextSplitter headers_to_split_on = [ ("#", "h1"), ("##", "h2"), ("###", "h3"), ("####", "h4"), ] md_splitter = MarkdownHeaderTextSplitter( headers_to_split_on=headers_to_split_on, strip_headers=False, # 保留标题在正文里 ) md_chunks = md_splitter.split_text(markdown_content)切出来的每个 chunk 都带 metadata,比如:
{ "h1": "产品手册", "h2": "安装指南", "h3": "环境要求", "content": "### 环境要求\n\n- Python 3.9+\n- 内存 8GB 以上\n..." }这个 metadata 太有用了。检索时你可以把它拼进 chunk 的文本前缀,比如[产品手册 > 安装指南 > 环境要求] 环境要求:Python 3.9+...,让 embedding 把标题路径也编码进去。实测这个技巧能把检索命中率再拉高 10 个点左右。
3.3 代码块和表格:必须特殊保护
MarkdownHeaderTextSplitter只按标题切,不处理代码块和表格。如果某个章节特别长,标题切分后还是一个大 chunk,就需要二次切分。这时候要小心:代码块和表格不能被从中间切断。
我的做法是先识别出代码块和表格,把它们当作"原子块",切分时整体保留:
import re def protect_blocks(text): # 提取代码块,用占位符替换 code_blocks = [] def replace_code(m): code_blocks.append(m.group(0)) return f"__CODE_BLOCK_{len(code_blocks)-1}__" text = re.sub(r'```[\s\S]*?```', replace_code, text) # 提取表格(连续的 | 开头行) tables = [] def replace_table(m): tables.append(m.group(0)) return f"__TABLE_{len(tables)-1}__" text = re.sub(r'(\|.*\|\n)+', replace_table, text) return text, code_blocks, tables def restore_blocks(text, code_blocks, tables): for i, block in enumerate(code_blocks): text = text.replace(f"__CODE_BLOCK_{i}__", block) for i, table in enumerate(tables): text = text.replace(f"__TABLE_{i}__", table) return text切分完再还原。这样代码块和表格始终是完整的,不会被切碎。代价是可能出现超长 chunk,但比起信息丢失,我宁愿 chunk 大一点。
提示:如果代码块或表格本身超过 chunk_size,那就只能单独成块,并在 metadata 里标记
block_type: code或block_type: table,检索时可以针对性处理。
3.4 标题路径拼接:让每个 chunk 自带上下文
前面提到把标题路径拼进 chunk 文本,这里展开说下具体做法。切分后,每个 chunk 的 metadata 里有 h1、h2、h3 等字段,按层级拼成路径:
def build_context_prefix(metadata): parts = [] for key in ["h1", "h2", "h3", "h4"]: if key in metadata and metadata[key]: parts.append(metadata[key]) return " > ".join(parts) for chunk in md_chunks: prefix = build_context_prefix(chunk.metadata) chunk.page_content = f"[{prefix}]\n{chunk.page_content}"这样每个 chunk 开头都带着完整路径。检索时,即使正文没出现关键词,路径里的词也能帮上忙。比如用户问"环境要求是什么",chunk 正文可能只写了"Python 3.9+",但前缀[产品手册 > 安装指南 > 环境要求]里的"环境要求"直接命中。
4. 从 txt 到 Markdown:格式转换的取舍
4.1 什么时候该转,什么时候不该转
很多教程一上来就说"把所有格式统一转成 Markdown 再处理"。这话对了一半。转 Markdown 的好处是统一了结构表达,后续切分逻辑只用写一套。但转换本身会丢信息,尤其是从 PDF、Word 转过来的时候,表格和排版经常变形。
我的判断标准:
| 原始格式 | 是否转 Markdown | 理由 |
|---|---|---|
| txt(无结构) | 不转 | 转了也没结构,白费功夫 |
txt(有伪结构,如用===分隔) | 转 | 可以转成#标题,获得结构 |
| HTML | 转 | HTML 标签转 Markdown 成熟且信息损失小 |
| Word | 视情况 | 简单文档转,复杂排版保留原格式解析 |
| 谨慎 | 表格和公式转换损失大,建议先解析再决定 |
对 txt 来说,如果它本身有伪结构——比如用连续等号、短横线做分隔,或者用"第一章""1.1"这种编号——可以写规则转成 Markdown 标题,这样就能复用 Markdown 的解析逻辑。
4.2 伪结构识别:用正则把 txt 变成 Markdown
常见的 txt 伪结构有几种:
- 用
====或----下划线标记标题 - 用
第X章、第X节标记章节 - 用
1.、1.1、1.1.1数字编号 - 用全大写行标记标题
写一组正则把它们转成 Markdown 标题:
import re def txt_to_markdown(text): lines = text.split('\n') result = [] for i, line in enumerate(lines): stripped = line.strip() # 下划线标题:下一行是 === 或 --- if i + 1 < len(lines): next_line = lines[i+1].strip() if next_line and set(next_line) <= {'='} and len(next_line) >= 3: result.append(f"# {stripped}") continue if next_line and set(next_line) <= {'-'} and len(next_line) >= 3: result.append(f"## {stripped}") continue # 跳过下划线行本身 if stripped and set(stripped) <= {'=', '-'} and len(stripped) >= 3: continue # 章节编号 if re.match(r'^第[一二三四五六七八九十百]+[章节]', stripped): result.append(f"## {stripped}") continue # 数字编号 1.1.1 m = re.match(r'^(\d+(?:\.\d+)*)\s+(.+)', stripped) if m: level = m.group(1).count('.') + 1 level = min(level, 6) result.append(f"{'#' * level} {stripped}") continue result.append(line) return '\n'.join(result)这套规则不是万能的,但能覆盖大部分技术文档和书籍 txt。转完之后,就能用 Markdown 的解析逻辑处理了。
4.3 转换后的校验:别让规则误伤正文
正则转换最大的风险是误伤。比如正文里出现"1. 首先打开设置",这其实是个列表项,不是标题,但会被数字编号规则误判成# 1. 首先打开设置。
我的做法是加一层校验:转换后统计标题数量,如果标题占比超过全文行数的 20%,说明规则太激进,需要收紧。另外,标题一般不会以标点结尾,可以加个判断:
def is_likely_heading(text): if len(text) > 80: # 标题不会太长 return False if text.endswith(('。', ',', ';', ':', '.', ',', ';')): # 标题不以标点结尾 return False return True在转换前用这个函数过滤一遍,能挡掉大部分误判。转换完最好人工抽查几个文件,确认标题层级合理,再批量跑。
5. 切块参数的调优:没有万能值,只有场景值
5.1 chunk_size 到底怎么定
这是被问得最多的问题。我的答案永远是:看你的检索场景和模型上下文窗口。
- 如果检索的是事实型问答("XX 的参数是多少"),chunk 要小,200-400 字符,保证精准命中
- 如果检索的是总结型问答("这段讲了什么"),chunk 要大,800-1500 字符,保证信息完整
- 如果模型上下文窗口大(如 128k),可以适当放大 chunk,减少 chunk 数量,降低检索开销
我一般从 500 起步,跑一批测试问题,看命中率和答案完整度,再上下调整。别迷信某个"最佳值",不同数据集差异很大。
5.2 overlap 的作用与代价
overlap 是为了防止信息在切分点丢失。但 overlap 太大会导致 chunk 冗余,检索时返回一堆重复内容,浪费上下文窗口。
我的经验值:overlap 取 chunk_size 的 10%-20%。500 的 chunk 配 50-100 的 overlap 比较合适。如果文档结构清晰(如 Markdown 按标题切),overlap 可以设小甚至为 0,因为标题切分本身就是语义边界。
5.3 中文的特殊处理
中文没有空格分词,按字符数切分时要注意:别在词语中间切断。比如"人工智能"被切成"人工"和"智能",两个 chunk 都失去了完整语义。
解决办法是优先在标点处切分。RecursiveCharacterTextSplitter的 separators 里把中文标点放前面:
separators=["\n\n", "\n", "。", "!", "?", ";", ",", "、", " ", ""]这样切分时会优先在句号、问号处断开,实在不行才在逗号处断,最后才按字符硬切。实测这个顺序对中文语义完整性帮助很大。
6. 元数据标注:让 chunk 可追溯、可过滤
6.1 必带的元数据字段
每个 chunk 除了文本内容,还应该带一组元数据,方便检索时过滤和展示来源。我一般会带这些:
| 字段 | 说明 | 示例 |
|---|---|---|
| source | 源文件路径 | docs/install.md |
| file_type | 文件类型 | markdown / txt |
| chunk_index | 在文档中的序号 | 12 |
| heading_path | 标题路径 | 产品手册 > 安装指南 |
| block_type | 块类型 | text / code / table |
| char_count | 字符数 | 486 |
这些字段在检索时能派上大用场。比如用户问"代码示例",可以过滤block_type=code;用户问某个章节的内容,可以按heading_path过滤。
6.2 用元数据做检索后过滤
向量检索返回 Top-K 后,可以用元数据做二次过滤。比如只保留来自特定文档的结果,或者排除代码块:
def filter_results(results, source_filter=None, block_type=None): filtered = [] for r in results: meta = r.metadata if source_filter and source_filter not in meta.get('source', ''): continue if block_type and meta.get('block_type') != block_type: continue filtered.append(r) return filtered这个技巧在多知识库场景特别有用。比如一个 RAG 系统同时挂了产品文档和内部 Wiki,用户问产品问题时,可以按source过滤掉 Wiki 内容,避免串味。
6.3 元数据别塞太多
元数据不是越多越好。每个字段都会增加存储和检索开销,而且塞太多无关字段会稀释有效信息。我的原则是:只带检索和展示真正用得上的字段。像文件的创建时间、修改时间、作者这些,除非业务需要,否则不用带。
7. 实测中的几个坑与应对
7.1 编码识别失败的兜底方案
charset-normalizer也有失手的时候,尤其是短文件。我的兜底方案是多编码尝试 + 可读性打分:
def read_with_fallback(path): encodings = ['utf-8', 'gbk', 'gb18030', 'utf-16', 'latin-1'] best_text = None best_score = -1 for enc in encodings: try: with open(path, 'r', encoding=enc) as f: text = f.read() score = readability_score(text) if score > best_score: best_score = score best_text = text except (UnicodeDecodeError, LookupError): continue return best_text def readability_score(text): # 统计常见汉字和英文字母占比 if not text: return 0 common = sum(1 for c in text if '\u4e00' <= c <= '\u9fff' or c.isalnum() or c.isspace()) return common / len(text)按可读性打分选最优结果,比单纯依赖编码识别库更稳。这个方案我用了两年多,基本没再遇到乱码问题。
7.2 Markdown 表格被切碎的修复
即使做了块保护,有时候表格还是会被切碎——比如表格特别长,超过 chunk_size。这时候我的处理是:把表格转成文本描述,而不是保留 Markdown 表格语法。
def table_to_text(md_table): lines = [l for l in md_table.strip().split('\n') if l.strip()] if len(lines) < 2: return md_table headers = [c.strip() for c in lines[0].strip('|').split('|')] rows = [] for line in lines[2:]: # 跳过表头分隔行 cells = [c.strip() for c in line.strip('|').split('|')] row_desc = ",".join(f"{h}是{c}" for h, c in zip(headers, cells)) rows.append(row_desc) return ";".join(rows)比如一张参数表,转成"参数A是值1,参数B是值2;参数A是值3,参数B是值4"这样的文本。虽然丢了表格形式,但语义完整,embedding 能理解,检索时也能命中。
7.3 超长单行文本的处理
有些 txt 文件是一整行超长文本,没有换行。这时候按换行切分完全失效,只能按字符硬切。但硬切会破坏语义。
我的做法是先按标点插入换行,再走正常切分流程:
def insert_linebreaks(text, max_len=200): # 在句末标点后插入换行,如果该行已超长 result = [] current = [] current_len = 0 for char in text: current.append(char) current_len += 1 if char in '。!?;' and current_len > max_len: result.append(''.join(current)) current = [] current_len = 0 if current: result.append(''.join(current)) return '\n'.join(result)这样超长行会被拆成合理的段落,后续切分就正常了。
7.4 重复内容去重
知识库数据经常有重复——同一份文档多个版本、复制粘贴的段落。重复内容会导致检索时返回一堆相似结果,浪费上下文。
我一般用SimHash 或 MinHash做近似去重。简单点的话,可以对 chunk 做归一化(去空格、转小写)后算 MD5,完全相同的直接去重:
import hashlib def dedup_chunks(chunks): seen = set() result = [] for chunk in chunks: normalized = re.sub(r'\s+', '', chunk.page_content).lower() h = hashlib.md5(normalized.encode()).hexdigest() if h not in seen: seen.add(h) result.append(chunk) return result近似去重更复杂,需要算相似度,但对质量提升明显。如果数据量大,建议上 MinHash LSH,效率高。
8. 一套可复用的解析流水线
把前面的东西串起来,我封装了一个通用的解析函数,输入 txt 或 Markdown,输出带元数据的 chunk 列表:
def parse_document(path): # 1. 读取(带编码兜底) if path.endswith('.md'): text = read_with_fallback(path) is_markdown = True else: text = read_with_fallback(path) # 尝试伪结构转 Markdown text = txt_to_markdown(text) is_markdown = True # 2. 清洗 text = clean_text(text) # 3. 保护代码块和表格 text, code_blocks, tables = protect_blocks(text) # 4. 按标题切分 md_splitter = MarkdownHeaderTextSplitter( headers_to_split_on=[("#", "h1"), ("##", "h2"), ("###", "h3")], strip_headers=False, ) chunks = md_splitter.split_text(text) # 5. 还原代码块和表格 for chunk in chunks: chunk.page_content = restore_blocks(chunk.page_content, code_blocks, tables) # 6. 二次切分(超长 chunk) final_chunks = [] for chunk in chunks: if len(chunk.page_content) > 1000: sub_splitter = RecursiveCharacterTextSplitter( chunk_size=500, chunk_overlap=80, separators=["\n\n", "\n", "。", "!", "?", ";", ",", " ", ""], ) sub_texts = sub_splitter.split_text(chunk.page_content) for i, sub in enumerate(sub_texts): new_chunk = Document( page_content=sub, metadata={**chunk.metadata, 'sub_index': i} ) final_chunks.append(new_chunk) else: final_chunks.append(chunk) # 7. 加标题路径前缀 for chunk in final_chunks: prefix = build_context_prefix(chunk.metadata) if prefix: chunk.page_content = f"[{prefix}]\n{chunk.page_content}" chunk.metadata['source'] = path chunk.metadata['char_count'] = len(chunk.page_content) # 8. 去重 final_chunks = dedup_chunks(final_chunks) return final_chunks这套流水线我用了很久,覆盖了 txt 和 Markdown 的绝大多数场景。你可以直接拿去改,也可以按自己的需求裁剪。
9. 几个容易被忽略的细节
9.1 文件读取的编码参数要显式指定
Python 的open()如果不指定 encoding,会按系统默认编码走。Windows 默认 GBK,Linux 默认 UTF-8,同一份代码在不同机器上结果不一样。永远显式指定 encoding,这是铁律。
9.2 Markdown 的 front matter 要单独处理
很多 Markdown 文件开头有 YAML front matter(---包裹的元数据)。这部分不该进正文,应该解析出来放进 metadata:
import yaml def extract_front_matter(text): if text.startswith('---'): parts = text.split('---', 2) if len(parts) >= 3: try: meta = yaml.safe_load(parts[1]) return meta, parts[2].strip() except yaml.YAMLError: pass return {}, textfront matter 里的 title、tags、date 都是很好的元数据,检索时能用来过滤。
9.3 列表项不要单独成块
Markdown 的列表项如果被单独切成一个 chunk,会失去上下文。比如:
- 支持 Python 3.9+ - 支持 Node 18+ - 支持 Java 17+如果每个列表项单独成块,检索"支持哪些语言"时,可能只命中一个,答案不完整。我的做法是把整个列表作为一个块,除非列表特别长。
9.4 标题本身也要进 chunk
有些切分策略会把标题从正文里剥离,只留正文。这样 chunk 就失去了"这段讲什么"的直接线索。我建议strip_headers=False,让标题留在正文里,同时 metadata 里也存一份。
10. 关于效果验证的一点经验
解析和切分做完,怎么知道效果好不好?别靠感觉,要建测试集。
我的做法是:从文档里挑 20-30 个典型问题,人工标注正确答案所在的 chunk。然后跑检索,看 Top-K 命中率。如果命中率低于 70%,说明切分或解析有问题,回去调。
另外,看 bad case比看整体指标更有用。把没命中的问题拉出来,看命中的 chunk 是什么,为什么没命中。常见原因有:chunk 太大导致语义稀释、chunk 太小导致信息不全、标题路径没拼、编码问题导致乱码。对症下药,比盲目调参有效得多。
这套流程跑下来,txt 和 Markdown 的解析基本就稳了。后面处理 PDF、Word、HTML 时,思路是一样的——先保住结构,再谈切分。结构是 RAG 检索质量的地基,地基打不牢,上面堆再多优化都是白搭。