首页 / 资讯中心 / 文章详情

RAG数据导入实战:txt与Markdown解析及LangChain Document构建

RAG数据导入实战:txt与Markdown解析及LangChain Document构建 ★ FEATURED ARTICLE
1. 为什么数据导入是 RAG 系统的第一道生死关做过 RAG 项目的人都有一个共识模型选得再好检索策略调得再花哨只要数据导入这一环出了问题后面全是白搭。我见过太多团队在 RAG 项目上踩坑最后复盘发现 70% 的bad case 都出在数据解析阶段——PDF 里的表格被拆成了乱码、Markdown 的层级结构丢失、txt 文件的编码格式不统一导致乱码这些问题在 demo 阶段看不出来一上生产环境就集中爆发。这个系列我打算把 RAG 数据导入与解析的完整链路拆开来讲第一篇聚焦在最基础但也最容易被忽视的环节通用文本文件txt和结构化文档Markdown的导入与解析。为什么从这两种格式开始因为它们是 RAG 知识库的“最小公分母”——不管你后面要处理 PDF、Word、HTML 还是数据库导出最终都要先转成纯文本或类 Markdown 结构再交给 LangChain 的 Document 对象去处理。把这两种格式吃透了后面的复杂格式就是在这个基础上做加法。这篇文章适合谁看如果你正在搭建 RAG 知识库手头有一堆 txt 笔记、Markdown 文档需要导入或者你已经用 LangChain 跑通了 demo但发现检索效果不稳定想从数据源头找问题再或者你只是好奇 RAG 的数据管道到底长什么样想找一个能直接抄作业的方案——那这篇内容应该能帮到你。我会从设计思路讲到代码实现再到踩坑经验尽量把每个决策背后的“为什么”说清楚。2. 整体设计思路从文件到 Document 对象的完整链路2.1 核心需求拆解RAG 到底需要什么样的数据形态很多人一上来就写代码TextLoader一调load()一跑觉得完事了。但 RAG 对数据的要求远不止“读出来”这么简单。我总结下来一个合格的 RAG 数据导入环节需要满足四个条件第一内容完整性。原始文件里的信息不能丢包括正文、标题层级、列表结构、代码块、表格这些。很多人只关注正文文字结果检索的时候发现标题里的关键词完全匹配不上因为标题在解析时被当成普通文本混在一起了。第二结构可追溯。每个 chunk 要能追溯到它的来源文件、在文件中的位置、所属的章节层级。这在做引用溯源和调试时特别重要。你想想用户问了一个问题系统返回了一段答案但你不知道这段答案是从哪个文件的哪一段来的出了问题根本没法排查。第三元数据丰富。除了内容本身还需要附带文件路径、修改时间、文件类型、字符数等元数据。这些信息在后续的过滤检索、增量更新、权限控制中都会用到。比如你可以根据文件路径做权限隔离根据修改时间做增量索引。第四格式统一。不管输入是 txt 还是 Markdown最终都要转成 LangChain 的Document对象包含page_content和metadata两个核心字段。这样后续的 splitter、embedding、vector store 才能用同一套流程处理。提示很多人忽略了一点——RAG 的数据导入不是一次性的而是持续性的。今天导入一批明天可能还要追加所以元数据里最好带上文件哈希或修改时间方便做增量更新。2.2 技术选型为什么是 LangChain 自定义 LoaderLangChain 生态里现成的 loader 很多TextLoader、UnstructuredMarkdownLoader、DirectoryLoader都能用。但我在实际项目中很少直接裸用原因有三个一是编码问题。TextLoader默认用 UTF-8 读取但实际拿到的 txt 文件编码五花八门GBK、GB2312、UTF-8 with BOM 都有。直接读大概率报UnicodeDecodeError或者读出来是乱码。你需要自己封装一个能自动检测编码的 loader。二是 Markdown 结构丢失。UnstructuredMarkdownLoader底层用的是unstructured库它会把 Markdown 转成元素列表但标题层级信息在转换过程中容易丢失。比如## 二级标题和### 三级标题在它眼里可能都是Title元素你没法区分层级。而层级信息对 RAG 很重要——检索时你可能希望优先返回某个章节下的内容。三是元数据不够用。现成 loader 给的元数据通常只有source一个字段你需要自己补充文件大小、修改时间、字符数、标题路径等信息。所以我的方案是基于 LangChain 的Document数据结构自己写一套轻量的 loader。不依赖unstructured这种重库用 Python 标准库加少量第三方库就能搞定可控性更强调试也方便。2.3 处理流程总览四步走策略整个数据导入流程我拆成四步文件发现与编码检测扫描目标目录识别 txt 和 md 文件自动检测编码格式。内容解析与结构化txt 按段落切分Markdown 按标题层级解析成树状结构。Document 对象构建把解析结果转成 LangChain 的Document列表附带完整元数据。质量校验与去重检查空文档、超长文档、重复内容做初步清洗。这四步看起来简单但每一步都有细节。下面我逐个展开。3. 核心细节解析txt 与 Markdown 的解析要点3.1 txt 文件解析编码检测是第一道坎txt 文件看似最简单但编码问题能坑掉一半新手。我遇到过的情况包括Windows 记事本保存的 GBK 文件、Mac 上带 BOM 的 UTF-8 文件、从某些网站下载的 GB2312 文件甚至还有混合编码的文件前半段 GBK 后半段 UTF-8这种基本无解只能人工处理。我的处理策略是三级检测第一级用chardet库做概率检测。chardet.detect()会返回一个编码和置信度置信度高于 0.8 的直接采用。这个库对中文编码的识别准确率还不错但偶尔会把 GBK 误判成 GB2312不过这两个编码兼容性很好误判影响不大。第二级如果chardet置信度低尝试用utf-8-sig读取处理 BOM失败再试gbk再失败试gb18030GBK 的超集覆盖更多生僻字。第三级如果都失败用errorsreplace强制读取把无法解码的字符替换成同时记录警告日志后续人工检查。import chardet def detect_encoding(file_path): with open(file_path, rb) as f: raw f.read(10000) # 只读前10KB做检测大文件全读太慢 result chardet.detect(raw) if result[confidence] 0.8: return result[encoding] # 置信度低走备选方案 for enc in [utf-8-sig, gbk, gb18030]: try: with open(file_path, r, encodingenc) as f: f.read(1000) return enc except UnicodeDecodeError: continue return utf-8 # 兜底配合 errorsreplace注意chardet对短文本的检测准确率很低所以读取前 10KB 做检测比读全文更合理。如果文件小于 10KB就全读。编码解决后txt 的内容解析相对简单。我的做法是按空行分段连续的非空行合并成一个段落。为什么不按单行因为很多 txt 文件是硬换行的一行可能只有十几个字按行切会把一个完整句子拆散。按空行分段能保留语义完整性。但这里有个坑有些 txt 文件通篇没有空行几万字挤在一起。这种情况需要降级策略——按句号、问号、感叹号等句末标点切分再按长度合并。我一般设置单段最大 1000 字符超过就强制切分。3.2 Markdown 解析保留层级结构是关键Markdown 比 txt 复杂的地方在于它有结构。标题、列表、代码块、表格、引用块每种元素在 RAG 里的处理方式都不一样。我的解析思路是构建标题树。用正则匹配^#{1,6}\s(.)$识别标题行记录层级和标题文本。遇到标题时把当前累积的内容存成一个 section然后开启新 section。每个 section 记录它的标题路径比如[第一章, 1.2 节, 1.2.3 小节]。import re HEADING_PATTERN re.compile(r^(#{1,6})\s(.)$) def parse_markdown(text): lines text.split(\n) sections [] current_heading_path [] current_content [] for line in lines: match HEADING_PATTERN.match(line) if match: # 保存上一个 section if current_content: sections.append({ heading_path: current_heading_path.copy(), content: \n.join(current_content).strip() }) current_content [] level len(match.group(1)) title match.group(2).strip() # 调整标题路径 current_heading_path current_heading_path[:level-1] current_heading_path.append(title) else: current_content.append(line) # 保存最后一个 section if current_content: sections.append({ heading_path: current_heading_path.copy(), content: \n.join(current_content).strip() }) return sections这段代码的核心逻辑是维护一个current_heading_path列表。遇到一级标题时清空列表再加新标题遇到二级标题时保留一级标题再加二级以此类推。这样每个 section 都能拿到完整的标题路径。代码块的处理要特别小心。Markdown 里的代码块用 包裹里面的内容可能包含#开头的行比如 Python 注释如果被误识别成标题就乱了。所以解析前要先标记代码块区域跳过标题匹配。表格的处理也有讲究。Markdown 表格转成纯文本后行列关系会丢失。我的做法是把表格转成“列名: 值”的形式比如| 姓名 | 年龄 | |------|------| | 张三 | 25 |转成姓名: 张三, 年龄: 25这样检索时“张三的年龄”这种查询更容易命中。3.3 元数据设计哪些字段必须保留元数据是 RAG 数据导入里最容易被忽视、但后期最影响体验的部分。我一般会保留以下字段字段名类型说明用途sourcestr文件绝对路径溯源、权限控制file_namestr文件名展示、过滤file_typestrtxt / md分类处理file_sizeint文件字节数质量监控modified_timefloat修改时间戳增量更新encodingstr检测到的编码调试heading_pathlist标题路径结构化检索section_indexint章节序号排序、定位char_countint字符数切分参考content_hashstr内容哈希去重heading_path这个字段特别有用。检索时你可以根据它做过滤比如只搜某个章节下的内容展示时可以在答案上方显示“来源某某文档 第二章 2.3 节”用户一看就知道答案的出处信任感直接拉满。content_hash用 MD5 或 SHA256 都行主要用来去重。同一份文件被重复导入时哈希相同就跳过避免向量库里出现重复内容。4. 实操过程从零搭建通用文本导入管道4.1 环境准备与依赖安装先说一下环境。Python 3.9 以上都行我用的 3.10。核心依赖不多pip install langchain langchain-community chardetlangchain提供Document数据结构和后续的 splitter、embedding 接口chardet做编码检测。不需要装unstructured那个库依赖太重而且我们自己做解析可控性更强。如果你打算后续接向量库再装对应的客户端比如chromadb或faiss-cpu。这篇先不涉及向量化专注在数据导入。4.2 完整代码实现一个可复用的 Loader我把整个 loader 封装成一个类叫UniversalTextLoader。核心方法有三个load()返回 Document 列表_load_txt()和_load_markdown()分别处理两种格式。import os import hashlib from datetime import datetime from pathlib import Path from typing import List import chardet from langchain_core.documents import Document class UniversalTextLoader: def __init__(self, root_dir: str, encoding_fallback: str utf-8): self.root_dir Path(root_dir) self.encoding_fallback encoding_fallback self.supported_ext {.txt, .md, .markdown} def load(self) - List[Document]: docs [] for file_path in self._discover_files(): try: if file_path.suffix.lower() .txt: docs.extend(self._load_txt(file_path)) else: docs.extend(self._load_markdown(file_path)) except Exception as e: print(f[WARN] 处理 {file_path} 失败: {e}) return docs def _discover_files(self): for path in self.root_dir.rglob(*): if path.is_file() and path.suffix.lower() in self.supported_ext: yield path def _detect_encoding(self, file_path: Path) - str: with open(file_path, rb) as f: raw f.read(10000) result chardet.detect(raw) if result[confidence] and result[confidence] 0.8: return result[encoding] for enc in [utf-8-sig, gbk, gb18030]: try: with open(file_path, r, encodingenc) as f: f.read(1000) return enc except UnicodeDecodeError: continue return self.encoding_fallback def _base_metadata(self, file_path: Path, encoding: str) - dict: stat file_path.stat() return { source: str(file_path.absolute()), file_name: file_path.name, file_type: file_path.suffix.lower().lstrip(.), file_size: stat.st_size, modified_time: stat.st_mtime, encoding: encoding, } def _load_txt(self, file_path: Path) - List[Document]: encoding self._detect_encoding(file_path) with open(file_path, r, encodingencoding, errorsreplace) as f: text f.read() # 按空行分段 paragraphs [p.strip() for p in text.split(\n\n) if p.strip()] docs [] base_meta self._base_metadata(file_path, encoding) for idx, para in enumerate(paragraphs): meta base_meta.copy() meta[section_index] idx meta[char_count] len(para) meta[content_hash] hashlib.md5(para.encode()).hexdigest() docs.append(Document(page_contentpara, metadatameta)) return docs def _load_markdown(self, file_path: Path) - List[Document]: encoding self._detect_encoding(file_path) with open(file_path, r, encodingencoding, errorsreplace) as f: text f.read() sections self._parse_markdown_sections(text) docs [] base_meta self._base_metadata(file_path, encoding) for idx, sec in enumerate(sections): if not sec[content].strip(): continue meta base_meta.copy() meta[heading_path] sec[heading_path] meta[section_index] idx meta[char_count] len(sec[content]) meta[content_hash] hashlib.md5(sec[content].encode()).hexdigest() # 把标题路径拼进内容提升检索命中率 heading_str .join(sec[heading_path]) content f{heading_str}\n\n{sec[content]} if heading_str else sec[content] docs.append(Document(page_contentcontent, metadatameta)) return docs def _parse_markdown_sections(self, text: str) - List[dict]: import re heading_pattern re.compile(r^(#{1,6})\s(.)$) lines text.split(\n) sections [] heading_path [] buffer [] in_code_block False for line in lines: if line.strip().startswith(): in_code_block not in_code_block buffer.append(line) continue if not in_code_block: match heading_pattern.match(line) if match: if buffer: sections.append({ heading_path: heading_path.copy(), content: \n.join(buffer).strip() }) buffer [] level len(match.group(1)) title match.group(2).strip() heading_path heading_path[:level-1] heading_path.append(title) continue buffer.append(line) if buffer: sections.append({ heading_path: heading_path.copy(), content: \n.join(buffer).strip() }) return sections用起来很简单loader UniversalTextLoader(./knowledge_base) documents loader.load() print(f共加载 {len(documents)} 个文档片段) for doc in documents[:3]: print(doc.metadata[source], doc.metadata.get(heading_path)) print(doc.page_content[:100]) print(---)4.3 关键参数的选择与计算代码里有几个参数需要根据实际情况调整我逐个说明选择依据。编码检测的读取字节数10000。这个值太小检测不准太大影响性能。我实测下来 10KB 是个平衡点。中文文本 10KB 大约 3000-5000 字足够chardet做出准确判断。如果你的文件普遍很小比如都是几百字的笔记那就全读。段落切分的最大长度。我在 txt 解析里没有强制切分因为按空行分段后单段通常不会太长。但如果你遇到那种通篇无空行的文件需要加一个兜底逻辑单段超过 2000 字符就按句号切分。为什么是 2000因为后续 embedding 模型通常有 512 token 的限制2000 中文字符大约对应 1000-1500 token留了余量给后续的 splitter 再切。Markdown 标题路径的拼接方式。我用的是连接比如第一章 1.2 节 1.2.3 小节。这个符号选择有讲究——不能用#因为会和 Markdown 语法冲突不能用/因为看起来像文件路径视觉上清晰而且不会和正文内容混淆。内容哈希的算法。MD5 足够用了速度快碰撞概率在 RAG 场景下可以忽略。如果你对安全性有要求换 SHA256但速度会慢一些。哈希的输入是page_content不是整个文件这样即使文件只改了一小部分也只有受影响的 section 哈希会变方便做增量更新。4.4 实操现场导入一个真实的知识库目录我拿一个实际的项目目录来演示。目录结构是这样的knowledge_base/ ├── notes/ │ ├── python_basics.txt │ └── linux_commands.txt ├── docs/ │ ├── api_guide.md │ └── deployment.md └── README.md跑一遍 loaderloader UniversalTextLoader(./knowledge_base) docs loader.load() # 统计信息 from collections import Counter type_count Counter(d.metadata[file_type] for d in docs) print(f文档片段总数: {len(docs)}) print(f按类型分布: {dict(type_count)}) # 检查元数据完整性 sample docs[0] print(f元数据字段: {list(sample.metadata.keys())})输出大概是文档片段总数: 47 按类型分布: {txt: 23, md: 24} 元数据字段: [source, file_name, file_type, file_size, modified_time, encoding, heading_path, section_index, char_count, content_hash]47 个片段来自 5 个文件平均每个文件 9 个片段。这个粒度对 RAG 来说比较合适——太粗了检索不精准太细了上下文不完整。我特意检查了几个边界情况README.md只有一级标题heading_path就是[README]api_guide.md有四级标题路径完整保留python_basics.txt里有中文和英文混排编码检测正确识别为 UTF-8。5. 常见问题与排查技巧实录5.1 编码问题速查表编码问题是 txt 导入的头号杀手我整理了一个速查表现象可能原因排查方法解决方案读取报 UnicodeDecodeError编码不是 UTF-8用 chardet 检测按检测结果指定编码中文显示为乱码用错编码读取检查文件头是否有 BOM尝试 utf-8-sig / gbk部分字符显示为 编码不兼容查看乱码位置用 gb18030 兜底文件开头有奇怪字符UTF-8 BOM十六进制查看前几字节用 utf-8-sig 读取混合编码文件多次编辑导致分段检测编码人工拆分处理提示Windows 记事本保存的 UTF-8 文件默认带 BOM用utf-8读取时开头会出现\ufeff字符。这个字符虽然看不见但会影响检索匹配。用utf-8-sig读取可以自动去掉。5.2 Markdown 解析的五个坑坑一代码块里的#被当成标题。这个前面提过解决方案是维护in_code_block状态。但要注意有些 Markdown 用~~~作为代码块标记也要一并处理。坑二标题里包含 Markdown 链接。比如## [标题](url)直接取文本会把链接语法也带进去。需要额外做一次链接提取只保留显示文本。坑三Setext 风格的标题。有些 Markdown 用下划线表示标题一级标题 二级标题 --------这种不是#开头正则匹配不到。如果你的文档里有这种写法需要额外加一条规则检查当前行下一行是否全是或-。坑四表格跨行。Markdown 表格如果单元格内容太长有些编辑器会自动换行导致解析出来的表格结构错乱。这种情况建议在解析前先做表格规范化或者直接用专门的表格解析库。坑五HTML 标签混入。有些 Markdown 里嵌了div、br等 HTML 标签这些标签在检索时是噪音。我的做法是用正则把 HTML 标签去掉但保留标签内的文本。5.3 性能优化大目录导入的加速技巧如果你的知识库有几千个文件上面的代码可能会跑得比较慢。我分享几个优化技巧并行处理。用concurrent.futures.ThreadPoolExecutor并行读取文件。IO 密集型任务用多线程就能获得不错的加速比。我实测 4 线程比单线程快 3 倍左右。from concurrent.futures import ThreadPoolExecutor def load_parallel(self, max_workers4): files list(self._discover_files()) docs [] with ThreadPoolExecutor(max_workersmax_workers) as executor: results executor.map(self._load_single, files) for result in results: docs.extend(result) return docs增量导入。记录每个文件的modified_time和content_hash下次导入时只处理变化的文件。这个逻辑可以配合一个简单的 JSON 状态文件实现。跳过空文件和小文件。小于 10 字节的文件基本没内容直接跳过。这个判断放在_discover_files里避免无谓的读取。5.4 质量校验导入后必做的三项检查数据导入完成后别急着往向量库里灌。先做三项检查第一空内容检查。统计page_content为空的 Document 数量。如果超过 5%说明解析逻辑有问题需要排查。第二超长文档检查。统计字符数超过 2000 的 Document。这些文档在后续 embedding 时会被截断导致信息丢失。需要提前切分。第三重复内容检查。用content_hash去重看看有多少重复。重复率高说明目录里有冗余文件或者解析逻辑把同一内容重复提取了。def quality_check(docs): empty [d for d in docs if not d.page_content.strip()] too_long [d for d in docs if len(d.page_content) 2000] hashes [d.metadata[content_hash] for d in docs] duplicates len(hashes) - len(set(hashes)) print(f空文档: {len(empty)}) print(f超长文档: {len(too_long)}) print(f重复文档: {duplicates}) if empty: print(空文档来源:, [d.metadata[source] for d in empty[:5]])我一般把这三项检查做成一个函数每次导入后自动跑一遍有问题及时报警。6. 从导入到切分下一步该做什么数据导入只是 RAG 管道的第一步。拿到 Document 列表后下一步是切分splitting。但切分策略和导入时的结构保留是强相关的——如果你在导入时保留了heading_path切分时就可以按章节切而不是无脑按字符数切。举个例子一个 Markdown 文档有 5000 字按字符切会切成 5 段可能把一个小节的完整论述拆散。但如果按heading_path切每个小节一个 chunk语义完整性就好很多。这就是为什么我在导入阶段花大力气保留结构信息——它是为后续切分和检索服务的。另外content_hash在增量更新时特别有用。你可以维护一个哈希集合新导入的 Document 先查哈希已存在就跳过。这样即使全量扫描目录也不会重复灌数据。我个人在实际操作中的体会是RAG 数据导入的功夫80% 花在边界情况的处理上。正常文件谁都能读但乱码文件、混合编码、结构异常的 Markdown这些才是拉开差距的地方。建议你在正式导入前先拿一批“脏数据”测试把各种异常情况都跑一遍把处理逻辑打磨稳定了再上生产环境。踩过几次坑之后你会发现前期在数据导入上多花一天后期在检索调优上能省一周。
阅读完成 · 觉得有帮助?
咨询建站