據(jù)進RAG全攻略:從CSV到數(shù)據(jù)庫的導入與實戰(zhàn))
表格類數(shù)據(jù)進 RAG我一直覺得是被低估的硬骨頭。文本類文檔大家已經(jīng)玩得比較順了PDF、Word、Markdown 各有各的解析套路但一換成 CSV、Excel、數(shù)據(jù)庫表很多人就卡住了直接整表轉(zhuǎn)文本丟進向量庫檢索效果稀碎每行單獨轉(zhuǎn)成 Document又不知道元數(shù)據(jù)怎么掛才合理真要連數(shù)據(jù)庫連接串、查詢語句、增量同步又是一堆新問題。這篇是 RAG 數(shù)據(jù)導入與解析系列的第三篇專門把表格和數(shù)據(jù)庫這條路走一遍從 CSV 到 Excel 再到 LlamaHub 連庫實戰(zhàn)每一步我都會給出可復現(xiàn)的代碼和實際踩坑記錄。先說清楚這篇適合誰你已經(jīng)在用 LlamaIndex 或類似框架處理過文本數(shù)據(jù)想把手里的結構化數(shù)據(jù)訂單表、配置表、產(chǎn)品清單真正接進 RAG或者你還沒動手但對「表格數(shù)據(jù)到底該怎么進知識庫」這件事沒想明白想先看一條完整的落地方案。這篇不堆概念全是實操。1. 表格數(shù)據(jù)在 RAG 里的特殊位置為什么不能照抄文檔解析的老路子很多人拿到表格數(shù)據(jù)后的第一反應是跟 PDF 一樣讀進來切塊丟進索引完事。這個思路對文本沒問題但對表格幾乎一定會翻車。想明白為什么是這篇所有操作的基礎。1.1 二維數(shù)據(jù)與「一刀切」切塊的沖突文本是線性的段落和段落之間有天然的順序關系所以按固定窗口切塊、加一點重疊語義損失通??山邮?。但表格是二維的橫向是字段列名縱向是記錄行每一格的語義價值高度依賴它的表頭。如果你把整張表轉(zhuǎn)成一段長文本再按 token 切常見的結果有兩種。第一種情況一張 50 列、2000 行的表文本化之后可能上萬 token固定窗口切出來有的 chunk 會截斷前幾列有的 chunk 會把同一行數(shù)據(jù)拆到兩個 chunk 里。檢索的時候你命中了一個 chunk但關鍵的數(shù)值字段在另一個 chunk 里模型根本拿不到完整記錄。第二種情況更隱蔽某些表的列名本身沒有語義比如col1、col2、item_code、region_id。切塊后這些 chunk 里全是孤立數(shù)值embedding 模型對這些短數(shù)字片段的編碼效果非常差檢索召回基本靠運氣。所以處理表格數(shù)據(jù)的第一原則是不要默認走文本切塊 pipeline先看表的形態(tài)再決定每一行、每一塊到底要以什么粒度進入索引。1.2 記錄型與分析型先分清你手頭是什么表我習慣把表格先分成兩類處理邏輯完全不同。記錄型表格典型是業(yè)務流水、訂單明細、用戶列表每一行都是獨立的、完整的事實。這種表最適合的行粒度是「一行 一個 Document」因為一行內(nèi)的字段組合起來構成完整語義行與行之間沒有強上下文依賴。檢索場景通常是「查某個特定條件下的記錄」比如某訂單金額、某客戶所在地區(qū)。分析型表格典型是統(tǒng)計報表、匯總表、指標看板行與行、列與列之間存在維度關系某一格單獨拿出來沒意義必須依賴表格整體結構。比如「華東區(qū)一季度銷售額」它的上下文是行表頭和列表頭的組合。這種表直接按行拆會丟失結構比較合理的做法是先做「表格摘要 局部摘錄」或者把表按業(yè)務語義塊拆分再把每個塊轉(zhuǎn)成自然語言描述而不是單純貼數(shù)值。這兩種表如果混在一起統(tǒng)一處理后面召回測試會很難受因為你對「正確結果」的判斷標準都不一樣。1.3 從「最終要回答什么問題」倒推導入方案這是我想強調(diào)的習慣動手導入之前先列出業(yè)務側真實會問的問題。我做某個項目時對方列了一堆表要導入我讓他們先給我十個問題。結果發(fā)現(xiàn)他們關心的幾乎都是「某個商品最近 30 天在華南區(qū)的銷量」「哪幾個訂單超過萬元并且還沒發(fā)貨」這類帶條件的查詢。這些問題直接決定了三件事一是必須保留哪些字段作為過濾條件二是哪些字段應該寫進正文文本三是每行文本應該如何組織語言。反過來看如果一開始就把所有表不分青紅皂白轉(zhuǎn)文本入庫檢索會把大量無關行撈回來query engine 再聰明也被噪聲淹沒。這個環(huán)節(jié)不寫代碼但我覺得它比代碼更重要。表格數(shù)據(jù)進 RAG本質(zhì)上不是「把數(shù)據(jù)塞進去」而是「為特定問題設計一種可檢索的文本視圖」。2. CSV 導入實戰(zhàn)不止 read_csv 一下就完事CSV 看起來最簡單其實最容易出問題。我見過太多「CSV 文件插入向量庫之后一問三不知」的情況原因大多不在向量庫而在 CSV 導入那一步的數(shù)據(jù)質(zhì)量和行文本組織。2.1 數(shù)據(jù)體檢編碼、分隔符、空值的那些事CSV 作為純文本格式第一關就是編碼。國內(nèi)環(huán)境中 GBK 編碼的 CSV 仍然大量存在Excel 導出的老版本 CSV 甚至默認 ANSI。如果你用 pandas 直接讀遇到 GBK 文件會直接拋 UnicodeDecodeError。處理方式很簡單import pandas as pd # 先探測編碼再讀取 def detect_encoding(file_path): import chardet with open(file_path, rb) as f: raw f.read(100000) return chardet.detect(raw).get(encoding, utf-8) enc detect_encoding(sales.csv) df pd.read_csv(sales.csv, encodingenc)分隔符也值得確認一下。部分業(yè)務系統(tǒng)導出的 CSV 不是逗號分隔而是|或\t還有的企業(yè)數(shù)據(jù)里字段本身含逗號但沒有按 CSV 標準加引號。這個不提前檢查后面整行解析錯位導入的文本全是亂的而且很難發(fā)現(xiàn)。建議讀取前先pd.read_csv一個 5 行的樣本打印出來看一眼??罩堤幚砀潜刈鲰棥H绻骋恍嘘P鍵字段是 NaN你直接str(row)會把nan寫進文本檢索時如果用戶問「哪些訂單沒填寫客戶名」模型會被這些nan誤導。導入前必須明確空值策略要么丟棄該行要么用「未知」等有效文本占位。2.2 每行轉(zhuǎn) Document還是整表轉(zhuǎn)大文本我在 1.1 里說了記錄型表按行轉(zhuǎn) Document。但這里有個容易忽略的細節(jié)Document的 text 字段不能是「列名: 值」的機械拼接而是要盡量轉(zhuǎn)成一句完整自然語言。舉個例子下面這樣的原始行order_idproductregionamountstatusA1001無線耳機華東1280已發(fā)貨機械拼接的結果是order_id: A1001, product: 無線耳機, region: 華東, amount: 1280, status: 已發(fā)貨。檢索「華東區(qū)發(fā)了多少錢的貨」時這句文本的語義密度很低因為 embedding 模型對這種「字段名短值」結構的編碼并不好。我推薦做一次行級文本化def row_to_text(row, table_desc訂單記錄): parts [] if order_id in row and pd.notna(row[order_id]): parts.append(f訂單編號為{row[order_id]}) if product in row and pd.notna(row[product]): parts.append(f商品為{row[product]}) if region in row and pd.notna(row[region]): parts.append(f銷售區(qū)域為{row[region]}) if amount in row and pd.notna(row[amount]): parts.append(f金額為{row[amount]}元) if status in row and pd.notna(row[status]): parts.append(f狀態(tài)為{row[status]}) return f這是一條{table_desc}{.join(parts)}。這樣轉(zhuǎn)出來的是自然語言短句embedding 檢索的命中率明顯提升。這個步驟多花不了多少時間但對結果是決定性的。我在多個項目里對比過機械拼接檢索 top-5 命中正確行概率大概 40%自然語言化之后能到 75% 以上。2.3 一套可直接用的 CSV 自定義導入模板LlamaHub 上其實有現(xiàn)成的 CSVReader但我很少直接用原因是它對 CSV 的假設太理想了默認 UTF-8、默認逗號分隔、沒有空值處理、按整個文件內(nèi)容作為一個大 Document 處理。對干凈的小文件它很方便但對真實業(yè)務數(shù)據(jù)不夠用。我更傾向于用 pandas 清洗 自定義 Document 構建import pandas as pd from llama_index.core.schema import Document def load_csv_as_documents( file_path, table_desc數(shù)據(jù)表, encodingNone, sep,, drop_emptyTrue, filter_empty_cols[order_id, amount], ): if encoding is None: encoding detect_encoding(file_path) df pd.read_csv(file_path, encodingencoding, sepsep, dtypestr) # 空值處理 df df.dropna(howall) # 全空行直接丟 if drop_empty and filter_empty_cols: df df.dropna(subsetfilter_empty_cols) # 遍歷每一行轉(zhuǎn)自然語言 Document documents [] for idx, raw_row in df.iterrows(): row raw_row.dropna() # 去掉 NaN 字段避免文本里出現(xiàn) nan text row_to_text(row, table_desc) documents.append( Document( texttext, metadata{ source: file_path, table: table_desc, row_index: idx, }, ) ) return documents注意我把dtypestr強制全部轉(zhuǎn)字符串避免 ID 變成科學計數(shù)法或日期被 pandas 改格式。然后空值字段直接不寫進文本只保留有效字段。這里有個 metadata 設計細節(jié)想多說一句row_index一定要留。后面如果發(fā)現(xiàn)某條檢索結果內(nèi)容有問題你能直接通過 row_index 回原表定位定位速度比全文搜原文件快得多。數(shù)據(jù)量一大這個習慣能救你很多次。3. Excel 導入實戰(zhàn)多 Sheet、合并單元格與公式緩存的坑Excel 比 CSV 麻煩一個數(shù)量級。CSV 是純數(shù)據(jù)Excel 里埋著格式、公式、多個 Sheet、合并單元格、日期類型等各種隱雷。我在項目里處理最多的問題有三個多 Sheet 映射、合并單元格導致的空值、公式值讀不到。3.1 選型PandasExcelReader 還是 openpyxl 自己來LlamaHub 里有兩個相關的 ReaderPandasExcelReader和PandasCSVReader之類。PandasExcelReader 的定位是「把每個 Excel 文件轉(zhuǎn)成一個或多個 Document」內(nèi)部用pd.read_excel對單 Sheet 的簡單表夠用但對復雜 Excel 不夠靈活。復雜場景我基本直接用pd.read_excel(file, sheet_nameNone)把所有 Sheet 一次性讀進來再逐個自定義處理。這樣每個 Sheet 是獨立的 DataFrame你可以為不同 Sheet 配置不同的 table_desc、不同的空值策略、不同的行文本模板。另外一個值得記住的點pd.read_excel需要指定引擎。.xlsx默認 openpyxl.xls需要 xlrd。舊版.xls文件現(xiàn)在反而少見但遇到時引擎報錯很容易讓人懵建議直接.xls文件先用 openpyxl 另存為.xlsx別跟它較勁。3.2 多 Sheet 怎么拆元數(shù)據(jù)怎么標多 Sheet 的坑在于不同 Sheet 可能是完全不同的業(yè)務表。比如一個「月度銷售報表.xlsx」里Sheet1 是訂單明細Sheet2 是區(qū)域匯總Sheet3 是產(chǎn)品目錄。如果你把它們當成同一個文件處理混在同一個索引里檢索時 Sheet 之間的相互干擾非常嚴重。正確做法是每個 Sheet 獨立構建 Document并且把sheet_name放進 metadataimport pandas as pd from llama_index.core.schema import Document def load_excel_workbook(file_path, sheet_to_table_descNone): # sheet_to_table_desc 是 {訂單明細: 訂單記錄, 區(qū)域匯總: 區(qū)域銷售統(tǒng)計} dfs pd.read_excel(file_path, sheet_nameNone, dtypestr) documents [] for sheet_name, df in dfs.items(): table_desc (sheet_to_table_desc or {}).get(sheet_name, sheet_name) # 清洗邏輯同 CSV略 for idx, raw_row in df.iterrows(): row raw_row.dropna() text row_to_text(row, table_desc) documents.append( Document( texttext, metadata{ source: file_path, sheet: sheet_name, table: table_desc, row_index: idx, }, ) ) return documentsmetadata 里加sheet字段后檢索時可以做到表級過濾——用戶問題里出現(xiàn)「區(qū)域匯總」相關描述時只在該 Sheet 的 Document 范圍內(nèi)檢索這比把所有 Sheet 混在一起喂給模型要準得多。后面第 5 章我會細講過濾和路由的用法。3.3 合并單元格和公式緩存兩個低頻但致命的問題合并單元格是 Excel 導入里最陰間的坑。比如一個產(chǎn)品目錄表產(chǎn)品名稱列做了縱向合并合并后pd.read_excel讀進來只有合并區(qū)域的第一行有值其余行全是 NaN。如果你不做處理導入后的文本里那些行就缺了產(chǎn)品名稱而數(shù)據(jù)本身是存在的你只是沒讀到。處理合并單元格的常見辦法是 forward fill# 對需要前向填充的列做合并單元格還原 df[產(chǎn)品名稱] df[產(chǎn)品名稱].ffill()注意ffill只能解決「上方合并」的情形如果有橫向合并單元格需要ffill(axis1)但橫向合并語義復雜建議先輸出到 Excel 里肉眼確認一遍再動。公式緩存又是另一個坑。某些 Excel 單元格是公式比如SUM(C2:C20)。openpyxl 讀單元格值時有data_onlyTrue參數(shù)能返回上次 Excel 打開文件時緩存的計算結果但如果文件是程序生成、從來沒被 Excel 打開過緩存就不存在讀出來是 None。pandas 的read_excel內(nèi)部按緩存值讀取一旦遇到無緩存公式你會得到一列空的數(shù)值而用戶看到原始 Excel 里明明是有的。我的建議導入 Excel 之前先用辦公軟件把文件打開并另存一遍這樣公式緩存基本都在如果文件是程序自動生成的優(yōu)先去源頭導出「值」而不是「公式」。另外可以在導入后做個數(shù)量級校驗統(tǒng)計每個數(shù)值列的 sum對比業(yè)務方給的手工數(shù)字差太多就說明公式或合并單元格出了問題。4. 數(shù)據(jù)庫連庫實戰(zhàn)LlamaHub DatabaseReader 用法拆解前兩章講的 CSV、Excel 本質(zhì)上還是「文件導入」會有更新不及時、數(shù)據(jù)漂移的問題。數(shù)據(jù)庫表的正確姿勢是連庫直接讀LlamaHub 里的 DatabaseReader 就是干這個的。這一章把配置、查詢參數(shù)、行文本化全部過一遍。4.1 為什么連庫而不是導出 CSV你可能會想「我從數(shù)據(jù)庫里 SELECT 出來導出個 CSV再走第二章的方案不就行了嗎」對于一次性遷移可以但如果是持久化知識庫連庫有不可替代的優(yōu)勢。第一時效性。很多 RAG 項目要求數(shù)據(jù)按天更新你要是定期導出 CSV 再觸發(fā)導入鏈路長、容易漏。連庫讀取可以做到每次構建索引時直接查最新數(shù)據(jù)或者做增量查詢。第二數(shù)據(jù)一致性。手工導出 CSV 容易丟類型、丟精度尤其大數(shù)值和帶時區(qū)的時間戳。直接從數(shù)據(jù)庫查詢拿到的值更準也能保留主鍵和索引字段方便后面增量去重。第三可溯源。連庫導入時可以直接把主鍵或業(yè)務 ID 放進 metadata出問題查原始記錄比靠 row_index 猜快得多。4.2 DatabaseReader 的配置、查詢參數(shù)與行文本化LlamaHub 的 DatabaseReader 核心依賴 SQLAlchemy所以只要你安裝了對應對應庫的方言驅(qū)動比如 psycopg2、pymysql就能連 PostgreSQL、MySQL、SQLite、SQL Server 等主流數(shù)據(jù)庫?;A用法如下from sqlalchemy import create_engine from llama_index.readers.database import DatabaseReader engine create_engine(postgresql://user:passwordlocalhost:5432/mydb) reader DatabaseReader(sql_databaseengine) documents reader.load_data( querySELECT * FROM orders WHERE created_at 2024-01-01 LIMIT 10000 )load_data的不同之處在于它需要你傳入一個query參數(shù)而不是像文件 Reader 那樣傳路徑。每個返回行會被轉(zhuǎn)成 Document列名會變成 metadata 的一部分。這個設計的好處是你可以用 SQL 完成大部分清洗工作WHERE過濾、條件聚合、甚至 JOIN 后直接 SELECT 出你想要的上下游上下文。但我實際操作中的體會是DatabaseReader 直接返回的 Document text 仍然偏機械基本是 key: value 形式。所以我對它的用法是先用它拿到 DataFrame 或 List[Dict]再走一遍自定義行文本化和 metadata 構建。如果你不想改源碼可以不用 load_data而是自己用 SQLAlchemy 查詢import pandas as pd from sqlalchemy import create_engine engine create_engine(postgresql://user:passwordlocalhost:5432/mydb) sql SELECT order_id, product, region, amount FROM orders WHERE created_at 2024-01-01 df pd.read_sql(sql, engine) # 復用第二章的 row_to_text 和 Document 構建邏輯 documents [] for idx, row in df.iterrows(): text row_to_text(row, 訂單記錄) documents.append(Document(texttext, metadata{ source: postgresql://mydb/orders, row_id: row.get(order_id), table: orders, }))這個方案的可控性更高適合不同庫之間字段差異大、需要逐個定制的情況。DatabaseReader 的價值在你圖省事、表結構干凈時最能體現(xiàn)表結構復雜時我建議自己寫查詢。4.3 行級自然語言轉(zhuǎn)換讓向量檢索真正「看懂」數(shù)值連庫讀數(shù)據(jù)有個額外挑戰(zhàn)數(shù)據(jù)庫表往往字段名是英文或縮寫比如pc_name、qty、amt_usd。如果你直接把這些英文列名帶進文本embedding 的效果會打折扣——中文用戶問「哪個產(chǎn)品代碼賣得最多」和你索引里的pc_name完全對不上。我目前最常用的方案是在 SQL 查詢層做好「列名自然語言映射」直接 SELECT 出自帶語義的文本字段SELECT order_id AS 訂單編號, product AS 商品名稱, region AS 銷售區(qū)域, amount AS 訂單金額元, status AS 發(fā)貨狀態(tài) FROM orders WHERE created_at 2024-01-01;然后把 pandas 讀出來的 DataFrame 逐行轉(zhuǎn)自然語言效果比直接讓 reader 轉(zhuǎn)好很多。此外某些庫里有枚舉型、字典型字段比如status 1表示已發(fā)貨。嚴格來說這屬于數(shù)據(jù)清洗但放在導入鏈路里做最高效SQL 層面 JOIN 字典表把1翻譯成「已發(fā)貨」再進 RAG。否則你問「已發(fā)貨的訂單」永遠匹配不到status1。5. 導入只是開始召回驗證與數(shù)據(jù)同步表格數(shù)據(jù)導入完成不等于 RAG 就能回答業(yè)務問題。入庫之后你要回答一個更實際的問題「它到底能不能把正確的那幾行撈出來。」這一章是全篇的臨門一腳也是最容易被跳過的一步。5.1 召回測試問一遍真實業(yè)務問題索引構建完不要著急接 query engine先做一次最小的召回測試。用index.as_retriever(similarity_top_k5)把業(yè)務方那十幾個真實問題逐一輸入直接看召回出來的 top-5 Document 文本。這一步不用讓 LLM 生成回答只看檢索質(zhì)量。舉例來說索引里是訂單表你問「華北區(qū)最近有哪些大額訂單」如果 top-5 里混著華南區(qū)的訂單說明 region 字段沒有在文本和 metadata 里被充分表達或者行文本生成得不夠清晰。這時候調(diào)行文本模板比調(diào) embedding 模型參數(shù)有效得多。如果發(fā)現(xiàn)某類問題總是召不回正確行大概率是行文本里缺少用戶會用的同義詞。比如用戶習慣說「大客」你的表里是「重點客戶」那就在行文本生成時把別名也拼進去。這類詞表工作很笨拙但效果好。5.2 用 metadata 做表級路由與過濾表格數(shù)據(jù)進 RAG 后多張表共存時最大的噪聲源是「問 A 表問題召回了 B 表數(shù)據(jù)」。解決的關鍵是 metadata filter。你可以在構建 retriever 時按表過濾retriever index.as_retriever( similarity_top_k5, filtersMetadataFilters( filters[MetadataFilter(keytable, value訂單記錄)] ) )更進一步可以用函數(shù)判斷問題的關鍵詞來決定使用哪個 filter。比如問題里出現(xiàn)「區(qū)域匯總」就過濾table區(qū)域銷售統(tǒng)計出現(xiàn)「訂單」就過濾table訂單記錄。這就是表級路由比讓 LLM 自己猜要穩(wěn)定得多。我建議每張表導入時都把一個標準化的table字段寫進 metadata并保證全庫統(tǒng)一命名。命名不規(guī)范比如一張叫「訂單記錄」、另一張叫「orders」路由就得寫兩套邏輯。5.3 增量更新的兩種策略與 doc_id 去重對于數(shù)據(jù)庫表這種變更頻繁的數(shù)據(jù)源全量重建只適合初期或小數(shù)據(jù)量場景。數(shù)據(jù)量大以后必須做增量。我實踐下來有兩條可行的路。第一條是時間戳增量。在表里加一個updated_at導入時記錄上次同步時間last_sync每次只查WHERE updated_at last_sync。這個方案防漏更新但對刪除操作不敏感某行被刪了索引里還留著。第二條是 doc_id 去重重建。給每個 Document 設置doc_id為業(yè)務主鍵比如order_A1001。同步時不管新增還是更新全部插入插入前先把已存在的同 doc_id 文檔刪掉或標記覆蓋。LlamaIndex 的index.delete(doc_id)和index.insert(document)配合就能做到。這個方案能處理更新和刪除但每輪要全量拉取需要同步的數(shù)據(jù)適合表數(shù)據(jù)量可控的場景。兩種方案也可以組合時間戳圈定范圍doc_id 去重保證更新。對大多數(shù)中小規(guī)模業(yè)務表這個組合已經(jīng)非常穩(wěn)了。關于同步頻率我的經(jīng)驗是不要每次都重建整個索引。如果只有幾百行增量直接 insert 新 Document 到已有索引里快很多。如果增量超過總量 30%重建可能更劃算因為向量索引的刪除累積會留下碎片檢索性能會慢慢下降。表格數(shù)據(jù)進 RAG我現(xiàn)在越來越覺得做得好的關鍵不在于用了多高級的 embedding 或者框架而在于導入前的數(shù)據(jù)建模和導入后的驗證閉環(huán)。CSV 要處理編碼和空值Excel 要解決 Sheet 和公式問題數(shù)據(jù)庫連庫要設計好查詢和行文本。每一步都有固定的坑踩過一遍之后后面基本就是復制模板的問題。按照上面這套流程走一遍你的表格知識庫應該能覆蓋絕大多數(shù)業(yè)務問答場景。