跳至主要内容

Day 18:RAG(一)索引端 — 八成的失敗在提問之前就決定了

· 閱讀時間約 6 分鐘
Willis Chen
Tech Instructor

今天進入生成式 AI 職缺中出現頻率最高的主題:RAG(48.1%)。

因為它太重要,我用三天處理:今天講索引端(文件怎麼進去)、明天講檢索端(怎麼找出來)、後天講生成與評測(怎麼用、怎麼知道好不好)。

如果你只能讀一天,讀今天——因為多數 RAG 系統的品質上限,在文件進資料庫的那一刻就決定了

今天要解決的問題

一個真實的抱怨:

「我們把公司 3,000 份文件都放進去了,但它還是常常答不出來。是不是要換更強的模型?」

換模型幾乎不會解決這個問題。因為問題通常出在:使用者問「請假規定是什麼」,而檢索取回的是三段被攔腰切斷的段落——第一段結尾寫「請假規定如下:」,第二段是表格的中間三列,第三段從「……但主管得視情況調整」開始。

模型拿到這種脈絡,只能靠猜。這不是模型的問題,是索引的問題。

📊 職缺訊號

「RAG 與企業知識庫」出現在 48.1% 的生成式 AI 職缺中,是最高的任務訊號。值得注意的是 JD 的用詞已經從早期的「熟悉 RAG」進化為「優化檢索品質」「處理企業文件的權限與更新」——市場已經過了「會做 RAG」的階段,進入「會把 RAG 做好」的階段。


一、現象:RAG 的兩條管線

先建立全局:

image

圖 18-1:RAG 的兩條管線(示意架構)。兩者共用同一個嵌入模型是硬性要求——換模型就必須重建索引。

今天處理上半部。 這半部的特性是:做錯了,下半部再怎麼優化都補不回來。

二、原理:切塊策略決定品質上限

切塊(chunking)看起來很無聊,卻是 RAG 品質最大的單一變因。

四種切塊策略的比較

圖 18-2:四種切塊策略對同一份文件的切法比較(示意對照)。固定大小會攔腰切斷語意;遞迴切分尊重段落句讀;結構感知沿標題切;父子塊以小塊檢索、大塊生成。

策略做法適合問題
固定大小每 500 字元切一刀快速原型攔腰切斷表格、清單、句子(開頭那個案例)
遞迴切分優先在段落、換行、句號處切一般文件的合理預設對結構化文件仍不夠好
結構感知沿標題階層切(H1/H2/H3)技術文件、法規、SOP需要文件本身結構良好
父子塊用小塊做檢索,命中後回傳所屬的大塊企業文件的最佳解實作較複雜、儲存量較大

父子塊為什麼是最佳解? 因為它同時解決兩個矛盾的需求:

  • 檢索需要小塊——語意集中、向量才精準。
  • 生成需要大塊——脈絡完整、模型才答得好。

用小塊命中、回傳大塊,兩者兼得。

三、動手:建一個能用的索引

3.1 切塊:結構感知 + 重疊

"""chunk.py —— 結構感知切塊,保留標題階層作為 metadata。"""
import re
from langchain_text_splitters import RecursiveCharacterTextSplitter

def split_markdown(text: str, source: str):
"""依標題切段,並把標題路徑寫進 metadata(明天檢索時會用到)。"""
chunks, heading_stack, buf = [], [], []

def flush():
if buf:
body = "\n".join(buf).strip()
if body:
chunks.append({
"text": " > ".join(heading_stack) + "\n" + body, # ← 標題放進內文
"metadata": {
"source": source,
"heading_path": " > ".join(heading_stack),
"level": len(heading_stack),
},
})
buf.clear()

for line in text.split("\n"):
m = re.match(r"^(#{1,4})\s+(.*)", line)
if m:
flush()
level = len(m.group(1))
heading_stack[:] = heading_stack[:level - 1] + [m.group(2).strip()]
else:
buf.append(line)
flush()

# 過長的段落再用遞迴切分,並保留重疊避免切斷語意
splitter = RecursiveCharacterTextSplitter(
chunk_size=800, chunk_overlap=120,
separators=["\n\n", "\n", "。", "!", "?", ";", ",", " ", ""],
)
out = []
for c in chunks:
for piece in splitter.split_text(c["text"]):
out.append({"text": piece, "metadata": c["metadata"]})
return out

兩個關鍵設計

  1. 把標題路徑寫進切塊內文人事規章 > 請假規定 > 特休)。這讓一個孤立的段落也帶著它的上下文,對嵌入品質的提升非常明顯。
  2. separators 要含中文標點。預設的分隔符是為英文設計的,中文文件用預設值會切得很難看。

3.2 嵌入:選型與那條硬性規則

"""embed.py —— 嵌入模型的選擇與使用。"""
from sentence_transformers import SentenceTransformer

# 中文場景的選型考量:多語支援、序列長度上限、維度(影響儲存與速度)
MODEL_NAME = "BAAI/bge-m3" # 多語、支援長序列,中文表現穩定
model = SentenceTransformer(MODEL_NAME)

def embed(texts: list[str], is_query: bool = False):
"""注意:部分模型對「查詢」與「文件」需要不同的前綴,用錯會顯著掉分。"""
if is_query and "bge" in MODEL_NAME:
texts = [f"為這個句子生成表示以用於檢索相關文章:{t}" for t in texts]
return model.encode(texts, normalize_embeddings=True)

⚠️ RAG 的第一條硬性規則

建立索引與查詢時,必須使用同一個模型的同一個版本。 換了模型(甚至只是升版)就必須重建整個索引,因為新舊向量不在同一個空間裡,相似度計算完全沒有意義。

實務做法:把嵌入模型名稱與版本寫進索引的 metadata,服務啟動時比對,不一致就拒絕啟動——寧可起不來,也不要靜默地給出爛結果

3.3 向量庫:選型與索引原理

HNSW 索引的分層搜尋

圖 18-3:HNSW 索引的運作直觀(示意架構)。上層稀疏圖快速逼近目標區域,逐層下降後在底層稠密圖精細搜尋——以可調的精度損失換取數量級的速度提升。

要理解的核心概念是「近似」:HNSW 是近似最近鄰演算法,它不保證找到真正最相似的前 k 筆。這個取捨由參數控制(ef_search 越大越準也越慢),而多數人不知道自己在做這個取捨。

"""index.py —— 用 Chroma 建立索引(本機可跑,適合起步)。"""
import chromadb

client = chromadb.PersistentClient(path="./chroma_db")
collection = client.get_or_create_collection(
name="company_docs",
metadata={"hnsw:space": "cosine", "embedding_model": MODEL_NAME}, # 記下模型
)

def index_documents(chunks):
collection.add(
ids=[f"{c['metadata']['source']}#{i}" for i, c in enumerate(chunks)],
embeddings=embed([c["text"] for c in chunks]).tolist(),
documents=[c["text"] for c in chunks],
metadatas=[{
**c["metadata"],
"dept": c["metadata"].get("dept", "all"), # ← 權限過濾用(Day 27)
"updated_at": "2026-07-25", # ← 增量更新用
} for c in chunks],
)
向量庫適合注意
Chroma原型、小型專案(< 100 萬筆)本機即可跑,正式環境需評估
pgvector已有 PostgreSQL、要與關聯資料一起查中等規模最務實的選擇
Qdrant/Milvus大規模、需要進階過濾與分散式需要獨立維運
雲端託管(各家向量服務)要少維運資料主權需評估(Day 27)

四、取捨:企業文件的三個真實難題

教學範例都用乾淨的 Markdown,真實企業文件不是這樣。三個必然遇到的問題:

難題症狀處理方向
PDF 解析表格變成亂碼、雙欄排版讀成交錯的句子、掃描檔沒有文字層用版面感知的解析工具;掃描檔先做 OCR;表格單獨處理成 Markdown 表格
權限業務看到人資的薪資文件metadata 帶部門/機密等級,在檢索階段就過濾(Day 27 詳談)
更新文件已刪除,助理還在引用建立增量更新管線,刪除必須連動——這是最常被漏掉的一環

💡 給起步者的最小可行索引

別一開始就追求完美。第一版這樣做就好:

  1. 只放一個部門、格式最乾淨的 50 份文件(不要一次 3,000 份)。
  2. 用結構感知切塊,chunk_size=800overlap=120
  3. metadata 至少放:source(來源檔名與頁碼)、dept(權限)、updated_at(更新)。
  4. 先驗證這 50 份的檢索品質(明天講怎麼量),再擴大。

範圍小而品質好的 RAG,遠比範圍大而品質差的有用。 後者會讓使用者第三次答錯後就再也不用了——而使用者的信任一旦失去,比技術問題難修得多。


今日小結

  • RAG 有兩條管線:離線索引線上查詢。索引做錯了,查詢端再怎麼優化都補不回來。
  • 切塊策略是最大的單一變因。父子塊(小塊檢索、大塊生成)是企業文件的最佳解,因為它同時滿足「檢索要小、生成要大」的矛盾需求。
  • 中文切塊的兩個實務要點:把標題路徑寫進切塊內文分隔符要含中文標點
  • 硬性規則:索引與查詢必須用同一個嵌入模型版本,換模型就要重建索引;把模型版本寫進 metadata 並在啟動時比對。
  • HNSW 是近似演算法,精度與速度是可調的取捨——要知道自己在調什麼。
  • 企業文件的三個真實難題:PDF 解析、權限過濾、刪除連動(最常被漏)。
  • 起步策略:先做 50 份文件並驗證品質,再擴大

明天預告

索引建好了,明天處理檢索端:為什麼純向量檢索會漏掉「型號 A-2350」這種精確關鍵字、混合檢索與重排序各能提升多少、以及那個所有 RAG 系統都會遇到的問題——使用者問「那它的保固呢」,系統完全不知道「它」是什麼

我也會給一張「每加一層優化,品質上升多少、延遲增加多少」的取捨圖。

延伸閱讀


← Day 17

本文同步發布於 iThome 鐵人賽;Blog 版本為站內閱讀與系列導覽的主要版本。