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