iT邦幫忙

2026 iThome 鐵人賽

DAY 18
0
AI Engineering

從 4,343 筆職缺到 AI Engineer:MLOps × GenAI Engineering 雙主軸實戰系列 第 18

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

  • 分享至 

  • xImage
  •  

今天進入生成式 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:LLM 應用骨架 — 從 LINE Bot 到會自己決定的系統
系列文
從 4,343 筆職缺到 AI Engineer:MLOps × GenAI Engineering 雙主軸實戰18
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言