精華筆記

· @aihub.tw

RAG 系統實作

RAG 是什麼:長 context 不是萬靈丹

RAG 是什麼:長 context 不是萬靈丹

假設你幫公司導入了 Claude API,老闆第一個需求是:「讓 AI 能回答我們內部文件的問題。」你打開 Playground,貼進一份 PDF,它回答得很好。你很開心——直到你發現文件有 400 頁,單次輸入的 token 費用讓報銷系統差點當機,而且每次查詢都要把所有文件塞給模型,慢到難以接受。更根本的問題:公司文件每週都在更新,而模型的訓練資料有截止日期。你沒辦法每隔幾週就重新訓練一個模型,也沒辦法每次都把整間公司的知識庫塞進一次請求。

這就是 RAG 存在的原因。

這堂課適合誰 適合:有 LLM API 呼叫經驗、想把 AI 接上私有資料的工程師(本課程屬工程師專區)。需要基礎:Python 基本語法、會用 pip 安裝套件、看得懂函式呼叫與 dict 操作。前置課:本課程假設你完成過 Claude API 課或有同等經驗,能獨立呼叫聊天補全(Chat Completion)API。

這堂學什麼

  • RAG 解決的三個核心問題:私有資料、即時性、成本
  • 長 context 模型的真實局限——以及它何時確實比 RAG 更好(誠實分析,不是一面倒)
  • 索引管線(Indexing Pipeline)與查詢管線(Query Pipeline)的完整架構
  • 2026 年主流工具生態一覽:Embedding 模型、向量資料庫、Rerank 服務
  • 課程路線圖:7 堂課各自負責什麼,彼此如何接起來

RAG 解決的三個真實問題

RAG 全名 Retrieval-Augmented Generation(檢索增強生成)。核心思路只有一句話:不要把所有資料都塞給模型,而是先找出最相關的幾段,再把這幾段加進 prompt 讓模型回答。就像考試開書:你不需要把整本書背起來,只要在需要的時候翻到對的頁面。

但為什麼需要這樣做?有三個很具體的理由。

理由一:你的資料模型根本沒看過

GPT-4o、Claude Sonnet 的訓練資料有截止日期,而且根本不包含你公司的內部文件、你的產品說明書、你客戶的合約。有人會問:「那就微調(fine-tuning)吧?」微調適合調整模型的風格與格式,但不適合灌入大量知識——微調成本高、需要持續重跑,最重要的是模型學了之後無法精確說「這個答案出自第幾頁的哪段話」,幻覺(hallucination)問題依然存在。

RAG 的做法是:知識存在你的資料庫,模型在回答時現場去查。知識永遠是最新的,也永遠可以附上「此答案引用自 HR 政策手冊 v2.3 第 5 頁」的來源。

理由二:即時性

假設你的客服系統要回答「這款產品現在還有貨嗎?」庫存是每分鐘都在變動的資料,不可能訓練到模型裡。但如果你把最新庫存即時同步到向量資料庫,RAG 系統就能在查詢時拿到最新數字。這是長 context 無論如何辦不到的事——長 context 是「一次性輸入」,不是「持續更新的資料庫」。

理由三:規模與成本

一間公司的知識庫可能有幾萬份文件。如果每次問問題都要把全部文件塞進 context,費用和延遲都不可控。而 RAG 只把真正相關的 3~10 段文字放進 prompt,每次查詢的 token 量是固定的、可預測的。

長 context vs RAG 每次查詢 token 用量對比

長 context 不是萬靈丹——但有時它才是正確答案

這堂課的標題說「長 context 不是萬靈丹」,但我要先說一句反話:如果你的文件夠少,長 context 可能比 RAG 更省事。2026 年的主流模型 context window 都在 128K 到 1M token 之間,大約 200,000 tokens 可以放一本 300 頁的書。

什麼時候直接用長 context 更好

  • 文件總量不超過 100~150K tokens(一份合約、一份技術規格書)
  • 資料是靜態的,幾乎不更新
  • 你需要模型同時理解整份文件的上下文:例如跨章節推理、分析整本合約裡的矛盾條款、找出前後不一致的地方
  • 你在做原型開發,不想先花時間建向量管線——先驗證需求再建基礎設施

在這些情境下,「直接把文件塞進去」不只更簡單,有時回答品質還更好,因為模型看到了完整的上下文。

長 context 的真實局限

問題一:「迷失在中間」(Lost in the Middle)。多份研究顯示,把重要資訊放在長 context 的中段,模型的召回率會顯著下降——開頭和結尾記得最清楚。文件量一大、資訊散落各處,準確率就開始不穩定。

問題二:費用隨文件量線性成長。每次查詢都要付整份文件庫的 token 費用,沒辦法「只付用到的那幾段」。文件從 50 頁增加到 500 頁,費用就乘以十。

問題三:延遲高。塞 100K tokens 進去,首 token 延遲(TTFT)明顯比只塞 2K 慢。對需要快速回應的 API 產品來說,這個差距很明顯。

問題四:無法即時更新。每次文件改版,要重新把整份文件塞進 context。動態資料根本不適用。

決策框架

資料總量 < 50K tokens 且靜態 → 先試長 context,省得建管線,驗證需求後再評估要不要換 RAG。

超過 50K、或資料動態更新、或需要精確引用來源 → 建 RAG 管線。這堂課接下來的內容就是為這個場景而設計的。

2026 年最佳實踐:高品質的企業 AI 系統通常兩者都用——小範圍的精確文件用長 context 做深度理解,大規模知識庫用 RAG 做快速檢索,再合併結果。但這是進階架構,課程第 7 課會提到思路。現在先把 RAG 建好。

長 context 或 RAG 的決策樹

RAG 的完整架構

RAG 分兩條管線:索引管線(Indexing Pipeline)只跑一次或定期跑;查詢管線(Query Pipeline)則是每次使用者問問題時都執行。這兩條管線跑的時間點不同,是最多人看架構圖時搞混的地方。

索引管線:把資料變得可查詢

原始文件(PDF / Word / HTML / 資料庫)
        ↓  文件解析(Parser)
純文字 + metadata(頁碼、日期、標題…)
        ↓  切塊(Chunking)
多個文字片段(chunk 1、chunk 2… chunk N)
        ↓  Embedding 模型
每個 chunk 的向量(一串 float 數字)
        ↓  寫入向量資料庫
可以做相似度搜尋的索引

這條管線的工作是「把資料準備好」。每次有新文件或文件更新,就重跑這條管線——它是離線(offline)的,不影響使用者體驗。

關鍵決策點在中間兩步:切塊的方式決定了「可以找到的最小資訊單位」是什麼,Embedding 模型決定了「語意相似」的定義是什麼。這兩個選擇錯了,後面再怎麼調都救不了。第 2 課和第 4 課會深入討論。

查詢管線:即時找到最相關的資訊

使用者的問題
        ↓  用同一個 Embedding 模型轉成向量
查詢向量(query embedding)
        ↓  向量相似度搜尋(ANN Search)
Top-K 個最相近的 chunks(例如前 20 個)
        ↓  (選用)Rerank 精排
最終 3~5 個最相關的 chunks
        ↓  組合成 prompt + 送給 LLM
有來源引用的回答

查詢管線把使用者的問題也轉成向量,用這個向量在資料庫裡找「語意最相似」的幾段文字,把這幾段加進 prompt 讓 LLM 生成有根據的回答。整個流程通常在 300~800ms 內完成。

重要注意:索引和查詢必須用同一個 Embedding 模型。用不同模型產生的向量放在同一個空間裡比較相似度,結果完全沒意義——就像用公尺量的距離跟用英尺量的距離直接比大小一樣。把模型名稱定義成常數,讓兩條管線都引用同一個值。

RAG 完整架構:索引管線與查詢管線

2026 生態盤點

選工具之前先看全局,避免到第 3 課才發現選錯架構。

Embedding 模型

Embedding 模型把文字轉成向量。向量的品質決定了「語意相似」找得準不準。

模型 維度 定價(每百萬 tokens) 特點
OpenAI text-embedding-3-small 1,536 $0.02(批次 $0.01) 最便宜、用途廣,主力首選
OpenAI text-embedding-3-large 3,072 $0.13(批次 $0.065) 精度高,適合法律/學術場景
Voyage voyage-4-large 可調 $0.18* 2026/01 發布,MoE 架構,MTEB 頂端
Voyage voyage-4-lite 可調 $0.06* 平衡精度與速度,中文效果佳
Voyage voyage-4-nano 256~1024 開源免費 Apache 2.0,可完全本機跑

*Voyage 4 系列:每帳號前 2 億 tokens 免費。Voyage AI 由 Anthropic 投資,於 2026 年 1 月推出第四代模型,採用混合專家(MoE)架構,voyage-4-large 的服務成本比同等精度的稠密模型低約 40%;另提供業界首個「共用向量空間」:不同大小的 voyage-4 模型產生的向量可以互相比較,讓你在查詢端用小模型壓低延遲、文件端用大模型衝精度。

本課程選擇:實戰章節統一用 text-embedding-3-small(最易取得、成本最低、文件完整)。想衝精度的讀者可直接換成 Voyage——兩者 API 介面相近,切換只需改幾行程式碼和一個常數。

向量資料庫

向量資料庫負責儲存向量並快速做相似度搜尋(Approximate Nearest Neighbor, ANN)。

工具 類型 最適場景
Chroma 輕量 Python 函式庫 本機原型、學習、快速驗證
pgvector PostgreSQL 擴充套件 已有 Postgres,向量量 < 5000 萬
Qdrant 獨立服務(Rust) 生產環境,速度與功能並重
Pinecone 全託管 SaaS 零 ops 優先,2GB 免費額度

2026 年社群共識:

  • 原型 / 學習:Chroma,pip install chromadb 兩行就能跑,不用架任何服務
  • 現有 Postgres 系統:pgvector 自 0.5.0 支援 HNSW 索引後,百萬向量以下效能不輸專門的向量庫
  • 生產環境 > 100 萬向量且速度敏感:Qdrant(Rust 撰寫,10M 向量 P99 延遲約 12ms,優於同量級競品的 16ms)
  • 不想管 infra:Pinecone serverless 2GB 永久免費,適合小型產品

本課程第 3 課會詳細實作 Chroma(開發環境)和 pgvector(生產環境)兩條路線。

Rerank 服務

初始向量搜尋用餘弦相似度,速度快但不精確——它只看「向量距離」,不理解「問題和文件段落的真實語意關係」。Rerank 模型是第二道篩選:把前 2050 個候選結果用交叉編碼器(cross-encoder)重新打分排序,取最精準的 35 個送給 LLM。

  • Cohere Rerank v3.5:約 $2 / 1,000 次搜尋(1 次搜尋 = 1 個查詢 + 最多 100 份文件),業界廣泛採用,支援 VPC 部署
  • Voyage Rerank:與 Voyage Embedding 同生態,共享 2 億 tokens 免費額度
  • Cross-encoder 本機跑:HuggingFace 有開源選項(如 cross-encoder/ms-marco-MiniLM-L-6-v2),適合資料不能出境的場景

Rerank 是第 5 課的重點。這裡先知道它的存在:如果你的 RAG 準確度不夠好,加 Rerank 通常是 ROI 最高的優化手段。

2026 RAG 工具生態全景圖

手把手實戰:建一個可以跑通的最小 RAG

理論夠了,動手做一個能跑的最小版本。用 Chroma(持久化模式)和 OpenAI Embedding——不用架任何伺服器,在本機就能完整跑通索引管線和查詢管線。

安裝依賴,準備環境

pip install chromadb openai python-dotenv

在專案根目錄建一個 .env 檔:

OPENAI_API_KEY=sk-...

確認 .gitignore 裡有 .env 這一行。金鑰不能進版本控制——第 6 課會再強調這一點。

準備測試文件

documents.py,用三段模擬的公司文件當測試資料:

# documents.py
docs = [
    {
        "id": "doc-001",
        "text": (
            "公司的遠端工作政策:員工每週最多可在家工作三天,"
            "但每週四必須到辦公室出席。出差期間不計入遠端工作天數。"
        ),
        "source": "HR 政策手冊 v2.3",
    },
    {
        "id": "doc-002",
        "text": (
            "產假與陪產假:主要照顧者享有 16 週全薪產假,"
            "次要照顧者享有 5 天全薪陪產假。需提前 8 週書面申請。"
        ),
        "source": "HR 政策手冊 v2.3",
    },
    {
        "id": "doc-003",
        "text": (
            "2026 年 Q2 業績:台灣區營收 NTD 1.2 億,"
            "較去年同期成長 18%。主要成長來源為企業授權方案,佔總營收 62%。"
        ),
        "source": "Q2 財報摘要",
    },
]

建立索引管線

index.py,把文件轉成向量並存入 Chroma:

# index.py
from dotenv import load_dotenv
import chromadb
from openai import OpenAI
from documents import docs

load_dotenv()

# 索引和查詢要用同一個模型,定義成常數避免兩邊各自寫死
EMBED_MODEL = "text-embedding-3-small"

openai_client = OpenAI()
# PersistentClient:資料寫進磁碟,重啟後不消失
chroma_client = chromadb.PersistentClient(path="./chroma_data")
collection = chroma_client.get_or_create_collection(
    name="company_docs",
    metadata={"hnsw:space": "cosine"},  # 使用 cosine 相似度
)


def embed_texts(texts: list[str]) -> list[list[float]]:
    """批次呼叫 OpenAI Embedding API"""
    response = openai_client.embeddings.create(
        model=EMBED_MODEL,
        input=texts,
    )
    return [item.embedding for item in response.data]


# 批次 embed 所有文件文字,再寫進向量庫
vectors = embed_texts([doc["text"] for doc in docs])

collection.add(
    ids=[doc["id"] for doc in docs],
    embeddings=vectors,
    documents=[doc["text"] for doc in docs],
    metadatas=[{"source": doc["source"]} for doc in docs],
)

print(f"索引完成,共 {collection.count()} 筆文件")

執行:

python index.py
# 索引完成,共 3 筆文件

執行後你會看到專案目錄多了一個 chroma_data/ 資料夾——向量就存在這裡。

建立查詢管線

query.py,實作完整的「問題→向量搜尋→組 prompt→LLM 回答」流程:

# query.py
from dotenv import load_dotenv
import chromadb
from openai import OpenAI

load_dotenv()

EMBED_MODEL = "text-embedding-3-small"  # 必須和 index.py 一致
SIMILARITY_THRESHOLD = 0.60  # 低於這個相似度的 chunk 不送給 LLM

openai_client = OpenAI()
chroma_client = chromadb.PersistentClient(path="./chroma_data")
collection = chroma_client.get_collection("company_docs")


def query_rag(question: str, top_k: int = 5) -> str:
    # 步驟 1:把問題也轉成向量
    q_embedding = openai_client.embeddings.create(
        model=EMBED_MODEL,
        input=[question],
    ).data[0].embedding

    # 步驟 2:向量相似度搜尋
    results = collection.query(
        query_embeddings=[q_embedding],
        n_results=top_k,
        include=["documents", "metadatas", "distances"],
    )

    chunks = results["documents"][0]
    sources = [m["source"] for m in results["metadatas"][0]]
    # Chroma cosine 模式回傳「距離」,1 - distance = similarity
    similarities = [round(1 - d, 4) for d in results["distances"][0]]

    # 步驟 3:過濾掉相似度太低的 chunk
    filtered = [
        (chunk, source, sim)
        for chunk, source, sim in zip(chunks, sources, similarities)
        if sim >= SIMILARITY_THRESHOLD
    ]

    if not filtered:
        return "根據目前的文件庫,找不到與這個問題相關的資訊。"

    # 步驟 4:組合 context
    context_parts = [
        f"[來源: {source} | 相似度: {sim}]\n{chunk}"
        for chunk, source, sim in filtered
    ]
    context = "\n\n---\n\n".join(context_parts)

    # 步驟 5:送給 LLM 生成回答
    system_prompt = (
        "你是公司內部知識庫助理。根據提供的文件內容回答問題。"
        "如果文件裡找不到答案,請直接說「這份文件沒有包含這個資訊」,"
        "不要猜測或補充訓練資料的知識。回答末尾請列出引用的來源名稱。"
    )
    user_prompt = f"文件內容:\n{context}\n\n問題:{question}"

    response = openai_client.chat.completions.create(
        model="gpt-4o-mini",
        messages=[
            {"role": "system", "content": system_prompt},
            {"role": "user", "content": user_prompt},
        ],
        temperature=0,  # 設 0 讓回答盡量穩定、不亂發揮
    )

    return response.choices[0].message.content


if __name__ == "__main__":
    questions = [
        "每週可以在家上班幾天?",
        "Q2 台灣區營收是多少?",
        "公司有沒有提供健身房補貼?",  # 文件裡沒有這個答案
    ]

    for q in questions:
        print(f"\n問題:{q}")
        print(f"回答:{query_rag(q)}")
        print("=" * 60)

預期輸出(節錄):

問題:每週可以在家上班幾天?
回答:根據公司遠端工作政策,員工每週最多可在家工作三天,
但每週四必須到辦公室出席。[來源: HR 政策手冊 v2.3]

問題:公司有沒有提供健身房補貼?
回答:這份文件沒有包含這個資訊。

注意第三個問題——模型正確承認「文件裡沒有」,不是亂編答案。這是 RAG 比直接問 LLM 更可靠的地方:你提供的 context 決定了答案的邊界。

加一層診斷:看清楚向量搜尋在幹嘛

加個小工具了解相似度分佈,這對後面除錯非常有用:

# debug_retrieval.py
from dotenv import load_dotenv
import chromadb
from openai import OpenAI

load_dotenv()

EMBED_MODEL = "text-embedding-3-small"
openai_client = OpenAI()
chroma_client = chromadb.PersistentClient(path="./chroma_data")
collection = chroma_client.get_collection("company_docs")


def inspect_retrieval(question: str, top_k: int = 3):
    q_vec = openai_client.embeddings.create(
        model=EMBED_MODEL,
        input=[question],
    ).data[0].embedding

    results = collection.query(
        query_embeddings=[q_vec],
        n_results=top_k,
        include=["documents", "metadatas", "distances"],
    )

    print(f"\n查詢:「{question}」\n")
    for i, (doc, meta, dist) in enumerate(zip(
        results["documents"][0],
        results["metadatas"][0],
        results["distances"][0],
    )):
        sim = round(1 - dist, 4)
        bar = "█" * int(sim * 20)
        print(f"排名 {i+1}  相似度: {sim}  {bar}")
        print(f"  來源: {meta['source']}")
        print(f"  內容: {doc[:55]}...")
        print()


if __name__ == "__main__":
    inspect_retrieval("產假有幾週?")
    inspect_retrieval("今年業績如何?")

輸出大致如下:

查詢:「產假有幾週?」

排名 1  相似度: 0.8512  █████████████████
  來源: HR 政策手冊 v2.3
  內容: 產假與陪產假:主要照顧者享有 16 週全薪產假...

排名 2  相似度: 0.6204  ████████████
  來源: HR 政策手冊 v2.3
  內容: 公司的遠端工作政策:員工每週最多...

排名 3  相似度: 0.4831  █████████
  來源: Q2 財報摘要
  內容: 2026 年 Q2 業績:台灣區營收...

排名 1 的相似度 0.85 明顯高於其他,代表向量搜尋抓對了。在真實系統裡,如果 top-1 相似度低於 0.55,通常代表文件庫裡真的沒有相關資料——可以設門檻直接回「找不到」,不必強行送給 LLM 瞎猜。

向量相似度的直覺示意

常見坑

坑 1:兩個腳本用了不同的 Chroma 客戶端模式

症狀:chromadb.errors.InvalidCollectionException: Collection company_docs does not exist——明明跑了 index.py,query.py 還是說找不到。

原因:chromadb.Client()(不帶參數)是記憶體模式。每個 Python 行程各自維護獨立的 in-memory 狀態,index.py 建的 collection 根本不存在於 query.py 的行程裡。

解法:兩個腳本都用 chromadb.PersistentClient(path="./chroma_data")——本文範例程式已經這樣做了。如果你是照網路上的舊教學複製,請特別檢查這一行。

# 錯的:每個行程各自一份記憶體,沒辦法共享
chroma_client = chromadb.Client()

# 對的:資料寫進磁碟,兩個腳本都能存取
chroma_client = chromadb.PersistentClient(path="./chroma_data")

坑 2:索引和查詢用了不同的 Embedding 模型

症狀:查詢結果很奇怪——完全相關的文件排最後,不相關的反而排第一。用 debug_retrieval.py 看,所有相似度都在 0.45~0.55 之間,沒有明顯高峰。

原因:索引時用 text-embedding-3-large(3,072 維),查詢時改用 text-embedding-3-small(1,536 維),維度不同,cosine 相似度計算出來沒有意義。

更隱藏的版本:兩次都用 text-embedding-3-small,但索引時加了 dimensions=512 截短向量、查詢時沒加——維度都是 512,但值域分佈不同,結果一樣爛。

根本原則:把模型名稱定義成一個常數(EMBED_MODEL),讓索引和查詢都引用同一個變數,絕對不要在兩個地方各自寫死字串。


坑 3:Context 塞了太多低相關 chunk,LLM 開始混淆

症狀:回答超長、答非所問,或明明文件裡有答案,模型卻夾雜了不相關的資訊。

原因:你設了 top_k=10,向量搜尋忠實地回傳了 10 個結果——但排名 6~10 的相似度只有 0.45,跟問題關係不大。LLM 拿到這些雜訊就開始混淆。

解法:加相似度門檻,範例程式已實作(SIMILARITY_THRESHOLD = 0.60)。實際合適的門檻值取決於你的 embedding 模型和文件類型,從 0.6 開始調,用 debug_retrieval.py 觀察真實分佈。補充手段是第 5 課的 Rerank——它從 20~50 個候選裡選出真正的 Top 5,比用固定門檻更精準。


坑 4:文件沒有切塊直接 embed,觸發 token 上限錯誤

症狀:openai.BadRequestError: This model's maximum context length is 8191 tokens

原因:你把一整份 PDF 當成單一文字送去 embed。text-embedding-3-small 最多吃 8,191 tokens,大約 10~15 頁的文件就可能超標。這是非常常見的新手錯誤,因為「先切塊」這件事在最小範例裡不明顯。

暫時解法(僅用於測試):

# 粗暴截斷——只用於快速測試,正式環境絕對不能這樣
text = text[:3000]

正確解法在第 4 課(切塊策略)。文件在 embed 之前必須先切塊,這是整個 RAG 管線裡最影響效果的環節。

作業

  1. 把範例程式完整跑通,替換 documents.py 裡的三段文字,換成你自己的資料(個人筆記、產品說明、某份文件的摘錄都可以)。跑 debug_retrieval.py 觀察相似度分佈。
  2. 試「文件裡沒有答案的問題」:提三個你確定文件沒有涵蓋的問題,看看模型有沒有正確回「這份文件沒有包含這個資訊」,還是開始幻覺出一個聽起來合理的假答案。這個觀察是第 6 課(防幻覺)的前置感受。
  3. 進階選做:把 SIMILARITY_THRESHOLD 從 0.60 改成 0.80、再改成 0.40,觀察三種設定下回答品質和「找不到」的頻率如何變化。思考你的應用情境更在意「不漏答」還是「不亂答」。

下一課預告

你現在知道了 RAG 是什麼,也跑通了一個最小版本。但你有沒有注意到一個問題:我們一直在說「把文字轉成向量」,但這個向量到底是什麼?為什麼「產假有幾週?」和「產假與陪產假」在向量空間裡彼此接近,但和「Q2 財報」就很遠?如果換另一個 Embedding 模型,這個關係還成立嗎?

第 2 課深入 Embedding:它的數學直覺(不需要線代背景)、不同模型的 MTEB 評測怎麼看、同一段中文在 OpenAI 和 Voyage 模型下的向量有什麼差異,以及一個你在建 RAG 之前就必須做的決定——查詢向量和文件向量要不要用同一個模型、有沒有更好的非對稱 embedding 策略。

#RAG#向量資料庫#Embedding#LLM#AI 系統

← 回所有文章