精華筆記

· @aihub.tw

RAG 系統實作

綜合實戰:個人文件問答系統上線

綜合實戰:個人文件問答系統上線

前六課你已經個別練過:embedding 模型怎麼選、向量資料庫怎麼建、切塊策略怎麼調、hybrid search 怎麼跑、rerank 怎麼接、生成端怎麼防幻覺引用來源。每一課都是一塊積木,但積木零散放著不算完成任何東西。

這堂課就是把它們全部組起來,做成一個真正能上線的個人文件問答系統。不是 demo、不是 notebook 裡跑幾行,而是有上傳介面、有 API endpoint、有前端頁面、能實際給自己或同事用的東西。

這堂課適合誰 適合:完成第 1–6 課或有同等程度的工程師,想把 RAG 從「實驗性程式碼」升級成「可上線的系統」。需要基礎:Python 中級(async/await 看得懂、知道什麼是 HTTP API)、會用 Docker Compose 啟動服務。前置課:第 6 課(生成端:引用來源與防幻覺)。

這堂學什麼

  • 把六課知識串成完整架構:文件上傳 → 索引管線 → 查詢 API → 前端
  • 增量更新策略:新文件進來只更新差異,不重建整個索引
  • 成本精算:embedding + 儲存 + 生成,1,000 頁文件一個月實際要花多少
  • 評估與迭代:用 Precision@K 和 MRR 讓你知道系統有沒有在進步
  • ≥6 個高頻坑的症狀、錯誤訊息與解法
  • 下一步:Agent 開發課的銜接點在哪

觀念一:完整系統架構一張圖

先確認整個系統長什麼樣,後面的實戰步驟每一步都對應到這張圖的某個方塊。

RAG 系統完整架構:索引路徑與查詢路徑共用向量資料庫,三個按量計費成本點

這張圖對應到四個後端檔案,各司其職:ingest.py 管索引路徑(萃取、切塊、embed、寫入向量庫),retriever.py 管查詢路徑的前半段(向量搜尋 + rerank),llm.py 管生成端(帶引用、防幻覺),main.py 用 FastAPI 把上傳與查詢兩個 endpoint 串起來。前端只是薄薄一層 HTML,負責呼叫這兩個 API。把責任切乾淨的好處是:之後要換向量庫只動 retriever.py、要換模型只動 llm.py,彼此不會牽連。

技術選型(2026 年 7 月建議):

元件 建議選項 備選
Embedding text-embedding-3-small($0.02/MTok)或 voyage-4-lite($0.02/MTok) voyage-4($0.06/MTok,精準度更高)
向量資料庫 pgvector(已有 Postgres 就用這個) Qdrant Docker(沒有 Postgres 時)
Rerank Cohere Rerank 3.5($2/千次查詢) 不用 rerank 也可以,先 ship 再加
生成 Claude Haiku 4.5($1/$5 per MTok) Claude Sonnet 4.6(需要更好推理時)
後端框架 FastAPI(Python)

查詢路徑裡的檢索其實分兩段:先用向量搜尋寬鬆撈一批候選,再用 rerank 精挑幾個送進 LLM。後面實戰的 TOP_K_RETRIEVE = 20TOP_K_RERANK = 5 就是這個漏斗:

兩段式檢索漏斗:Hybrid Search 寬鬆召回 20 個候選,Cohere Rerank 精排後只留 5 個送進 LLM

觀念二:成本估算

很多教學跳過這一段,導致系統上線後帳單嚇一跳。以下用具體數字算:

假設場景:1,000 頁 PDF(約 50 萬 tokens)、每月 2,000 次查詢、每次召回 5 個 chunk 共約 1,500 tokens context。

費用項目 計算方式 每月金額
初次索引(一次性) 500K tokens × $0.02/MTok 約 $0.01
新增文件 embedding(假設每月 10%) 50K tokens × $0.02/MTok 約 $0.001
Rerank(Cohere 按 search 計價) 2,000 次 ÷ 1,000 × $2/千次 約 $4
LLM input(context+問題) 2,000 × 2K tokens × $1/MTok 約 $4
LLM output(答案限 300 字內) 2,000 × 400 tokens × $5/MTok 約 $4
pgvector 儲存(500K 向量,1536 維) 自建 Postgres 電費 / Supabase 免費方案 接近 $0

結論:一個 1,000 頁的個人知識庫,每月成本落在 LLM 生成與 rerank 兩塊,合計約 10–12 美元。Embedding 與儲存幾乎不用錢。有兩個省錢重點:第一,Cohere Rerank 按查詢次數收費($2/千次),若先不上 rerank(第 5 課提過「先 ship 再加」),月費直接砍掉三分之一;第二,LLM output token 單價是 input 的五倍($5 vs $1 / MTok),所以第 6 課教的「限制生成長度、只引用相關段落」在成本控制上格外關鍵。

RAG 系統月費結構:LLM 生成約 66%、Rerank 約 33%、Embedding 與儲存趨近於零,附兩個省錢槓桿

手把手實戰

專案結構

my-rag/
├── backend/
│   ├── main.py          # FastAPI 主程式
│   ├── ingest.py        # 索引管線
│   ├── retriever.py     # 檢索 + rerank
│   └── llm.py           # 生成端
├── frontend/
│   └── index.html       # 簡易前端
├── .env
└── docker-compose.yml

啟動向量資料庫

本課用 Qdrant 當向量庫(Docker 一行啟動,不需要先有 Postgres):

# docker-compose.yml
version: "3.9"
services:
  qdrant:
    image: qdrant/qdrant:latest
    ports:
      - "6333:6333"
    volumes:
      - ./qdrant_data:/qdrant/storage
docker compose up -d

啟動後 http://localhost:6333/dashboard 可以開 Qdrant 的管理介面確認正常。

.env 裡放好 API 金鑰:

OPENAI_API_KEY=sk-...
COHERE_API_KEY=...
ANTHROPIC_API_KEY=sk-ant-...

安裝依賴:

pip install fastapi uvicorn python-multipart \
    langchain langchain-openai langchain-community \
    qdrant-client cohere anthropic PyPDF2 python-dotenv tiktoken

索引管線:文件上傳到向量庫

# backend/ingest.py
import hashlib
import os
from pathlib import Path
from typing import List

import PyPDF2
from dotenv import load_dotenv
from langchain.text_splitter import RecursiveCharacterTextSplitter
from langchain_openai import OpenAIEmbeddings
from qdrant_client import QdrantClient
from qdrant_client.models import Distance, PointStruct, VectorParams
import tiktoken

load_dotenv()

COLLECTION_NAME = "my_docs"
EMBED_MODEL = "text-embedding-3-small"
EMBED_DIM = 1536
CHUNK_SIZE = 512
CHUNK_OVERLAP = 64

embeddings = OpenAIEmbeddings(model=EMBED_MODEL)
qdrant = QdrantClient(host="localhost", port=6333)

enc = tiktoken.get_encoding("cl100k_base")

def ensure_collection():
    """確保 collection 存在,避免重複建立"""
    existing = [c.name for c in qdrant.get_collections().collections]
    if COLLECTION_NAME not in existing:
        qdrant.create_collection(
            collection_name=COLLECTION_NAME,
            vectors_config=VectorParams(size=EMBED_DIM, distance=Distance.COSINE),
        )
        print(f"[ingest] 建立 collection: {COLLECTION_NAME}")

def file_hash(content: bytes) -> str:
    """用 SHA-256 當文件指紋,實現增量更新時的去重"""
    return hashlib.sha256(content).hexdigest()

def extract_text(filepath: str) -> str:
    """支援 PDF、Markdown、純文字"""
    if filepath.endswith(".pdf"):
        with open(filepath, "rb") as f:
            reader = PyPDF2.PdfReader(f)
            return "\n\n".join(p.extract_text() or "" for p in reader.pages)
    else:
        with open(filepath, "r", encoding="utf-8") as f:
            return f.read()

def chunk_and_index(filepath: str, source_name: str) -> int:
    """切塊、embed、存入 Qdrant。回傳寫入的 chunk 數。"""
    ensure_collection()

    raw_text = extract_text(filepath)
    if not raw_text.strip():
        print(f"[ingest] 警告:{filepath} 萃取文字為空,跳過")
        return 0

    with open(filepath, "rb") as f:
        doc_hash = file_hash(f.read())

    # 用 token 計數切塊
    splitter = RecursiveCharacterTextSplitter(
        chunk_size=CHUNK_SIZE,
        chunk_overlap=CHUNK_OVERLAP,
        length_function=lambda text: len(enc.encode(text)),
        separators=["\n\n", "\n", "。", ".", " ", ""],
    )
    chunks = splitter.create_documents(
        [raw_text],
        metadatas=[{"source": source_name, "doc_hash": doc_hash, "filepath": filepath}],
    )

    # 過濾太短的 chunk(防止空白或只有換行的垃圾片段)
    chunks = [c for c in chunks if len(c.page_content.strip()) >= 50]
    if not chunks:
        return 0

    # 批次 embed(每批 100 個,避免單次請求超過 API 限制)
    texts = [c.page_content for c in chunks]
    vectors = embeddings.embed_documents(texts)

    points = []
    for i, (chunk, vector) in enumerate(zip(chunks, vectors)):
        points.append(PointStruct(
            id=abs(hash(f"{doc_hash}_{i}")) % (2**63),  # 穩定的數字 ID
            vector=vector,
            payload={
                "text": chunk.page_content,
                "source": source_name,
                "doc_hash": doc_hash,
                "chunk_index": i,
                "total_chunks": len(chunks),
            },
        ))

    qdrant.upsert(collection_name=COLLECTION_NAME, points=points)
    print(f"[ingest] {source_name} → {len(points)} 個 chunk 寫入 Qdrant")
    return len(points)

增量更新的關鍵在 doc_hash。每份文件算一個 SHA-256 fingerprint。下次同名文件進來,先比對 hash:一樣就跳過,不一樣就刪掉舊的 hash 對應的 points,再重新索引。

查詢 API:檢索 + Rerank + 生成

# backend/retriever.py
import os
import cohere
from langchain_openai import OpenAIEmbeddings
from qdrant_client import QdrantClient
from qdrant_client.models import Filter, FieldCondition, MatchValue

COLLECTION_NAME = "my_docs"
EMBED_MODEL = "text-embedding-3-small"
TOP_K_RETRIEVE = 20   # 先寬鬆撈 20 個
TOP_K_RERANK = 5      # rerank 後只留 5 個送 LLM

embeddings = OpenAIEmbeddings(model=EMBED_MODEL)
qdrant = QdrantClient(host="localhost", port=6333)
co = cohere.Client(os.environ["COHERE_API_KEY"])

def retrieve_and_rerank(query: str, source_filter: str | None = None) -> list[dict]:
    """
    1. 向量搜尋召回 TOP_K_RETRIEVE 個候選
    2. Cohere Rerank v3 二次排序
    3. 回傳 TOP_K_RERANK 個帶 source 的結果
    """
    query_vector = embeddings.embed_query(query)

    search_filter = None
    if source_filter:
        search_filter = Filter(
            must=[FieldCondition(key="source", match=MatchValue(value=source_filter))]
        )

    hits = qdrant.search(
        collection_name=COLLECTION_NAME,
        query_vector=query_vector,
        limit=TOP_K_RETRIEVE,
        query_filter=search_filter,
        with_payload=True,
    )

    if not hits:
        return []

    # Cohere Rerank v3
    docs_for_rerank = [hit.payload["text"] for hit in hits]
    rerank_results = co.rerank(
        model="rerank-v3.5",
        query=query,
        documents=docs_for_rerank,
        top_n=TOP_K_RERANK,
    )

    return [
        {
            "text": hits[r.index].payload["text"],
            "source": hits[r.index].payload["source"],
            "relevance_score": r.relevance_score,
        }
        for r in rerank_results.results
    ]
# backend/llm.py
import anthropic

client = anthropic.Anthropic()

SYSTEM_PROMPT = """你是一個根據提供文件回答問題的助理。
規則:
1. 只根據「參考文件」裡的內容回答,不要加入你自己的知識
2. 每一個主要論點後面用 [來源:文件名] 標注引用來源
3. 如果文件裡找不到答案,說「在提供的文件中找不到相關資訊」
4. 回答用繁體中文"""

def generate_answer(query: str, contexts: list[dict]) -> str:
    if not contexts:
        return "找不到相關文件內容,無法回答。"

    context_block = "\n\n".join(
        f"[來源:{c['source']}]\n{c['text']}"
        for c in contexts
    )

    message = client.messages.create(
        model="claude-haiku-4-5",
        max_tokens=1024,
        system=SYSTEM_PROMPT,
        messages=[{
            "role": "user",
            "content": f"參考文件:\n{context_block}\n\n問題:{query}"
        }],
    )
    return message.content[0].text

FastAPI 主程式:上傳 + 查詢 endpoint

# backend/main.py
import os
import tempfile
from fastapi import FastAPI, File, UploadFile, HTTPException
from fastapi.middleware.cors import CORSMiddleware
from pydantic import BaseModel
from dotenv import load_dotenv

load_dotenv()

from ingest import chunk_and_index
from retriever import retrieve_and_rerank
from llm import generate_answer

app = FastAPI(title="My RAG API")

app.add_middleware(
    CORSMiddleware,
    allow_origins=["*"],   # 正式環境請限縮來源
    allow_methods=["*"],
    allow_headers=["*"],
)

class QueryRequest(BaseModel):
    question: str
    source_filter: str | None = None   # 可以限定只查某份文件

class QueryResponse(BaseModel):
    answer: str
    sources: list[dict]

@app.post("/upload")
async def upload_document(file: UploadFile = File(...)):
    """接收 PDF/MD/TXT,跑索引管線"""
    allowed_exts = {".pdf", ".md", ".txt"}
    ext = os.path.splitext(file.filename)[1].lower()
    if ext not in allowed_exts:
        raise HTTPException(status_code=400, detail=f"不支援的檔案格式:{ext}")

    with tempfile.NamedTemporaryFile(delete=False, suffix=ext) as tmp:
        content = await file.read()
        tmp.write(content)
        tmp_path = tmp.name

    try:
        chunk_count = chunk_and_index(tmp_path, file.filename)
    finally:
        os.unlink(tmp_path)

    return {"filename": file.filename, "chunks_indexed": chunk_count}

@app.post("/query", response_model=QueryResponse)
async def query(req: QueryRequest):
    """接收問題,回傳答案 + 引用來源"""
    if not req.question.strip():
        raise HTTPException(status_code=400, detail="問題不能是空白")

    contexts = retrieve_and_rerank(req.question, req.source_filter)
    answer = generate_answer(req.question, contexts)

    return QueryResponse(
        answer=answer,
        sources=[{"source": c["source"], "score": c["relevance_score"]} for c in contexts],
    )

@app.get("/health")
async def health():
    return {"status": "ok"}

啟動後端:

cd backend
uvicorn main:app --reload --port 8000

http://localhost:8000/docs 可以直接在 Swagger UI 上傳文件、測試查詢。

簡易前端:一個 HTML 檔搞定

<!-- frontend/index.html -->
<!DOCTYPE html>
<html lang="zh-Hant">
<head>
  <meta charset="UTF-8">
  <title>個人文件問答</title>
  <style>
    body { font-family: sans-serif; max-width: 720px; margin: 40px auto; padding: 0 20px; }
    textarea { width: 100%; height: 60px; font-size: 16px; }
    button { padding: 8px 20px; font-size: 16px; cursor: pointer; }
    #answer { margin-top: 20px; padding: 16px; background: #f5f5f5; border-radius: 8px; white-space: pre-wrap; }
    #sources { margin-top: 12px; font-size: 13px; color: #666; }
    .upload-area { margin-bottom: 24px; padding: 16px; border: 2px dashed #ccc; border-radius: 8px; }
  </style>
</head>
<body>
  <h2>個人文件問答系統</h2>

  <div class="upload-area">
    <p>上傳文件(PDF / MD / TXT)</p>
    <input type="file" id="fileInput" accept=".pdf,.md,.txt">
    <button onclick="uploadFile()">上傳並索引</button>
    <span id="uploadStatus"></span>
  </div>

  <textarea id="question" placeholder="輸入你的問題..."></textarea>
  <br><br>
  <button onclick="ask()">送出問題</button>

  <div id="answer" style="display:none"></div>
  <div id="sources"></div>

  <script>
    const API = "http://localhost:8000";

    async function uploadFile() {
      const file = document.getElementById("fileInput").files[0];
      if (!file) return alert("請先選擇檔案");
      const form = new FormData();
      form.append("file", file);
      document.getElementById("uploadStatus").textContent = "上傳中...";
      const res = await fetch(`${API}/upload`, { method: "POST", body: form });
      const data = await res.json();
      document.getElementById("uploadStatus").textContent =
        res.ok ? `✓ 索引完成,共 ${data.chunks_indexed} 個片段` : `錯誤:${data.detail}`;
    }

    async function ask() {
      const question = document.getElementById("question").value.trim();
      if (!question) return;
      document.getElementById("answer").style.display = "block";
      document.getElementById("answer").textContent = "查詢中...";
      document.getElementById("sources").textContent = "";

      const res = await fetch(`${API}/query`, {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({ question }),
      });
      const data = await res.json();
      document.getElementById("answer").textContent = data.answer;
      document.getElementById("sources").textContent =
        "引用來源:" + data.sources.map(s => `${s.source}(${s.score.toFixed(2)})`).join("、");
    }
  </script>
</body>
</html>

python -m http.server 3000 --directory frontend 啟動,在瀏覽器開 http://localhost:3000 就能用。

增量更新:新文件進來不重建索引

完整的增量更新邏輯建在 ingest.pychunk_and_index 裡,關鍵是 doc_hash。每次上傳時先查 Qdrant 裡有沒有同 hash 的 point:

def is_already_indexed(doc_hash: str) -> bool:
    """查 Qdrant 是否已有這份文件的 hash"""
    results = qdrant.scroll(
        collection_name=COLLECTION_NAME,
        scroll_filter=Filter(
            must=[FieldCondition(key="doc_hash", match=MatchValue(value=doc_hash))]
        ),
        limit=1,
    )
    return len(results[0]) > 0

def delete_by_hash(doc_hash: str):
    """刪除舊版本的所有 chunks"""
    qdrant.delete(
        collection_name=COLLECTION_NAME,
        points_selector=Filter(
            must=[FieldCondition(key="doc_hash", match=MatchValue(value=doc_hash))]
        ),
    )

chunk_and_index 最前面加這個判斷:

# 取得新版 hash
with open(filepath, "rb") as f:
    new_hash = file_hash(f.read())

# 查同檔名的舊 hash
old_hash = get_hash_by_source(source_name)   # 自行實作:查 Qdrant scroll

if old_hash == new_hash:
    print(f"[ingest] {source_name} 內容未改變,跳過索引")
    return 0

if old_hash:
    delete_by_hash(old_hash)
    print(f"[ingest] 刪除舊版本 {source_name}({old_hash[:8]}...)")

這樣每次上傳文件:沒變就跳過,有改就刪舊存新——索引永遠保持最新版,成本也不會因為重複上傳暴增。

增量更新決策樹:算 SHA-256 後查 Qdrant,hash 相同就跳過,不同則刪舊重建或直接索引

評估與迭代

系統跑起來之後,「感覺還不錯」不是夠好的評估。以下是工程師的評估做法:

建一個測試集:準備 20–30 個問答對(問題 + 正確答案 + 答案應該來自哪份文件哪一段)。這個測試集是你最寶貴的資產,後續每次調參都用它。

我們用兩個指標來量檢索品質——一個看「有沒有中」,一個看「排得多前」:

兩個評估指標對照:Precision@K 看命中率,MRR 看正確來源的倒數排名,附同一次檢索的計算範例

# eval.py  — 計算 Precision@K 和 MRR
test_cases = [
    {
        "query": "公司的請假規定是什麼?",
        "expected_source": "HR規則.pdf",
        "expected_keywords": ["年假", "事假", "申請流程"],
    },
    # ... 更多測試案例
]

def evaluate(test_cases, k=5):
    hits = 0
    reciprocal_ranks = []
    for tc in test_cases:
        results = retrieve_and_rerank(tc["query"])
        sources = [r["source"] for r in results[:k]]
        # Precision@K:前 K 個結果裡有沒有正確來源
        if tc["expected_source"] in sources:
            hits += 1
            rank = sources.index(tc["expected_source"]) + 1
            reciprocal_ranks.append(1 / rank)
        else:
            reciprocal_ranks.append(0)
    precision_at_k = hits / len(test_cases)
    mrr = sum(reciprocal_ranks) / len(test_cases)
    print(f"Precision@{k}: {precision_at_k:.2%}")
    print(f"MRR: {mrr:.4f}")

每次調整 chunk size、換 embedding 模型、加或拿掉 rerank,都跑一次 evaluate()——這樣才知道改動是在進步還是退步。這就是工程師和「憑感覺調參」最大的差別:每一次改動都有一個數字幫你背書。剛開始 20 個案例就夠用,系統上線後把使用者真正問過、但答錯的問題持續補進測試集,它會愈養愈準,慢慢變成你這套系統的專屬回歸測試。

常見坑

坑 1:PDF 萃取出亂碼或空字串

症狀:上傳 PDF 後 chunks_indexed 顯示 0,或 LLM 回答「找不到相關資訊」,但文件明明有內容。

原因:掃描版 PDF(圖片 PDF)或加密 PDF,PyPDF2 萃取出空字串或亂碼。

解法:先判斷萃取結果的字元密度——若每頁萃取的字元數 < 50,自動切換到 OCR 路線:

pip install pymupdf
import fitz  # PyMuPDF

def extract_text_with_fallback(filepath: str) -> str:
    doc = fitz.open(filepath)
    pages_text = []
    for page in doc:
        text = page.get_text()
        if len(text.strip()) < 50:
            # 掃描版:用 OCR(需要裝 tesseract)
            pix = page.get_pixmap(dpi=300)
            # 這裡接 pytesseract 或雲端 OCR API
            text = f"[第{page.number+1}頁:掃描版,需 OCR 處理]"
        pages_text.append(text)
    return "\n\n".join(pages_text)

坑 2:向量庫 collection 不存在,啟動就炸

症狀:

qdrant_client.http.exceptions.UnexpectedResponse: 
  Unexpected Response: 404 (Not Found)
  {"status":{"error":"Not found: Collection `my_docs` doesn't exist!"},"time":0.0}

原因:Qdrant container 第一次啟動時 collection 還沒建立,後端先跑一步查詢就掛。

解法:在任何 qdrant.search() 之前一定要先呼叫 ensure_collection()。上面的 chunk_and_index 已經包含,但 retriever.pyretrieve_and_rerank 也要加一行:

def retrieve_and_rerank(query: str, ...):
    ensure_collection()   # 加這行
    ...

坑 3:Cohere Rerank 回傳 index 超出範圍

症狀:

IndexError: list index out of range

原因:co.rerank(top_n=5) 要求 documents 至少有 5 個,但向量搜尋結果可能不足 5 個(文件太少或沒有任何相關結果)。

解法:讓 top_n 動態取 min:

top_n = min(TOP_K_RERANK, len(docs_for_rerank))
if top_n == 0:
    return []
rerank_results = co.rerank(model="rerank-v3.5", query=query, documents=docs_for_rerank, top_n=top_n)

坑 4:CORS 問題,前端無法呼叫 API

症狀:瀏覽器 console 出現:

Access to fetch at 'http://localhost:8000/query' from origin 'http://localhost:3000' 
has been blocked by CORS policy: No 'Access-Control-Allow-Origin' header is present.

原因:FastAPI 預設不允許跨來源請求。上面的 main.py 已加 CORSMiddleware,但要確認 allow_origins 包含你前端的來源。開發時設 ["*"] 沒問題,上線後換成具體網域。

坑 5:hash ID 碰撞,新文件 upsert 覆蓋舊文件的 chunk

症狀:上傳第二份文件後,查詢第一份文件的內容突然消失。

原因:用 abs(hash(...)) % (2**63) 算 ID 有低機率碰撞,兩份文件的某個 chunk ID 相同,upsert 時互相覆蓋。

解法:改用 UUID:

import uuid

point_id = str(uuid.uuid5(uuid.NAMESPACE_DNS, f"{doc_hash}_{i}"))
# Qdrant 的 PointStruct id 支援 UUID 字串
points.append(PointStruct(id=point_id, vector=vector, payload={...}))

坑 6:生成回答明明有正確 context,但 LLM 還是亂說

症狀:Rerank 後的 Top 5 chunks 都包含正確答案,但 Claude 給出的回答跟 context 不一致甚至矛盾。

原因:system prompt 沒有夠強的限制,模型在 context 不完整時自動「補充」訓練知識。

解法:加強 system prompt 的邊界:

SYSTEM_PROMPT = """你是一個嚴格依照提供文件回答的助理。
絕對規則:
1. 禁止加入文件以外的資訊
2. 如果文件中沒有足夠資訊,必須說「文件中沒有此資訊」,不得猜測
3. 每個來自文件的論點必須在句尾標注 [來源:檔名]
4. 回答長度控制在 300 字以內"""

常見坑位置對應圖:六個坑分別標在上傳萃取、向量庫啟動、rerank、前端 API、向量庫寫入、LLM 生成

作業

  1. 把系統跑起來:照實戰步驟把 Qdrant、後端、前端都啟動,上傳至少 3 份不同格式的文件(一份 PDF、一份 MD、一份 TXT),各問 3 個問題,確認引用來源都正確。
  2. 成本計算練習:把你自己的文件數量和預期查詢量代入觀念二的表格,估算你的系統一個月大概要花多少美元。如果生成成本太高,你可以從哪個地方砍?
  3. 評估測試集:建 10 個問答對,跑 evaluate() 函式,記下基準的 Precision@5 和 MRR。然後試著把 TOP_K_RETRIEVE 從 20 改成 30,再跑一次——數字有沒有變化?
  4. 選做:換 embedding 模型:把 EMBED_MODELtext-embedding-3-small 換成 voyage-4-lite(同樣 $0.02/MTok),對同一份語料重建索引,用你的測試集比較兩個 embedding 的 MRR——記住,換了 embedding 必須重建整個向量庫,兩個模型的向量空間不相容。

下一課預告

這堂課的系統是「問題進去、答案出來」的單輪問答。但真實場景往往更複雜:使用者要追問、要請系統主動搜尋外部資料、要讓 AI 決定查哪份文件甚至拆解成多個子問題再合併——這就是 Agent 的領域了。

RAG 系統其實是 Agent 最常用的工具之一:你剛剛做好的 /query endpoint,可以直接被包成一個 tool 讓 Agent 呼叫。下一個課程系列《Claude Agent 開發》會從 tool use 的機制講起,帶你把今天的 RAG 系統升級成一個能自己決定「要不要查文件、查哪份、查完還需不需要再查一次」的自主助理。你在這七堂課建立的基礎——embedding、向量搜尋、生成端控制——全部都用得上。

#RAG#FastAPI#pgvector#Qdrant#Voyage AI#個人專案

← 回所有文章