- 一支實驗 Jupyter 要上線,得拆成 四個團隊:營用(給文件)→ 預訓練(算向量)→ 維運(建庫+API+MCP)→ 使用(任何 agent)。
- RAG 不是訓練模型。「預訓練團隊」做的是算 embedding 建索引,模型原廠不動——BUILD 一次,SERVE 無數次。
- 知識庫落 PostgreSQL + pgvector:三張表(書 / 片+向量 / 授權),檢索與授權用一條 SQL 同時完成。
- 檢索包成 一個 MCP,之後任何 agent 掛上就能用,授權由後端擋。
- 手冊 3 年持續疊代:只動變動的那一本(切嵌→upsert),其他不碰;唯一「牽一髮動全身」是換嵌入模型 = 全庫重建。

一支 Jupyter 不能直接上線
我用一支 Jupyter notebook 把 5 本真實工廠設備手冊(抽水機、空壓機、CNC 銑床、變頻器、PLC)做成了 RAG 問答,實跑切出 1,251 片、每片一個 1024 維向量。但這支 notebook 有個致命問題:每次跑都重新切、重新算向量。這在實驗室 OK,上線就是災難——沒人要每次問一個問題就等它把整批手冊重切一次。
要變成能長期運作的平台,必須把「一次性的準備」和「每次的查詢」切開,並且讓不同角色各司其職。這就帶出四個團隊。
四個團隊,各司其職
| 團隊 | 職責 | 產出 → 交給誰 |
|---|---|---|
| 營用團隊 (給文件) | 提供手冊 PDF + 一份 metadata 清單(book_id、機器、別名、語言) | PDF + manifest → 預訓練 |
| 預訓練團隊 (算向量) | 切片(CPU)+ 算 embedding(bge-m3,GPU 佳)。不訓練模型 | chunks + 向量 → 維運 |
| 維運團隊 (平台) | 建 PG+pgvector、灌向量、建 API(帶授權)、包 MCP | 一個 MCP → 使用 |
| 使用團隊 (消費) | 任何 agent / 人,掛上 MCP 就能問手冊 | — |
manifest.yaml
bge-m3→1024維
API+授權
包 MCP
掛 MCP 就問
RAG 不是訓練:BUILD 一次 vs SERVE 無數次
先破一個常見誤解:這整套完全沒有「訓練」或「微調」任何模型。bge-m3(嵌入模型)和生成 LLM 都是原廠拿來直接用。所謂「預訓練團隊」名字容易誤導——它做的是算 embedding、建索引,不是改模型的腦。RAG 的鐵律是:利用模型,不改模型;缺知識就餵它對的資料,而不是把知識塞進權重。
整個系統天生分兩個時間軸——這是理解 RAG 架構的鑰匙:
| 階段 | 做什麼 | 頻率 | 吃 GPU? |
|---|---|---|---|
| BUILD | 切片 → 算向量 → 存進 PG | 一次(新手冊才增量) | embedding 這步 GPU 佳(CPU 也行);切片純 CPU |
| SERVE | 問題→算 1 個向量→搜→授權→回片段→生成 | 無數次 | 不用(單句嵌入 CPU 即可) |
SERVE 階段的完整 UML 循序圖見文章最上方(BUILD + SERVE 一次呈現)。
三張表:資料庫設計
知識庫落在 PostgreSQL,靠 pgvector 擴充支援向量欄位。三張表:書層、片層(帶向量)、授權層。UML 實體關係圖:

對應的建表 SQL:
CREATE EXTENSION IF NOT EXISTS vector;
-- 書層(= 營用團隊的 manifest)
CREATE TABLE manual_books (
book_id text PRIMARY KEY, -- pump_nantou / vfd_delta_ms300
machine text, -- 抽水機/泵浦
title text,
lang text, -- zh-TW / zh-CN
aka text[] -- {抽水,泵浦,水泵} ← book-aware 路由
);
-- 片層(= 1251 片)
CREATE TABLE manual_chunks (
id bigserial PRIMARY KEY,
book_id text REFERENCES manual_books(book_id) ON DELETE CASCADE,
section text, -- 「五、簡易故障排除」或「【故障排除表】」
page_from int,
page_to int,
chunk_method text, -- 'toc' / 'heading'
text text,
embedding vector(1024) -- bge-m3 固定 1024 維
);
CREATE INDEX ON manual_chunks (book_id);
CREATE INDEX ON manual_chunks USING hnsw (embedding vector_cosine_ops);
-- 授權層
CREATE TABLE access_grants (
principal text, -- api 呼叫者身分
book_id text REFERENCES manual_books(book_id) ON DELETE CASCADE,
PRIMARY KEY (principal, book_id)
);
不管是 TOC 切、標題偵測切、還是把表格壓平成「症狀→處置」,全部都收斂成同一個扁平結構。複雜度留在切片程式裡,資料表永遠乾淨。varied 的資訊記在
section / chunk_method 兩欄即可。
演算法① 切片策略:有目錄用目錄,沒有就看字級
切片(chunking)決定檢索品質。真手冊是「髒資料」,不能盲切固定字數(會把跨機台的內容切在一起)。策略是照文件結構切:
- 有 TOC(目錄) → 用目錄項當章節邊界,一節一片(變頻器、PLC 手冊走這條)。
- 沒有好 TOC → 用字級偵測標題:真標題的字比內文大,以此當邊界(抽水機、空壓機、CNC 手冊走這條)。
- 表格 → 用 pymupdf 抓出來,壓平成語意句:故障表變「症狀:X → 處置:Y」、保養表變「保養項目:X → 週期:Y」。這樣檢索才找得到「處置」而不是一堆散落的表格格子。
每片最後帶上 metadata:{book_id, machine, section, page_from, page_to, chunk_method}。這些之後直接變成 PG 的欄位。實跑結果:5 本書 → 1251 片(TOC 切法佔多數片數,因為變頻器手冊 786 片最厚)。
演算法② Embedding:1024 維到底是什麼
Embedding 是「把文字變成一串數字座標」,好讓電腦用距離衡量語意相似度。我用 bge-m3,它的輸出固定是 1024 個數字——不管你丟進去的是一個字還是一整頁,出來永遠 1024 維。
那 1024 個數字代表什麼?是這段文字「意思」在 1024 維語意空間裡的座標。意思相近的文字 → 座標相近。單一維度沒有人類看得懂的意義,是 1024 個一起編碼語意。查詢時就靠這個:把問題也算成一個 1024 維向量,找座標最近的片段。
演算法③ 檢索 + 授權:一條 SQL 全做完
查詢時,問題不切(chunking 只發生在文件),而是整句 embed 成一個向量,再交給 pgvector 找最近的 top-k。而且——授權要在這一步就擋住。一條 SQL 同時做「授權過濾 + 向量搜尋」:
SELECT c.text, b.title, c.section, c.page_from
FROM manual_chunks c
JOIN manual_books b ON b.book_id = c.book_id
JOIN access_grants g ON g.book_id = c.book_id
AND g.principal = %(caller)s -- 授權:只撈這人有權的書
ORDER BY c.embedding <=> %(qvec)s::vector -- 向量搜尋:cosine 距離
LIMIT %(k)s;
- 授權在檢索層完成:沒授權的手冊連被撈到的機會都沒有——不是查完再過濾,是查的當下就擋。
<=>= cosine 距離(因嵌入已 normalize);pgvector 另有<->(L2)、<#>(內積)。- book-aware 別名路由:問句命中某本書的
aka別名(如「空壓機」)→ 可再加條件只查那本。
演算法④ 向量怎麼進 SQL(第一次玩 pgvector 必踩)
「字串的 SQL 我懂,向量的 SQL 怎麼寫?」答案:向量在 SQL 裡就是一個字串字面值 '[n1,n2,…,n1024]',加 ::vector 轉型,沒有魔法。
# 方法 A:裝 pgvector adapter,直接傳 numpy(最乾淨)
from pgvector.psycopg import register_vector
register_vector(conn)
cur.execute("INSERT INTO manual_chunks(...,embedding) VALUES(...,%s)", (..., vec))
# 方法 B:自己拼字串
vec_str = "[" + ",".join(str(x) for x in vec) + "]"
cur.execute("INSERT INTO manual_chunks(...) VALUES(..., %s::vector)", (..., vec_str))
list 傳給 psycopg 又不註冊 adapter——它會變成 PG 的陣列 '{...}'(大括號),那是 float[] 不是 vector '[...]'(中括號),型別對不上會報錯。要嘛 adapter 傳 numpy,要嘛自己拼 '[...]' 字串。
補一個實戰細節:寫入端不用寫 ::vector,查詢端要。 整包灌資料時(psql -f build_data.sql),INSERT 進 vector 欄的值就是字面字串 '[n1,n2,…]',PostgreSQL 會在寫入時隱式轉型,不必手動加 ::vector。但查詢時把外來的問題向量當參數傳,型別是未定的,就必須 %(qvec)s::vector 明確轉,否則會被當成 PG 陣列 {...} 對不上型別。同一個 '[...]' 字面值,寫入靠隱式、查詢靠顯式,是最容易搞混的一點。
SERVE 端實務:API 啟動載入 bge-m3,請求進來即時轉向量
一個關鍵但常被誤解的實作細節:PostgreSQL 不會 embed,它只會「比向量」。所以「把問題文字變成向量」這件事,必須在 API 這台做。實務流程是:
- API 服務一啟動,就把 bge-m3 載入記憶體一次(約 2GB RAM),之後常駐待命。
- 請求進來,取
POSTbody 裡的question→ 用已載入的那顆模型即時算成 1024 維向量。 - 把這個向量丟進 SQL(
ORDER BY embedding <=> qvec)→ PG 這時才知道哪些片「接近」。
| 階段 | embed 什麼 | 負擔 | GPU? |
|---|---|---|---|
| BUILD(一次) | 整批 1251 片 | 重(上千次前向) | 佳 |
| SERVE(每次查) | 一句問題 | 極輕(幾十字、一次前向) | 不用,CPU 幾十~兩百毫秒 |
embed 一句話只是一次、幾十字的前向運算,CPU 幾十到兩百毫秒就好,不會「燒乾 CPU」。真正吃資源的是 BUILD 那批 1251 片——而 BUILD 可以丟到別台有 GPU 的機器(甚至 Kaggle)跑一次,向量灌進 PG 就永久存著,API 這台一輩子不碰 GPU。而且查詢時完全沒有切片(問題不做 chunk),所以 SERVE 端很輕。只有並發衝到每秒數百查詢、CPU 來不及時,才考慮加核心或把 embed 拆成一個擺 GPU 的共用微服務。
把上面三步落成程式。先是查詢 SQL(授權 JOIN + 向量搜尋一條做完):
SELECT c.text, b.title, c.section, c.page_from
FROM manual_chunks c
JOIN manual_books b ON b.book_id = c.book_id
JOIN access_grants g ON g.book_id = c.book_id
AND g.principal = %(caller)s -- 授權:只查這人有權的書
ORDER BY c.embedding <=> %(qvec)s -- 向量搜尋,<=> = cosine 距離
LIMIT %(k)s;
再來是 FastAPI:啟動時載入 bge-m3 一次,請求進來即時把問題轉向量、丟進上面那條 SQL:
import os, numpy as np, psycopg
from pgvector.psycopg import register_vector
from sentence_transformers import SentenceTransformer
from fastapi import FastAPI, Header, HTTPException
from pydantic import BaseModel
PG_DSN = os.environ["PG_DSN"]
API_KEYS = {"key-maint": "agent_maintenance", "key-all": "agent_all"} # key -> principal
app, MODEL = FastAPI(), None
# 步驟①:服務一啟動,把 bge-m3 載入記憶體一次(約 2GB,CPU 即可)
@app.on_event("startup")
def _load():
global MODEL
MODEL = SentenceTransformer("BAAI/bge-m3") # dim = 1024
class Query(BaseModel):
question: str
k: int = 4
SQL = """
SELECT c.text, b.title, c.section, c.page_from
FROM manual_chunks c
JOIN manual_books b ON b.book_id = c.book_id
JOIN access_grants g ON g.book_id = c.book_id AND g.principal = %(caller)s
ORDER BY c.embedding <=> %(qvec)s
LIMIT %(k)s;
"""
@app.post("/rag/search")
def search(q: Query, authorization: str = Header(default="")):
caller = API_KEYS.get(authorization.removeprefix("Bearer ").strip())
if not caller:
raise HTTPException(401, "bad api key")
# 步驟②:用「已載入」的模型,把 question 即時算成 1024 維向量
qvec = MODEL.encode(q.question, normalize_embeddings=True)
# 步驟③:把向量丟進 SQL,PG 算距離找最近
with psycopg.connect(PG_DSN) as conn:
register_vector(conn) # numpy 才能當 vector 傳(不然變 PG 陣列)
rows = conn.execute(SQL, {"caller": caller,
"qvec": np.asarray(qvec, dtype="float32"),
"k": q.k}).fetchall()
return [{"text": t, "title": bt, "section": s, "page": p} for t, bt, s, p in rows]
startup 載一次,不是每請求重載;② MODEL.encode() 在 API 這台做(CPU、一句話幾十毫秒),PG 收到的是算好的向量;③ register_vector(conn) 是把 numpy 安全傳進 SQL 的關鍵,否則會變成 PG 陣列、型別對不上。
包成 MCP:所有 agent 共用同一套 R
RAG = R(檢索)+ A(增強)+ G(生成)。平台提供 R(那條授權 SQL),消費端的 agent 自帶 G(它自己的 LLM 算力)。把 R 包成一個 MCP server,任何支援 MCP 的 agent 掛上就能用,不用每個 agent 重寫檢索。MCP 工具只暴露一個輸入欄位:
{
"name": "search_manual",
"description": "在授權範圍內檢索工廠設備手冊,回最相關片段與出處(書·章·頁)",
"inputSchema": {
"type": "object",
"properties": {
"question": { "type": "string", "description": "自然語言問題" },
"k": { "type": "integer", "default": 4 }
},
"required": ["question"]
}
}
agent 只管下 question;它能看到哪些手冊,由 MCP server 配的那把 key 對應的授權決定——授權不暴露給 agent。這樣同一套後端,不同 agent / 不同租戶各看各的手冊。
實戰:不是 PPT,真的在 CPU 機器上跑通了
以上不是紙上談兵。我把整套在一台無 GPU 的 CPU 機器上實際部署、端到端驗證過:一台遠端的 Claude Code 透過 MCP 問「抽水機的簡易故障排除有哪些」,答案出自真手冊、標明書·章·頁回來,不是編的。
| 項目 | 實測 |
|---|---|
| 手冊 | 6 本(抽水機 / 空壓機 / CNC / 變頻器 / PLC / 工業爐) |
| 切片 × 維度 | 1435 片 × bge-m3 1024 維 ≈ 6MB |
| BUILD 嵌入 | CPU ~45 分鐘(90 batch);有 GPU 幾分鐘 → 印證「BUILD 吃 GPU」 |
| SERVE 查詢 | 單句 embed 幾十毫秒,CPU 免 GPU |
| 服務 | pgvector 容器 :5435 / FastAPI :8100 / MCP :8771 |
MCP 用「合併」策略:把 search_manual 加進機器上既有的 MCP endpoint(原本已有別的工具),同一個 8771 一次多一個查手冊的工具,遠端任何 agent 掛上就能用,不影響既有工具。完整建置步驟(pgvector 容器、依賴、build、ingest、授權、API、MCP 合併)寫成 DEPLOY.md 收進 repo,照著能重建。
踩坑①:「只切一本」的旗標是空殼
build_index 加了 --only / --dry-run 兩個參數,--help 看得到、文件也寫了——但 main() 的迴圈從頭到尾沒讀 args.only,照樣重切全部書。它是「假實作」:參數宣告了、行為沒接,不會報錯、看起來完全做完。抓到它的方式是跑一次 --dry-run --only <某本> 想「秒回證明它認得這本」,結果卡了兩分鐘沒輸出——dry-run 該瞬間卻在切全部 PDF,反常才去讀 main() 全文。教訓:diff 只有「宣告」沒有「使用」就起疑;加了旗標/欄位/開關,一定親手跑一次看行為真的變了,別信 --help 和 commit message。
踩坑②:服務別綁在登入 session 上
API 和 MCP 一開始是用 nohup 手動起的,綁在某個終端/session。session 一關(或機器斷電),服務就全被 SIGHUP 帶走,遠端一連就吃 502。工廠環境「斷電後不會自己起來」是不可接受的——這在每一台交付出去的機器都會踩。正解是做成 systemd service(開機自啟、崩潰自動重拉),而不是 nohup。手動起的常駐服務,遲早會因為某次重開機變成一通「怎麼又連不到」的電話。
踩坑③:.env 裡的 JSON 值要用單引號包住
把 API 金鑰對照表放進 .env:API_KEYS={"key-all":"agent_all"}。用 set -a; . .env 載入後,bash 的 quote removal 會把裡面的雙引號吃掉,變成 {key-all:agent_all} → 不是合法 JSON → json.loads 在 import 期就爆、app 啟動即死,而且 log 還是空的(最難查的一種)。修法:JSON 值整包用單引號包住 — API_KEYS='{"key-all":"agent_all"}',雙引號才保得住。通則:任何要餵給程式做 json.loads 的字串,經過 bash 一層都要先 echo 出來確認引號沒被吃。
3 年怎麼運作:手冊持續疊代
平台上線後,手冊會不斷新增、改版。核心原則:只動變動的那一本,其他不碰。四種維護操作:
| 操作 | 怎麼做 | 要 re-embed? |
|---|---|---|
| ➕ 新增一本 | 只對那本 PDF 切+嵌 → upsert books + insert chunks | 要(僅那本) |
| 🔄 改版一本 | 交易內:DELETE chunks WHERE book_id → 重切嵌 → INSERT 新片 | 要(僅那本) |
| ➖ 刪一本 | DELETE chunks + DELETE book(CASCADE 連授權一起) | 不用 |
| 🔑 授權變更 | 純加/刪 access_grants 一列 | 不用 |
為什麼改版是「整本刪掉重灌」而不是逐列 UPDATE? 因為 PDF 改版後,片的數量和邊界都變了,無法一對一對應舊列。整本 DELETE + INSERT 包在一個 transaction 裡最乾淨,查詢中途不會看到半套資料。
實戰補完:把「改一本」做成非同步佇列
上面那張表是原則,真的上線後把它做成一條非同步佇列:上傳一本 → 建一筆 job(queued)→ 背景 worker 搶單處理 → ready/failed。air-gapped 環境不想引 Redis,所以直接拿 Postgres 當 job queue,用 FOR UPDATE SKIP LOCKED 讓多個 worker 不會搶到同一筆;搶到後只對那一本跑 build_index --only <book_id> 再 ingest,失敗就把 error 寫回 job、status=failed,不卡住整條佇列。
-- worker 搶一筆最舊的 queued,SKIP LOCKED 讓多 worker 安全
UPDATE ingest_jobs SET status='processing', started_at=now()
WHERE id = (
SELECT id FROM ingest_jobs WHERE status='queued'
ORDER BY created_at LIMIT 1
FOR UPDATE SKIP LOCKED
)
RETURNING id, book_id, action;
疊代最貴的坑:增量其實不增量
疊代成本藏在一個不起眼的地方:如果 chunk_id 用的是位置序號(檔名#1、#2…),一本書重切時只要中間多/少一段,後面所有片的序號整個位移,ON CONFLICT (chunk_id) 就對不上舊列 → 等於整本重嵌。bge-m3 在 CPU 上很慢(數千片可達小時級),於是「改版一本、只動 10%」卻付 100% 的嵌入成本。在「3 年反覆改版」的場景會不斷放大。
擴充第二套語料:專屬表 vs 通用化
平台跑順後,要吃第二套語料(公司規章,140 本)。當下的做法是照手冊的樣子,開一組專屬的 regulations/reg_chunks 表、專屬的 /rag/regulations/search endpoint、專屬的 search_regulations MCP 工具。灌資料只是 psql -f build_data.sql 一包進去、現有 MCP 一行不改。能動——但它暴露一個長期問題:每多一種資料,就要改 schema + API + MCP 好幾個檔,成本是 O(資料種類),再乘上廠區數就不可維護。
這是一個值得停下來想的架構岔路,我找了架構、PM、資料工程、資安四個視角一起評估「專屬表 vs 通用一張表」:
| 做法 | 優 | 劣 |
|---|---|---|
| 專屬表(每種資料一組) | 可做豐富的結構化過濾(依分類/版次);MCP 工具語義明確 | 加一種=改 schema+API+MCP 五個檔;版本矩陣爆炸;每個口都可能忘記上鎖 |
通用一張 doc_chunks |
serving 成本 O(1);一份 restore/runbook 打天下;攻擊面單一好稽核 | 結構化過濾要靠 metadata 欄/JSONB,不如專欄直接 |
四個視角結論一致(而且都補一句:schema 形狀對檢索品質其實零影響——品質由切片粒度與 embed_text 決定,不是表名):
doc_chunks 表 + 一個 generic 搜尋工具。 domain 的差異(切法、章節邊界、metadata 前綴)全吸收在 build 階段的 profile,不外溢到 serving。一句話準則:serving code 只因「出現新的結構化查詢形態」而增加,永遠不因「來了新資料/新廠區」而增加。 加新資料 = 一包資料進去,查詢的 code 一行不動。既有的規章 bespoke 那套凍結成唯一樣板,不再仿製第二、第三個。
順著這個方向,把切片/嵌入抽成一個跟資料脫鉤的通用工具:多格式抽取(docx/xlsx/pptx 純標準庫零安裝、舊 .doc 硬抽、pdf、xls)、換一個 profile 就能吃不同來源、同一份向量可產 build_data.sql(pgvector)或 index.npz(零依賴本地索引)。方法論固定、資料可換,才撐得起「多廠區、多語料、定期重交」的長期營運。
想清楚給誰用:工安不是合規
技術做著做著,最關鍵的一課其實不是架構,是使用者。一套「把所有 PDF 集中管理」的系統,對上很好交代(有文件、有教育訓練),但實際上多到跟海一樣、沒人看得到 = 等於沒有,出事了才在翻手冊——已經太遲。而真正該幫的現場工人,看的是印出來的紙、不理總公司、廠區封閉,PDF 跟他們的日常根本是兩個世界。
怎麼知道它準不準:兩段式評估 + 實測數字
做完最該問的一句:這套到底準不準? RAG 有兩個不確定點,關鍵是分開量,別只量端到端——不然出錯你不知道是哪一段爛。
| 階段 | 性質 | 怎麼量 |
|---|---|---|
| ① 檢索(問題→向量→撈出該讀的段) | 確定性(同題同結果) | gold set(真問題→應命中哪本/哪段)→ Hit@k / MRR。可像單元測試反覆跑,換切法/模型就重跑對比。 |
| ② 生成(拿那些段→Agent 產答案) | 非確定性(每次不一樣) | faithfulness(有沒有幻覺、只用檢索到的內容)+ 同題跑 N 次看變異 + LLM-as-judge(先用人工標註校準評審)。 |
好消息:走抽取式(直接秀原文 + 出處、不自由生成)幾乎把第二段的不確定性工程掉——答案「就是」那段原文、引用天生精確。所以量測重心壓倒性落在第一段的檢索命中 + abstain 門檻。
實測:6 本手冊、16 題,書級 vs 段級
拿現有 6 本(1,435 片)寫了一支評估腳本(bge-m3 編問題 → pgvector cosine top-k → 比對應命中的 book_id / section),16 題(13 in-scope + 3 out-of-scope)。結果:
| 指標 | 數字 | 解讀 |
|---|---|---|
| Book-Hit@1(對的手冊) | 92% | 撈到「對的書」很穩,top3 全同一本 = 聚焦好 |
| Section-Hit@1(對的那一段) | 27% | top-1 常是「對的書、錯的段」——工安要看的是這個 |
| Section-Hit@3 / @5 | 73% / 91% | 對的那一段幾乎一定在前 5,只是沒排第一 |
| abstain 門檻(資料推出) | ≈ 0.52 | in-scope 最低 0.566、離題最高 0.471,無重疊 → 低於 0.52 就回「查不到」,100% 擋掉離題又不誤殺 |
兩個要老實講的:①這是我出的 DEMO 題(非老師傅標),數字示範「方法」,不是最終準確率;②段級用 keyword 比對 section 標題偏嚴,有些「✗」其實撈到相關段、只是標題沒那個字,所以 27% 是悲觀下限。真數字要老師傅判「這段對不對」。現成框架可參考 RAGAS(指標剛好分檢索/生成兩段)、DeepEval、promptfoo。
調適嘗試:四個檢索槓桿,全撞在同一道牆
段級 top-1 只有 27%,想把它調高,試了四招(都是「撈完再重排/融合」這一類):
| 方法 | Section-Hit@1 | @5 |
|---|---|---|
| baseline(純 cosine) | 27% | 91% |
| + cross-encoder reranker | 36% | 55% ↓ |
| + query instruction 前綴 | 36% | 64% ↓ |
| + hybrid(字面 BM25 + 向量 RRF) | 36% | 73% ↓ |
四個獨立槓桿全部撞在 ~36% 這道牆,而且都犧牲 @5。這種「不同方法收斂到同一個數」的訊號,通常代表天花板不在你正在調的地方。
轉折:先驗證你的「尺」——27% 其實是假象
差點下「調不動」的結論前,做了一件該一開始就做的事:把每題 top-1 實際撈到的內文印出來,自己當老師傅判「這段到底對不對」,不看我標的 keyword。結果打臉:
例:「進水管漏氣」top-1 的 section 標題是「三、啟動及啟動後操作注意事項」(keyword 不中),但內文是「[抽水機無法抽水] 症狀:泵浦漏氣 → 處置:清潔孔螺絲未鎖緊、逆止閥無密合…」——內容完全正確。PLC、HMI、CNC、電弧爐節能全是同一回事:內容撈對了,是被切歪的 section 標題騙了我的尺。
所以真正的改變(+ 核心 code)
釐清後,要動的地方跟原本想的完全不同:檢索不用改(~91%)、生成走抽取式不用改。唯一真的壞掉的是 section 標籤被切歪 → 給工人看的「出處(書·章·頁)」會標錯章節。三個改變:
- 根治(build 端):修 section 標籤——讓每片掛到內文真正所屬的標題。這是唯一該投入的技術活。
- 止血(serving 端,現在就做):出處以「原文 + 頁碼 + 分數」為主,section 為輔,並加 abstain。
- 評估:改判「內文對不對」,別用 keyword 比 section 標題。
第 ② 項的核心 code(API 檢索端,向後相容):
# 1) 請求加 abstain 門檻(實測:in-scope 最低 0.566、離題最高 0.471 → 取 0.52)
class SearchBody(BaseModel):
question: str
k: int = 4
min_score: float | None = None # None = 不啟用(向後相容)
# 2) 檢索 SQL 多回一個 cosine 分數
# 1 - (c.embedding <=> %(qvec)s::vector) AS score
# 3) 回傳:出處以「原文 + 頁碼 + 分數」為主,section 為輔(它會標歪)
# 低於門檻直接不回 = 查不到(答錯=工安,寧可閉嘴)
results = []
for r in rows:
score = float(r[4])
if body.min_score is not None and score < body.min_score:
continue # abstain
results.append({
"text": r[0], "page_from": r[3], "score": round(score, 4),
"book_title": r[1], "section": r[2], # section = best-effort,勿當唯一權威
})
return results
思考邏輯:內文與頁碼是可信的(撈對了、頁也對),section 標題被切歪不可信——那就把可信的當主出處、可疑的當輔,別讓錯標題騙工人。再用 0.52 門檻把離題擋掉(實測離題最高 0.471 < in-scope 最低 0.566,兩群無重疊,切得乾淨)。這是「答錯=工安」前提下的止血:內容已經對了,先讓出處別騙人 + 該閉嘴就閉嘴;根治(重切 section)另立 build-side 專案。
心法總結
- 切開 BUILD 與 SERVE:準備一次、查詢無數次。實驗 notebook 的原罪就是每次重切。
- RAG 不改模型:預訓練團隊算的是索引不是權重;缺知識餵資料,別動腦。
- 授權長在檢索裡:一條 JOIN,沒權的書進不了候選,比事後過濾安全。
- 包成 MCP:R 做成工具,人人可掛;G 由各 agent 自帶。這是「一次建、全公司用」的關鍵。
- 維護只動一本:增量疊代不重來;唯一例外是換嵌入模型要全庫重建,所以把它鎖死。
發佈留言