據(jù)導(dǎo)入第一關(guān):LangChain解析txt與Markdown實(shí)戰(zhàn))
1. 為什么數(shù)據(jù)導(dǎo)入是 RAG 系統(tǒng)的第一道生死關(guān)做 RAG 的人都有一個(gè)共識(shí)檢索效果差八成問題出在數(shù)據(jù)導(dǎo)入和解析環(huán)節(jié)而不是模型本身。我見過太多團(tuán)隊(duì)花大價(jià)錢調(diào) embedding 模型、換向量庫、折騰重排序最后發(fā)現(xiàn)原始文檔解析出來就是一堆亂碼或者斷句錯(cuò)亂后面再怎么優(yōu)化都是白搭。這個(gè)項(xiàng)目標(biāo)題聚焦的是 RAG 數(shù)據(jù)導(dǎo)入與解析的第一環(huán)——從純文本 txt 到結(jié)構(gòu)化 Markdown 的通用文本與結(jié)構(gòu)化解析。說白了就是把各種格式的原始文檔通過 LangChain 的 Document Loader 體系統(tǒng)一轉(zhuǎn)換成帶元數(shù)據(jù)的 Document 對(duì)象并且盡可能保留原文的層級(jí)結(jié)構(gòu)標(biāo)題、列表、表格、代碼塊為后續(xù)的切分和向量化打好基礎(chǔ)。為什么單獨(dú)把 txt 和 Markdown 拎出來講因?yàn)檫@兩個(gè)格式是所有文檔解析的最小公倍數(shù)。你從 PDF、Word、HTML 里解析出來的內(nèi)容最終都要落到純文本或類 Markdown 的結(jié)構(gòu)上。如果連 txt 和 Markdown 的解析都沒搞明白直接上 PDF 解析那基本就是給自己挖坑。這篇文章適合剛接觸 RAG 的開發(fā)者、正在搭建知識(shí)庫的技術(shù)負(fù)責(zé)人以及被文檔解析折磨過的運(yùn)維同學(xué)。我會(huì)把 LangChain 的 Loader 體系拆開講透配上可直接復(fù)現(xiàn)的代碼和踩坑記錄。2. LangChain Document Loader 體系的核心設(shè)計(jì)邏輯2.1 Document 對(duì)象到底裝了什么LangChain 里所有 Loader 的產(chǎn)出都是Document對(duì)象這個(gè)對(duì)象只有兩個(gè)核心字段page_content和metadata??雌饋砗唵蔚@兩個(gè)字段的設(shè)計(jì)直接決定了你后面能不能做好檢索。page_content是字符串存的是文檔的實(shí)際文本內(nèi)容。metadata是字典存的是這條內(nèi)容的來源信息——文件路徑、頁碼、標(biāo)題層級(jí)、創(chuàng)建時(shí)間等等。很多人只關(guān)注page_content把metadata當(dāng)擺設(shè)這是大錯(cuò)特錯(cuò)。在實(shí)際檢索場(chǎng)景里metadata是你做過濾檢索和結(jié)果溯源的唯一依據(jù)。比如用戶問2023 年的財(cái)報(bào)里營收是多少你如果沒有在 metadata 里存年份和文檔類型就只能靠語義相似度硬匹配召回率會(huì)慘不忍睹。我個(gè)人的經(jīng)驗(yàn)是metadata 的設(shè)計(jì)要在導(dǎo)入階段就定好不要等到檢索階段再補(bǔ)。因?yàn)橐坏┫蛄炕瓿稍傧虢o已有的向量補(bǔ) metadata就得全量重新 embedding成本極高。2.2 為什么 Loader 要分這么多種LangChain 提供了幾十種 Loader從TextLoader、UnstructuredMarkdownLoader到PyPDFLoader、CSVLoader看起來冗余其實(shí)每一種都對(duì)應(yīng)一類文檔的解析特性。txt 文件沒有結(jié)構(gòu)解析邏輯最簡單但編碼問題最頭疼。Markdown 有明確的語法結(jié)構(gòu)#標(biāo)題、-列表、|表格解析時(shí)要決定是保留原始 Markdown 標(biāo)記還是轉(zhuǎn)成純文本。PDF 有版式信息需要處理分欄、頁眉頁腳、掃描件 OCR。CSV 有行列結(jié)構(gòu)要決定每一行是一個(gè) Document 還是整個(gè)表是一個(gè) Document。這個(gè)項(xiàng)目標(biāo)題選擇從 txt 和 Markdown 入手我認(rèn)為是非常務(wù)實(shí)的路徑。因?yàn)檫@兩個(gè)格式的解析邏輯是其他所有格式的基礎(chǔ)PDF 解析出來本質(zhì)上是帶頁碼的文本HTML 解析出來本質(zhì)上是帶標(biāo)簽的文本W(wǎng)ord 解析出來本質(zhì)上是帶樣式的文本。你把 txt 和 Markdown 的解析吃透了其他格式只是多了一層格式轉(zhuǎn)換的殼。2.3 通用解析與結(jié)構(gòu)化解析的分界線標(biāo)題里提到通用文本與結(jié)構(gòu)化解析這其實(shí)是兩種不同的處理策略。通用文本解析的目標(biāo)是把內(nèi)容完整取出來不關(guān)心結(jié)構(gòu)產(chǎn)出的是連續(xù)的文本流。TextLoader就是典型代表它把整個(gè)文件讀成一個(gè)字符串塞進(jìn)一個(gè) Document 里。這種方式適合內(nèi)容本身沒有明顯層級(jí)、或者你打算用固定長度切分的場(chǎng)景。結(jié)構(gòu)化解析的目標(biāo)是把內(nèi)容按層級(jí)拆開產(chǎn)出的是帶結(jié)構(gòu)信息的多個(gè) Document 或帶層級(jí) metadata 的 Document。UnstructuredMarkdownLoader配合modeelements就是典型代表它會(huì)把每個(gè)標(biāo)題、每個(gè)段落、每個(gè)列表項(xiàng)都拆成獨(dú)立的 element并標(biāo)注類型。這種方式適合需要精確定位、按章節(jié)檢索的場(chǎng)景。選擇哪種策略取決于你的檢索需求。如果你做的是整篇文檔問答通用解析就夠了。如果你做的是精確定位到某一節(jié)那必須用結(jié)構(gòu)化解析。我后面會(huì)給出兩種策略的完整代碼和效果對(duì)比。3. 從 txt 到 Markdown核心解析細(xì)節(jié)與實(shí)操要點(diǎn)3.1 TextLoader 的編碼陷阱與參數(shù)配置TextLoader看起來是最簡單的 Loader但它的坑一點(diǎn)都不少。最典型的就是編碼問題。中文文檔在 Windows 上經(jīng)常是 GBK 或 GB2312 編碼而TextLoader默認(rèn)用 UTF-8 讀取遇到非 UTF-8 文件直接拋UnicodeDecodeError。from langchain_community.document_loaders import TextLoader # 錯(cuò)誤示范不指定編碼遇到 GBK 文件直接崩 loader TextLoader(財(cái)報(bào).txt) docs loader.load() # UnicodeDecodeError # 正確做法顯式指定編碼 loader TextLoader(財(cái)報(bào).txt, encodingutf-8) docs loader.load() # 如果文件是 GBK需要這樣處理 loader TextLoader(財(cái)報(bào).txt, encodinggbk) docs loader.load()但問題是你不可能提前知道每個(gè)文件的編碼。我的做法是寫一個(gè)編碼探測(cè)函數(shù)用chardet庫自動(dòng)識(shí)別然后傳給TextLoader。import chardet from langchain_community.document_loaders import TextLoader def detect_encoding(file_path): with open(file_path, rb) as f: raw f.read(10000) # 只讀前 10KB 做探測(cè)避免大文件慢 result chardet.detect(raw) return result[encoding] file_path 財(cái)報(bào).txt encoding detect_encoding(file_path) loader TextLoader(file_path, encodingencoding) docs loader.load()注意chardet對(duì)短文本的探測(cè)準(zhǔn)確率不高如果文件很小小于 1KB建議直接嘗試 UTF-8失敗再回退到 GBK。另外TextLoader的autodetect_encoding參數(shù)在部分版本里可用但實(shí)測(cè)下來不如手動(dòng)探測(cè)穩(wěn)。還有一個(gè)容易被忽略的點(diǎn)TextLoader默認(rèn)把整個(gè)文件讀成一個(gè) Document。如果你的 txt 文件有 10MB那page_content就是一個(gè) 10MB 的字符串后面切分的時(shí)候會(huì)非常慢。我的建議是在導(dǎo)入階段就做一次粗切分比如按空行或按固定字符數(shù)切避免單個(gè) Document 過大。3.2 Markdown 解析的兩種模式單文檔 vs 元素級(jí)Markdown 的解析比 txt 復(fù)雜因?yàn)?Markdown 本身有結(jié)構(gòu)。LangChain 提供了UnstructuredMarkdownLoader它有兩種模式默認(rèn)模式和modeelements。默認(rèn)模式下整個(gè) Markdown 文件被讀成一個(gè) Documentpage_content是去掉 Markdown 標(biāo)記后的純文本。這種模式適合整篇問答但丟失了標(biāo)題層級(jí)信息。from langchain_community.document_loaders import UnstructuredMarkdownLoader # 默認(rèn)模式整個(gè)文件一個(gè) Document loader UnstructuredMarkdownLoader(技術(shù)文檔.md) docs loader.load() print(len(docs)) # 1 print(docs[0].page_content[:200]) # 純文本無 Markdown 標(biāo)記modeelements模式下每個(gè) Markdown 元素標(biāo)題、段落、列表項(xiàng)、代碼塊都被拆成獨(dú)立的 Document并且 metadata 里會(huì)標(biāo)注元素類型。# 元素級(jí)模式每個(gè)元素一個(gè) Document loader UnstructuredMarkdownLoader(技術(shù)文檔.md, modeelements) docs loader.load() print(len(docs)) # 可能是幾十個(gè) for doc in docs[:5]: print(doc.metadata[category], |, doc.page_content[:50])輸出大概是這樣Title | 第一章 系統(tǒng)概述 NarrativeText | 本系統(tǒng)采用微服務(wù)架構(gòu)... Title | 1.1 核心模塊 NarrativeText | 核心模塊包括... ListItem | 用戶管理模塊這種模式的好處是標(biāo)題層級(jí)被保留在 metadata 里你可以根據(jù)category做過濾比如只檢索NarrativeText類型的內(nèi)容跳過Title。壞處是 Document 數(shù)量暴增如果后面不做合并向量庫會(huì)被大量短文本撐爆。我的實(shí)操經(jīng)驗(yàn)是元素級(jí)解析后一定要做一次標(biāo)題合并。把每個(gè)Title和它下面的NarrativeText合并成一個(gè) Document這樣既保留了層級(jí)信息又不會(huì)產(chǎn)生太多碎片。def merge_by_title(docs): merged [] current_title current_content [] for doc in docs: if doc.metadata[category] Title: if current_content: merged.append({ title: current_title, content: \n.join(current_content) }) current_title doc.page_content current_content [] else: current_content.append(doc.page_content) if current_content: merged.append({ title: current_title, content: \n.join(current_content) }) return merged3.3 Markdown 表格與代碼塊的特殊處理Markdown 里的表格和代碼塊是兩個(gè)特殊存在。表格在UnstructuredMarkdownLoader里會(huì)被識(shí)別為Table類型但page_content里的內(nèi)容是制表符分隔的文本不是 Markdown 表格語法。代碼塊會(huì)被識(shí)別為CodeSnippet類型內(nèi)容保留原始代碼。這兩個(gè)類型在檢索時(shí)有個(gè)共同問題語義相似度匹配效果差。表格里的數(shù)字和代碼里的符號(hào)embedding 模型很難理解。我的做法是給這兩類內(nèi)容單獨(dú)打標(biāo)簽在檢索時(shí)要么排除要么用專門的檢索策略。# 給表格和代碼塊單獨(dú)打標(biāo)簽 for doc in docs: if doc.metadata[category] Table: doc.metadata[content_type] table elif doc.metadata[category] CodeSnippet: doc.metadata[content_type] code else: doc.metadata[content_type] text提示如果你的知識(shí)庫里有大量表格建議在導(dǎo)入階段就把表格轉(zhuǎn)成自然語言描述。比如把| 年份 | 營收 |轉(zhuǎn)成2023 年?duì)I收為 1000 萬元。這個(gè)轉(zhuǎn)換可以用 LLM 做雖然增加成本但檢索效果提升非常明顯。4. 完整實(shí)操流程從文件掃描到 Document 入庫4.1 目錄掃描與文件類型分發(fā)實(shí)際項(xiàng)目里你面對(duì)的不是單個(gè)文件而是一個(gè)目錄樹。第一步是掃描目錄根據(jù)文件擴(kuò)展名分發(fā)到不同的 Loader。import os from pathlib import Path from langchain_community.document_loaders import TextLoader, UnstructuredMarkdownLoader def scan_directory(root_dir): files [] for path in Path(root_dir).rglob(*): if path.is_file(): files.append(str(path)) return files def load_file(file_path): ext os.path.splitext(file_path)[1].lower() if ext .txt: encoding detect_encoding(file_path) loader TextLoader(file_path, encodingencoding) return loader.load() elif ext in [.md, .markdown]: loader UnstructuredMarkdownLoader(file_path, modeelements) return loader.load() else: return [] def load_directory(root_dir): all_docs [] for file_path in scan_directory(root_dir): docs load_file(file_path) # 給每個(gè) Document 補(bǔ)充來源信息 for doc in docs: doc.metadata[source] file_path doc.metadata[file_name] os.path.basename(file_path) all_docs.extend(docs) return all_docs這段代碼看起來簡單但有幾個(gè)細(xì)節(jié)要注意。rglob(*)會(huì)遞歸掃描所有子目錄如果目錄里有.git、node_modules這種無關(guān)目錄會(huì)浪費(fèi)大量時(shí)間。建議加一個(gè)忽略列表。IGNORE_DIRS {.git, node_modules, __pycache__, .venv} def scan_directory(root_dir): files [] for path in Path(root_dir).rglob(*): if any(ignore in path.parts for ignore in IGNORE_DIRS): continue if path.is_file(): files.append(str(path)) return files4.2 元數(shù)據(jù)標(biāo)準(zhǔn)化讓每條 Document 都可溯源元數(shù)據(jù)標(biāo)準(zhǔn)化是導(dǎo)入階段最容易被忽視、但后期最影響體驗(yàn)的環(huán)節(jié)。我建議至少包含這幾個(gè)字段字段名類型說明是否必填sourcestring文件絕對(duì)路徑是file_namestring文件名是file_typestring文件類型txt/md是categorystring元素類型Title/NarrativeText等結(jié)構(gòu)化解析時(shí)必填title_pathstring標(biāo)題層級(jí)路徑如第一章 1.1 核心模塊結(jié)構(gòu)化解析時(shí)建議填create_timestring文件創(chuàng)建時(shí)間建議填content_typestring內(nèi)容類型text/table/code建議填title_path這個(gè)字段特別有用。它記錄了當(dāng)前內(nèi)容所屬的完整標(biāo)題路徑檢索時(shí)可以直接展示給用戶這段內(nèi)容來自《第一章 1.1 核心模塊》溯源體驗(yàn)直接拉滿。def build_title_path(docs): title_stack [] for doc in docs: if doc.metadata.get(category) Title: # 根據(jù)標(biāo)題層級(jí)調(diào)整棧 level doc.metadata.get(level, 1) title_stack title_stack[:level-1] title_stack.append(doc.page_content) doc.metadata[title_path] .join(title_stack) return docs4.3 切分策略從 Document 到 Chunk 的過渡導(dǎo)入階段產(chǎn)出的 Document 還不能直接向量化因?yàn)楹芏?Document 太長比如一個(gè) 10MB 的 txt。需要先切分成 Chunk。LangChain 提供了RecursiveCharacterTextSplitter它按字符遞歸切分優(yōu)先在段落、句子邊界切盡量保持語義完整。from langchain.text_splitter import RecursiveCharacterTextSplitter splitter RecursiveCharacterTextSplitter( chunk_size500, chunk_overlap50, separators[\n\n, \n, 。, , , , , , ] ) chunks splitter.split_documents(docs)chunk_size500和chunk_overlap50是我常用的起點(diǎn)。chunk_size太小語義不完整太大檢索精度下降。chunk_overlap是為了避免關(guān)鍵信息剛好被切在邊界上。中文場(chǎng)景下separators里一定要加中文標(biāo)點(diǎn)否則切分會(huì)在句子中間斷開。注意RecursiveCharacterTextSplitter會(huì)保留原 Document 的 metadata所以切分后的每個(gè) chunk 都帶著source、title_path等信息溯源不會(huì)斷。4.4 向量化與入庫的銜接切分完成后就可以調(diào) embedding 模型向量化然后存入向量庫。這一步雖然不屬于導(dǎo)入解析但導(dǎo)入階段的設(shè)計(jì)直接影響這一步的效率。from langchain_community.embeddings import OllamaEmbeddings from langchain_community.vectorstores import Chroma embeddings OllamaEmbeddings(modelnomic-embed-text) vectorstore Chroma.from_documents( documentschunks, embeddingembeddings, persist_directory./chroma_db )這里有個(gè)經(jīng)驗(yàn)批量向量化比逐條快得多。Chroma.from_documents內(nèi)部會(huì)做批處理但如果你自己寫循環(huán)逐條add_documents速度會(huì)慢好幾倍。另外如果 chunk 數(shù)量超過幾千建議分批入庫避免內(nèi)存爆掉。5. 常見問題與排查技巧實(shí)錄5.1 編碼亂碼問題速查編碼問題是 txt 解析的頭號(hào)殺手。我整理了一個(gè)速查表現(xiàn)象可能原因解決方法UnicodeDecodeError文件非 UTF-8 編碼用 chardet 探測(cè)編碼中文顯示為亂碼編碼探測(cè)錯(cuò)誤手動(dòng)指定 gbk/gb2312部分字符丟失編碼不兼容轉(zhuǎn)成 UTF-8 后再處理讀取速度極慢文件過大分塊讀取或先切分我踩過最坑的一次是一個(gè) GBK 文件被 chardet 誤判為 ISO-8859-1結(jié)果中文全變成亂碼但程序不報(bào)錯(cuò)。這種問題最難排查因?yàn)椴粧伄惓?。我的建議是導(dǎo)入后抽樣檢查隨機(jī)打印幾條page_content肉眼確認(rèn)內(nèi)容正常。5.2 Markdown 解析后 Document 數(shù)量暴增怎么辦modeelements模式下一個(gè) 100KB 的 Markdown 可能產(chǎn)出上千個(gè) Document。如果直接全部向量化向量庫會(huì)被大量短文本比如單個(gè)列表項(xiàng)撐爆檢索時(shí)也會(huì)返回一堆碎片。解決方法有兩個(gè)。一是前面提到的標(biāo)題合并把同一標(biāo)題下的內(nèi)容合并成一個(gè) Document。二是過濾短文本把長度小于 20 個(gè)字符的 Document 丟掉。docs [doc for doc in docs if len(doc.page_content.strip()) 20]但過濾要小心有些短文本可能是關(guān)鍵信息比如是、否這種表格值。我的做法是對(duì)NarrativeText和ListItem做長度過濾對(duì)Title和Table不過濾。5.3 標(biāo)題層級(jí)丟失的補(bǔ)救方案UnstructuredMarkdownLoader在部分版本里不會(huì)在 metadata 里標(biāo)注標(biāo)題層級(jí)level字段導(dǎo)致title_path構(gòu)建失敗。這時(shí)候需要自己解析 Markdown 的#數(shù)量。import re def extract_title_level(text): match re.match(r^(#)\s, text) if match: return len(match.group(1)) return None如果連category都沒有那就只能退回到默認(rèn)模式用正則手動(dòng)提取標(biāo)題然后自己構(gòu)建 Document 列表。這條路雖然麻煩但可控性最強(qiáng)。5.4 大文件導(dǎo)入的內(nèi)存與速度優(yōu)化導(dǎo)入大文件時(shí)內(nèi)存和速度是兩個(gè)瓶頸。我的優(yōu)化清單流式讀取不要一次性f.read()用for line in f逐行讀分批處理每處理 100 個(gè)文件就入庫一次清空內(nèi)存并行解析用concurrent.futures多線程解析IO 密集型任務(wù)提速明顯跳過已處理文件用文件哈希做去重避免重復(fù)導(dǎo)入import hashlib def file_hash(file_path): hasher hashlib.md5() with open(file_path, rb) as f: for chunk in iter(lambda: f.read(8192), b): hasher.update(chunk) return hasher.hexdigest()把文件哈希存到數(shù)據(jù)庫每次導(dǎo)入前先查哈希已存在就跳過。這個(gè)簡單的機(jī)制能省掉大量重復(fù)工作尤其是在調(diào)試階段反復(fù)導(dǎo)入同一批文件時(shí)。5.5 結(jié)構(gòu)化解析后檢索效果反而變差的排查有時(shí)候用了結(jié)構(gòu)化解析檢索效果反而比通用解析差。原因通常是切分粒度太細(xì)導(dǎo)致單個(gè) chunk 的語義信息不足。比如一個(gè)列表項(xiàng)只有用戶管理模塊五個(gè)字embedding 出來就是一個(gè)模糊的向量匹配不到具體問題。排查思路先看檢索返回的 chunk 內(nèi)容如果都是很短的片段那就是切分粒度問題。解決方法是在切分前先合并把同一標(biāo)題下的內(nèi)容合并成一個(gè)較長的 Document再切分。或者調(diào)整chunk_size讓它至少覆蓋一個(gè)完整的語義單元。6. 我個(gè)人的實(shí)操體會(huì)與后續(xù)擴(kuò)展方向這套從 txt 到 Markdown 的導(dǎo)入解析流程我在三個(gè)知識(shí)庫項(xiàng)目里都用過最深的體會(huì)是導(dǎo)入階段多花一小時(shí)做元數(shù)據(jù)標(biāo)準(zhǔn)化檢索階段能省十小時(shí)排查。很多人急著把數(shù)據(jù)灌進(jìn)去看效果結(jié)果檢索不準(zhǔn)回頭改導(dǎo)入邏輯又要全量重新向量化得不償失。另外一個(gè)小技巧在導(dǎo)入階段就做一次檢索模擬。隨便拿幾個(gè)預(yù)期問題用剛導(dǎo)入的數(shù)據(jù)跑一次檢索看看返回的 chunk 是不是你期望的。如果不對(duì)趁數(shù)據(jù)量還小趕緊調(diào)別等到幾萬條數(shù)據(jù)入庫了才發(fā)現(xiàn)問題。這個(gè)系列后續(xù)還可以往幾個(gè)方向擴(kuò)展。一是 PDF 和 Word 的解析重點(diǎn)講版式還原和表格提取。二是 HTML 和網(wǎng)頁內(nèi)容的解析重點(diǎn)講正文提取和噪聲過濾。三是多模態(tài)內(nèi)容的處理比如圖片 OCR 和圖表理解。每一類格式都有自己的坑但底層邏輯是一樣的把非結(jié)構(gòu)化數(shù)據(jù)轉(zhuǎn)成帶元數(shù)據(jù)的結(jié)構(gòu)化 Document為檢索服務(wù)。把 txt 和 Markdown 這兩個(gè)基礎(chǔ)格式吃透后面的擴(kuò)展就是水到渠成的事。