據(jù)導入與解析:從txt到Markdown的結構化處理全指南)
做 RAG 的人應該都有一個共同的感受頂層設計再花哨模型選得再大最后跑起來效果不好十有八九是卡在了數(shù)據(jù)導入這一步。我之前接過好幾個所謂的“知識庫問答”需求對方上來就問用哪個向量庫、哪個 embedding 模型等我把他們發(fā)來的原始資料打開一看——有的是從網(wǎng)上爬下來帶一堆標簽的 HTML有的是掃描版 PDF 轉出來的純文本還有的是毫無規(guī)律的聊天記錄 txt。這種數(shù)據(jù)直接進 RAG 管線別說檢索效果了切塊那一步就亂成一鍋粥。這篇系列文章的第一篇我打算把最基礎也最容易被糊弄過去的環(huán)節(jié)徹底講透通用文本txt 類和結構化文本Markdown 類的導入與解析。核心關鍵詞就三個RAG、數(shù)據(jù)導入、解析。我會按實際項目里的處理順序來拆解從拿到原始文件的第一個動作到產(chǎn)出可喂給向量化模塊的標準片段每一步都講清楚“為什么這么做”以及“踩過的坑是什么”。1. 為什么數(shù)據(jù)解析決定了 RAG 的成敗先明確一個觀點RAG 不是檢索模型不行而是喂進去的數(shù)據(jù)根本沒法檢索。你可以把 RAG 應用想象成一家餐廳模型是廚師向量庫是冰箱而數(shù)據(jù)解析就是后廚的擇菜、洗菜、切菜環(huán)節(jié)。菜不洗、不切廚師技術再好也做不出一盤像樣的菜。1.1 原文檔與檢索單元之間的鴻溝絕大多數(shù)企業(yè)內(nèi)部的存量文檔長什么樣txt 里的回車換行大量缺失段落和段落之間用全角空格代替Markdown 文件看起來規(guī)整實際上標題層級混亂、代碼塊誤用、表格里塞了圖片。這些原始形態(tài)和檢索單元之間存在一條巨大的鴻溝。檢索單元是什么向量庫里存的是有一定語義邊界的文本塊通常是幾百 token 一段。原始文檔是什么是一堆連續(xù)字符流或者只有少量格式標記的文本。解析環(huán)節(jié)的價值就是從連續(xù)字符流中切出有語義邊界的 chunk同時保留必要的標題層級信息讓每個 chunk 自帶“出身背景”。這一步不做后面用再強的 Rerank 模型也救不回來。1.2 我見過的典型失敗案例很多人直接拿 LangChain 的TextLoader和RecursiveCharacterTextSplitter來處理全部文檔本地測試感覺還行一上生產(chǎn)就露餡。一個典型的失敗案例有人把一份 500 頁的運維操作手冊導出的 HTML 轉 txt 格式直接按 1000 字符切塊。結果是什么第 47 塊的標題明明寫著“如何重啟數(shù)據(jù)庫”內(nèi)容里卻混著上一節(jié)“備份策略”的尾巴。用戶的提問是“數(shù)據(jù)庫重啟時需要注意什么”系統(tǒng)把這堆臟塊全部召回Rerank 之后找出來的內(nèi)容左右矛盾回答質量慘不忍睹。問題出在哪出在整個管線沒有一個環(huán)節(jié)“理解”文檔的結構。解析器只是做了字符層面的切分完全沒有感知到標題、段落、列表這些結構邊界。所以我始終堅持一個原則先做結構化解再做切塊結構化解的程度直接決定切塊的上限。2. 通用文本解析先把 txt 變成“干凈的長文”txt 類文件是 RAG 導入中最常見也最容易被輕視的輸入。很多人認為 txt 無非是open()讀進來、按字符切一切就完事真實處理起來遠沒有這么簡單。編碼混亂、字符污染、無效換行、段落粘連每一項都足以把后續(xù)的解析鏈路帶偏。2.1 編碼識別與統(tǒng)一第一步就翻車是家常便飯我接手過一個詞典類 txt打開一看全是類似鍥句功鍩虹 鏂囦歡的亂碼。原因很簡單——文件是 GBK 編碼但讀取時用了 UTF-8。這類問題在生產(chǎn)環(huán)境里發(fā)生率極高尤其是從舊系統(tǒng)導出的文檔。我的建議是不要用open(path, encodingutf-8)一把梭。穩(wěn)妥的做法是用charset-normalizer或者cchardet先做編碼探測把輸入統(tǒng)一轉成 UTF-8。# 編碼識別與統(tǒng)一讀取 from charset_normalizer import from_path def load_text_auto(path: str) - str: result from_path(path).best() if result is None: raise ValueError(f無法識別文件編碼: {path}) # 統(tǒng)一轉為 UTF-8 字符串 return str(result)實測下來charset-normalizer對 GBK、BIG5、Latin-1 的識別準確率比老的 chardet 高不少尤其是在短文本場景下。轉換之后建議再對內(nèi)容做一次強制的 UTF-8 校驗避免中英文混排時出現(xiàn)非法碼點。2.2 清洗規(guī)則不可見字符與異常換行一起處理編碼搞定之后另一個高頻問題是文件里混了大量肉眼看不見的臟數(shù)據(jù)。比如從 PDF 轉出來的 txt 會自動插入一些制表位字符、零寬空格U200B、不換行空格U00A0還有 Windows 和 Unix 混用的換行符號。這些字符不會讓程序直接報錯但到了切塊和向量化階段容易殘留成孤立 token檢索時反而制造噪聲。我習慣用一套正則預處理分三步走把\r\n、\r全部統(tǒng)一成\n。剔除所有控制字符和零寬字符但保留\n和\t。把全角空格統(tǒng)一轉半角多行空行壓縮成單行空行。import re def normalize_text(raw: str) - str: # 統(tǒng)一換行符 text raw.replace(\r\n, \n).replace(\r, \n) # 去掉控制字符與零寬字符保留 \n \t text re.sub(r[\x00-\x08\x0b\x0c\x0e-\x1f\u200b\u00a0], , text) # 全角空格轉半角并合并連續(xù)的空白行 text text.replace(\u3000, ) text re.sub(r[ \t]\n, \n, text) text re.sub(r\n{3,}, \n\n, text) return text.strip()這一步看著基礎但真的能解決很多下游玄學問題。之前有次做知識庫問答用戶問“如何申請退款”系統(tǒng)老是召回一些奇怪的片段兜兜轉轉查到最后發(fā)現(xiàn)是因為原文本里夾雜了全角空格和零寬字符導致語義被硬生生切斷。清洗完之后召回質量立刻上了一個臺階。2.3 語義段落聚合不要急著切塊清洗干凈的 txt還得經(jīng)過一道“段落聚合”。純文本不比 Markdown它沒有標題結構只有模糊的段落感。文檔里的“章節(jié)”往往是通過空行、縮進、連續(xù)大寫標題來暗示的。這時候如果直接按固定字符數(shù)切塊就會無視這些語義邊界。我通常是先按空行把文本切成段落列表再把過短的段落與相鄰段落做聚合最后輸出一版“語義化長文”。聚合規(guī)則不玄乎核心就兩條如果某段字數(shù)小于 30 字且不是列表項特征則合并到前一段。如果某段以數(shù)字編號或“第x章”“前言”“概述”開頭單獨標記為標題段不要合并。def paragraphs_to_doc(paragraphs: list[str], min_chars: int 30): merged [] for para in paragraphs: para para.strip() if not para: continue if len(para) min_chars and merged and not _looks_like_title(para): merged[-1] para else: merged.append(para) return merged def _looks_like_title(text: str) - bool: return bool(re.match(r^(第[一二三四五六七八九十百千0-9][章節(jié)篇]|前言|概述|附錄|結語|references), text))這一步的意義在于給后續(xù)的切分器提供更“完整”的語義塊。短段落單獨成塊很容易變成無頭無尾的碎片合并之后才能保證一個 chunk 內(nèi)部至少有一個完整論點。3. Markdown 結構化解析把標題變成檢索的骨架如果說 txt 處理是“洗干凈”那 Markdown 處理就是“搭骨架”。我對 Markdown 情有獨鐘因為它是目前少有的、人類可讀且機器可解析的輕量結構化格式。做 RAG 解析時從 Markdown 里提取標題層級、代碼塊、表格、列表比從 PDF 或 HTML 里抽結構要省太多力氣。3.1 為什么選 Markdown 作為中轉格式很多項目的數(shù)據(jù)源是 HTML或者是從各種爬蟲工具導出的富文本。我的建議是統(tǒng)一轉成 Markdown 再做結構化解析而不是直接在 HTML 上切塊。原因有三Markdown 把復雜的 DOM 樹壓縮成了線性文本配合markdown或markdown-it這類解析器可以無損還原標題結構。HTML 里大量無語義的div嵌套和 inline 樣式對切塊沒有任何幫助轉成 Markdown 后這些噪聲自動消失?,F(xiàn)代 LLM 對 Markdown 的理解能力很強后面做片段摘要、父子切塊時Markdown 片段直接可以當作優(yōu)質上下文喂給模型。我在項目中常寫一個html2md的預處理函數(shù)內(nèi)部用markdownify把 HTML 轉成 Markdown然后再走結構化解析。這一步跑通后整個知識庫的文檔形態(tài)就統(tǒng)一了。3.2 構建 Markdown AST從線性文本到嵌套樹解析 Markdown 不能靠正則逐行猜最穩(wěn)的方式是用語法樹AST。Python 生態(tài)里我推薦用markdown_it配合自定義 renderer或者直接用mistune。這兩個庫都能把 Markdown 解析成節(jié)點樹每個節(jié)點帶類型heading、paragraph、code、table、list和層級H1-H6。拿到 AST 之后我才真正開始做文章結構理解。我的做法是遍歷 AST把 heading 節(jié)點作為分段的“錨點”。每個 heading 及其后續(xù)兄弟節(jié)點歸為一個結構塊。結構塊內(nèi)部再細分段落節(jié)點、列表節(jié)點、代碼塊節(jié)點、表格節(jié)點。from mistune import create_markdown def md_to_struct_blocks(md_text: str) - list[dict]: md create_markdown(rendererast) nodes md(md_text) blocks [] current None for node in nodes: if node[type] heading: # 遇到新標題開啟新的結構塊 current { title: node[text], level: node[attrs][level], children: [], } blocks.append(current) else: if current is not None: current[children].append(node) else: # 無標題直接開頭的段落放在“文檔首部”塊 blocks.insert(0, {title: 文檔首部, level: 0, children: [node]}) return blocks實際輸出示例簡化后 [ {title: 環(huán)境準備, level: 2, children: [ {type: paragraph, text: 建議使用 Python 3.10}, {type: list, text: [pip install langchain, pip install chromadb]} ]}, {title: 數(shù)據(jù)導入, level: 2, children: [ {type: paragraph, text: 本節(jié)介紹數(shù)據(jù)導入流程} ]} ]這段邏輯是整個 Markdown 解析的核心分水嶺——從此之后文本不再是字符串而是有層級、有歸屬的節(jié)點流水線。后續(xù)切塊時每個塊都能自豪地說“我是從‘環(huán)境準備’這個標題下面切出來的”。3.3 特殊節(jié)點處理代碼塊、表格、數(shù)學公式不能一刀切這里必須先提醒一句不要把所有節(jié)點都無縫拼成長文本后切塊。代碼塊是按行組織的連續(xù)性文本表格是按行和列組織的二維數(shù)據(jù)數(shù)學公式是有著嚴格語義的 LaTeX 字符串。這三類內(nèi)容一旦被中間橫插一刀語義完整性就徹底碎了。我的處理策略如下代碼塊保留整體不拆分。代碼塊本身的語義邊界的完整度高于字符數(shù)如果代碼太長優(yōu)先按換行處的函數(shù)或類邊界去切而不是按字符數(shù)硬切。表格轉成一種“自然語言化”的文本格式再把整表作為單獨塊。例如把表格轉成列名: 值的枚舉文本保留可讀性同時便于向量化。數(shù)學公式分兩派。如果無需精確計算直接保留 LaTeX 源文本塊不轉圖片如果下游展示層需要渲染則單獨抽出來走渲染服務但向量化時仍然用 LaTeX 源文本。def format_table_node(node: dict) - str: header node[attrs][header] rows node[attrs][rows] lines [] for row in rows: pairs [f{h}: {v} for h, v in zip(header, row)] lines.append( | .join(pairs)) return \n.join(lines)實測中我發(fā)現(xiàn)表格轉成自然語言化文本后檢索效果往往比保留原始 Markdown 管道符寫法好很多。因為管道符和多余的空格在向量化時會帶來無意義的 token 噪聲而語義化的電壓: 220V | 頻率: 50Hz這種格式更像 LLM 能直接消化的知識表達。3.4 鏈接與圖片該丟就丟該留標記留標記Markdown 里的鏈接和圖片處理上經(jīng)常引發(fā)糾結。鏈接的標題文本往往就是一句話的精華比如[環(huán)境搭建文檔](./docs/setup.md)保留環(huán)境搭建文檔作為正文是有價值的但保留完整 URL 對向量化通常是噪聲。我的原則是標題文本轉成普通文本URL 剝掉圖片直接抽取路徑或 alt 文本不把圖片二進制喂給文本解析器。如果你需要處理“rag知識庫能存儲圖片嘛”這類問題我的建議是文本鏈路里不放圖片但保留圖片引用路徑和 alt 描述后續(xù)做多模態(tài)檢索時圖片走獨立的向量化通道在結果融合階段再和文本片段關聯(lián)。這一步的解析目標是為將來留好“鉤子”而不是現(xiàn)在就把圖片塞進文本模型。4. 邊界場景與實測中容易翻車的細節(jié)結構化解析框架搭起來之后真正的考驗在于邊界場景。我把自己在這一系列項目中反復踩過、也最終解決的幾個問題集中寫出來給同行們做個參考。4.1 短標題、流水號標題的誤判Markdown 或 txt 轉出來的文檔里常見一種現(xiàn)象正文行首剛好碰上了井號或數(shù)字比如“#1 一次生產(chǎn)事故復盤”“第 1 條不要用 root 跑服務”。這些根本不是標題但解析器很容易把它們當成 H1/H2 錨點導致一個文檔被切出幾十個語義碎片。我的解法是在生成結構塊之前先做一道“標題可信度”過濾。標題長度不能小于 4 個字符。標題不能以純數(shù)字、時間戳、序號開頭除非后續(xù)跟著中文字詞。標題不能以句號、逗號、分號結尾。4.2 嵌套列表壓扁成一行字Markdown 列表在視覺上很清晰但解析進 AST 之后嵌套列表的父子關系處理不好就會被粗暴地拼成一個長段落。比如第一章1.1 安裝依賴用 pip 安裝如果壓扁成“第一章 1.1 安裝依賴 用 pip 安裝”層次感就丟了。我的處理方式是把每級縮進轉成固定前綴符號例如兩空格或 a b 這類路徑式前綴然后按列表項逐項切塊。這樣每個列表項都保留了“第一章 1.1 安裝依賴 用 pip 安裝”這樣的路徑上下文。4.3 CSV 被誤判為 Markdown 表格這是“數(shù)據(jù)導入”環(huán)節(jié)經(jīng)常遇到的邊界問題。很多業(yè)務系統(tǒng)導出的文件是 CSV擴展名卻是 txt內(nèi)容看起來又特別像 Markdown 表格。解析器若按 Markdown 表格去解析通常能跑通但對字段內(nèi)的逗號、引號處理不當就會把一行拆成多行。我建議在解析之初先做格式嗅探如果文件里前幾行出現(xiàn)明顯的逗號分隔且字段數(shù)量一致就按 CSV 解析器處理否則按 Markdown 或純文本處理。兩個解析器走同一套“語義化文本”出口后續(xù)鏈路無需關心來源差異。4.4 HTML 標簽殘留導致的臟標記從網(wǎng)頁保存的 Markdown即便經(jīng)過了 html2md 轉換仍可能殘留部分span、div或 style 屬性。這些內(nèi)容在向量化時會被當成普通文本產(chǎn)生大量無效 token。我在解析流水線末端加了一個正則清掃掃描所有文本節(jié)點把[^]以及class...、style...這類屬性剝掉再做最終清洗。有一種比較隱蔽的情況是Markdown 代碼塊里的 HTML 標簽是合法內(nèi)容比如一份技術文檔的代碼示例里就寫了div。所以清掃標簽一定要在 AST 節(jié)點的“正文文本”層做而不是在原始 Markdown 全文做。層級一錯連代碼示例也被污染了。4.5 從 Markdown 轉檔時丟失的文檔元信息還有一類問題容易被忽略原始文檔的創(chuàng)建時間、作者、版本號、文號等信息。這些信息在解析階段如果被丟棄到了檢索階段想按時間過濾或按部門過濾就無能為力了。我的習慣是解析階段維護一份“文檔元信息”字典把文件名、首段描述、最近修改時間、來源路徑一并帶上。元信息和文本 chunk 是分開存儲的但在切塊時允許把某些元信息如版本號拼到對應標題下形成檢索端可用的過濾字段。這一步不是必須但在企業(yè)級知識庫場景下能省下大量返工成本。5. 結構化信息如何與切塊策略聯(lián)動解析環(huán)節(jié)完成之后接下來就到了切塊。很多教程把切塊說成“按 token 數(shù)切就行”但真正決定切塊質量的是你前面解析時保留了哪些結構信息。5.1 不要按固定字符數(shù)硬切固定字符數(shù)切塊的問題在于它無視標題邊界和段落邊界。即使你前面已經(jīng)把 Markdown 分成了結構塊最后硬切一刀下去依然會把一個結構塊從中間斬斷。所以我強烈建議在結構塊的基礎上做“語義最小單元”切分而不是“字符固定長度”切分。對一個結構塊我先看它內(nèi)部的段落、列表項、代碼塊的數(shù)量。如果數(shù)量只有一個且長度適中比如小于 800 token整個塊可以作為一個 chunk如果塊內(nèi)內(nèi)容過長我再按二級標題或段落進一步遞歸細分。這其實就是用解析得到的層級做了一次“有感知的切塊”。5.2 把標題層級寫進 chunk 元數(shù)據(jù)切塊之后每個 chunk 必須帶上它從哪個標題層級下切出來的。比如chunk: 系統(tǒng)要求建議使用 Python 3.10 及以上版本 metadata: { h1: 快速開始, h2: 環(huán)境準備, h3: 系統(tǒng)要求, source_file: docs/quickstart.md }這樣一個 chunk 在檢索時即使匹配到的只是片段也能通過元數(shù)據(jù)把完整的層級路徑呈現(xiàn)給最終用戶。很多生產(chǎn)級 RAG 項目里這一步直接決定了“回答的可追溯性”好不好。5.3 父子切塊與標題前綴拼接更進階一點的方案是做父子切塊父塊是某個二級標題下的完整章節(jié)子塊是按段落細分的片段。檢索時先召回子塊再根據(jù)元數(shù)據(jù)往上掛載父塊兩者一起給 LLM 當上下文。這種方式能顯著緩解“片段太碎、丟失大語境”的問題。但請注意父塊的長度不能失控。如果某個二級標題下有 10 屏內(nèi)容父塊就可能超出模型上下文窗口。因此我會給父塊設置一個軟上限比如 3000 token超過就自動提升一個標題層級再分。標題前綴拼接也是另一種解法在子塊文本前面加上從 H1 到當前標題的路徑字符串讓 chunk 自帶上下文效果也比較穩(wěn)。5.4 解析后的驗證清單解析流程跑完后不要直接去調(diào) embedding。先做一輪質量抽檢我自己的驗證清單大致這樣文本中不應該存在長段無換行的粘連內(nèi)容。標題層級路徑應該覆蓋絕大部分 chunk。表格和代碼塊應保持完整沒有在中間被切開。元數(shù)據(jù)與原始文件能一一對上定位無歧義。這一輪抽檢通常能發(fā)現(xiàn)引入臟亂數(shù)據(jù)源的問題避免把問題帶進向量庫。6. 通用解析模塊的工程化落地從腳本到服務到這里解析邏輯的原理和關鍵細節(jié)我們都過了一遍。最后聊聊工程化落地因為很多人寫完腳本就跑忽略了部署和維護周期里的幾個關鍵點。我見過太多“本地跑著沒問題一上線數(shù)據(jù)多了就卡死”的情況。6.1 解析模塊的可插拔設計我把解析模塊拆成三個獨立環(huán)節(jié)Loader負責讀取不同格式、Preprocessor負責清洗與格式嗅探、Structurer負責結構化切塊與元數(shù)據(jù)生成。每一環(huán)都面向接口編程不互相耦合。這樣做的好處是后續(xù)如果新增一種格式比如 epub、docx我只需要新寫一個 Loader復用后面的 Preprocessor 和 Structurer。如果某類文本有特殊清洗邏輯我也只需要新增一個 Preprocessor 實現(xiàn)不用動主干鏈路。6.2 性能與并發(fā)別在解析上出瓶頸解析邏輯以 IO 和正則為主性能瓶頸一般不在解析本身而在讀取大文件。幾百 MB 的 txt硬讀進內(nèi)存再處理內(nèi)存占用會一下子飆高。我的做法是對大文件先做分塊讀取按文件大小動態(tài)調(diào)整讀取塊大小解析后的中間結果寫臨時文件或對象存儲避免全部堆積內(nèi)存。6.3 失敗重試與臟數(shù)據(jù)隔離數(shù)據(jù)解析屬于典型的“輸入不可控”場景。有的文件編碼詭異有的文件內(nèi)容損壞。因此在工程實現(xiàn)上一定要對每個文件的解析結果做“成功/失敗/部分成功”三類標記。失敗的文件不是直接丟棄而是落入待人工復核隊列。部分成功的文件要把解析成功的 chunk 先入庫同時輸出一份“問題摘要”給運維人員。這套機制在長期運行的知識庫系統(tǒng)里極其重要。沒有它任何一個角落里的臟文件都會成為檢索回答出錯時最難排查的隱藏故障源。至此從 txt 到 Markdown 的通用文本與結構化解整條鏈路已經(jīng)完整呈現(xiàn)。我個人的體會是數(shù)據(jù)解析很難靠一次性到位它更像一個持續(xù)迭代的打磨過程——每一次新的數(shù)據(jù)來源都會帶來新的坑結構化解析的價值就是把這些坑提前在清洗和分層階段排掉而不是留給檢索階段“隨機爆炸”。下一篇系列文章里我打算接著寫 PDF 和 Word 這類富格式文檔的解析方案比 txt 和 Markdown 的復雜程度又要高出一個檔次。