綜合實戰:個人文件問答系統上線
前六課你已經個別練過:embedding 模型怎麼選、向量資料庫怎麼建、切塊策略怎麼調、hybrid search 怎麼跑、rerank 怎麼接、生成端怎麼防幻覺引用來源。每一課都是一塊積木,但積木零散放著不算完成任何東西。
這堂課就是把它們全部組起來,做成一個真正能上線的個人文件問答系統。不是 demo、不是 notebook 裡跑幾行,而是有上傳介面、有 API endpoint、有前端頁面、能實際給自己或同事用的東西。
這堂學什麼
- 把六課知識串成完整架構:文件上傳 → 索引管線 → 查詢 API → 前端
- 增量更新策略:新文件進來只更新差異,不重建整個索引
- 成本精算:embedding + 儲存 + 生成,1,000 頁文件一個月實際要花多少
- 評估與迭代:用 Precision@K 和 MRR 讓你知道系統有沒有在進步
- ≥6 個高頻坑的症狀、錯誤訊息與解法
- 下一步:Agent 開發課的銜接點在哪
觀念一:完整系統架構一張圖
先確認整個系統長什麼樣,後面的實戰步驟每一步都對應到這張圖的某個方塊。

這張圖對應到四個後端檔案,各司其職: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 = 20 和 TOP_K_RERANK = 5 就是這個漏斗:

觀念二:成本估算
很多教學跳過這一段,導致系統上線後帳單嚇一跳。以下用具體數字算:
假設場景: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 課教的「限制生成長度、只引用相關段落」在成本控制上格外關鍵。

手把手實戰
專案結構
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.py 的 chunk_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]}...)")
這樣每次上傳文件:沒變就跳過,有改就刪舊存新——索引永遠保持最新版,成本也不會因為重複上傳暴增。

評估與迭代
系統跑起來之後,「感覺還不錯」不是夠好的評估。以下是工程師的評估做法:
建一個測試集:準備 20–30 個問答對(問題 + 正確答案 + 答案應該來自哪份文件哪一段)。這個測試集是你最寶貴的資產,後續每次調參都用它。
我們用兩個指標來量檢索品質——一個看「有沒有中」,一個看「排得多前」:

# 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.py 的 retrieve_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 字以內"""

作業
- 把系統跑起來:照實戰步驟把 Qdrant、後端、前端都啟動,上傳至少 3 份不同格式的文件(一份 PDF、一份 MD、一份 TXT),各問 3 個問題,確認引用來源都正確。
- 成本計算練習:把你自己的文件數量和預期查詢量代入觀念二的表格,估算你的系統一個月大概要花多少美元。如果生成成本太高,你可以從哪個地方砍?
- 評估測試集:建 10 個問答對,跑
evaluate()函式,記下基準的 Precision@5 和 MRR。然後試著把TOP_K_RETRIEVE從 20 改成 30,再跑一次——數字有沒有變化? - 選做:換 embedding 模型:把
EMBED_MODEL從text-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、向量搜尋、生成端控制——全部都用得上。