手冊 RAG 上線架構:四團隊 + pgvector + MCP

重點摘要
  • 一支實驗 Jupyter 要上線,得拆成 四個團隊:營用(給文件)→ 預訓練(算向量)→ 維運(建庫+API+MCP)→ 使用(任何 agent)。
  • RAG 不是訓練模型。「預訓練團隊」做的是算 embedding 建索引,模型原廠不動——BUILD 一次,SERVE 無數次
  • 知識庫落 PostgreSQL + pgvector:三張表(書 / 片+向量 / 授權),檢索與授權用一條 SQL 同時完成。
  • 檢索包成 一個 MCP,之後任何 agent 掛上就能用,授權由後端擋。
  • 手冊 3 年持續疊代:只動變動的那一本(切嵌→upsert),其他不碰;唯一「牽一髮動全身」是換嵌入模型 = 全庫重建。
四團隊交互 UML 循序圖
四團隊交互 UML 循序圖:BUILD 營用→預訓練→維運;SERVE Agent→API→PG→生成
🧭 本文導覽
Part 一 — 為什麼要拆團隊
Part 二 — 核心觀念與資料庫
Part 三 — 演算法講清楚
Part 四 — 服務化與長期運維
Part 一 — 為什麼要拆團隊

一支 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 就能問手冊
① 營用
手冊 PDF
manifest.yaml
② 預訓練
切片(CPU)
bge-m3→1024維
③ 維運
PG+pgvector
API+授權
包 MCP
④ 使用
任何 Agent
掛 MCP 就問
◄─────── BUILD(一次 / 增量)───────►◄──────── SERVE(永久)────────►
Part 二 — 核心觀念與資料庫

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 實體關係圖:

三張表 UML 實體關係圖
UML 實體關係圖:manual_books 1—N manual_chunks / access_grants

對應的建表 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 兩欄即可。
Part 三 — 演算法講清楚

演算法① 切片策略:有目錄用目錄,沒有就看字級

切片(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 不是你的資料筆數、不是簡介、不是 title 造成的。它是 bge-m3 這顆模型天生固定的輸出維度。換模型維度就變(bge-small 是 384、OpenAI 是 1536)。title 只影響「向量編碼了什麼意思」(因為我們把書名/機器也一起嵌進去,讓檢索更準),不影響幾維

那 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 別名(如「空壓機」)→ 可再加條件只查那本。
1251 片需要重型向量資料庫嗎?不用。 1251 × 1024 維 × 4 bytes ≈ 5MB。這個量級 seq scan 算 cosine 都是亞毫秒,連 HNSW 索引都可省。選 PostgreSQL 純粹是為了授權 + 運維一體,不是效能。Qdrant/Milvus 那種要到千萬級 + 高併發才需要。

演算法④ 向量怎麼進 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))
大坑:別直接把 Python list 傳給 psycopg 又不註冊 adapter——它會變成 PG 的陣列 '{...}'(大括號),那是 float[] 不是 vector '[...]'(中括號),型別對不上會報錯。要嘛 adapter 傳 numpy,要嘛自己拼 '[...]' 字串。
Part 四 — 服務化與長期運維

補一個實戰細節:寫入端不用寫 ::vector,查詢端要。 整包灌資料時(psql -f build_data.sql),INSERTvector 欄的值就是字面字串 '[n1,n2,…]',PostgreSQL 會在寫入時隱式轉型,不必手動加 ::vector。但查詢時把外來的問題向量當參數傳,型別是未定的,就必須 %(qvec)s::vector 明確轉,否則會被當成 PG 陣列 {...} 對不上型別。同一個 '[...]' 字面值,寫入靠隱式、查詢靠顯式,是最容易搞混的一點。

SERVE 端實務:API 啟動載入 bge-m3,請求進來即時轉向量

一個關鍵但常被誤解的實作細節:PostgreSQL 不會 embed,它只會「比向量」。所以「把問題文字變成向量」這件事,必須在 API 這台做。實務流程是:

  1. API 服務一啟動,就把 bge-m3 載入記憶體一次(約 2GB RAM),之後常駐待命。
  2. 請求進來,取 POST body 裡的 question → 用已載入的那顆模型即時算成 1024 維向量。
  3. 把這個向量丟進 SQL(ORDER BY embedding <=> qvec)→ PG 這時才知道哪些片「接近」。
那 API 這台需要 GPU 嗎?不用。 關鍵是分清 embed「一句問題」和 embed「整批文件」負擔天差地別。
階段 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
端到端驗證:遠端 Claude Code → MCP → API → bge-m3+pgvector → 回「五、簡易故障排除 第 9 頁」等真片段,答案有書·章·頁可核對。這證明「BUILD 一次 / SERVE 無數次 + MCP 共用」的架構能落地,不只是圖——而且真的做到「API 那台無 GPU、單句 query 用 CPU embed」。

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 裡最乾淨,查詢中途不會看到半套資料。

唯一「牽一髮動全身」:換嵌入模型 = 全庫重建。 所有向量必須是同一顆 bge-m3 算的才能互相比距離。哪天想換更強的嵌入模型,向量空間變了、維度可能也變(例如 1024→768),整張 manual_chunks 的向量要全部重算、欄位要 ALTER。所以:嵌入模型固定住,這是設計上要鎖死的決定。

實戰補完:把「改一本」做成非同步佇列

上面那張表是原則,真的上線後把它做成一條非同步佇列:上傳一本 → 建一筆 job(queued)→ 背景 worker 搶單處理 → readyfailed。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 年反覆改版」的場景會不斷放大。

修法:用內容雜湊當快取鍵。 把快取鍵改成 embed_text 的內容 hash 而非位置序號。重切時內容沒變的片 hash 一樣、直接命中快取拿舊向量,只嵌真正變動的那幾片。位置序號當主鍵,是「增量更新」場景會反咬一口的隱形地雷。

擴充第二套語料:專屬表 vs 通用化

平台跑順後,要吃第二套語料(公司規章,140 本)。當下的做法是照手冊的樣子,開一組專屬regulationsreg_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 跟他們的日常根本是兩個世界。

一句話心法:這是工安題,不是合規題。 目標是讓現場工人在出事前幾秒、就這台機器、問一句就拿到答案。所以產品的靈魂不在 schema,而在:鎖定「這一台機器」(候選集只從這台的手冊出)、答案永遠附出處(書·章·頁)可核查不到就明講不硬掰(答錯=工安)、把 MCP 當穩定的後端合約、前端(語音/機台掃碼/LINE)隨各廠現場可插拔。若答錯或答不全,它只會變成更難拆穿的「合規劇場 2.0」——所以「抽取品質關卡」在這裡不是工程議題,是工安底線。

怎麼知道它準不準:兩段式評估 + 實測數字

做完最該問的一句:這套到底準不準? 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% 擋掉離題又不誤殺
關鍵洞察:「92% 準」是假的安心。 書級 92% 掩蓋了段級只有 27%——工人要的是對的那一段,不是對的那本書。這個落差 book-level 數字完全看不到,一定要驗到 section 級才現形。而段級 top-1 弱、top-5 強(91%)→ 修法很明確:現場一次秀 top-3~5 段讓人挑、或加 reranker 把對的段重排到第一。

兩個要老實講的:①這是我出的 DEMO 題(非老師傅標),數字示範「方法」,不是最終準確率;②段級用 keyword 比對 section 標題偏嚴,有些「✗」其實撈到相關段、只是標題沒那個字,所以 27% 是悲觀下限。真數字要老師傅判「這段對不對」。現成框架可參考 RAGAS(指標剛好分檢索/生成兩段)、DeepEval、promptfoo。

調適嘗試:四個檢索槓桿,全撞在同一道牆

段級 top-1 只有 27%,想把它調高,試了四招(都是「撈完再重排/融合」這一類):

方法 Section-Hit@1 @5
baseline(純 cosine)27%91%
+ cross-encoder reranker36%55% ↓
+ query instruction 前綴36%64% ↓
+ hybrid(字面 BM25 + 向量 RRF)36%73% ↓

四個獨立槓桿全部撞在 ~36% 這道牆,而且都犧牲 @5。這種「不同方法收斂到同一個數」的訊號,通常代表天花板不在你正在調的地方。

轉折:先驗證你的「尺」——27% 其實是假象

差點下「調不動」的結論前,做了一件該一開始就做的事:把每題 top-1 實際撈到的內文印出來,自己當老師傅判「這段到底對不對」,不看我標的 keyword。結果打臉:

判內文的話,top-1 正確 ≈ 10/11 = 91%,不是 27%。
例:「進水管漏氣」top-1 的 section 標題是「三、啟動及啟動後操作注意事項」(keyword 不中),但內文是「[抽水機無法抽水] 症狀:泵浦漏氣 → 處置:清潔孔螺絲未鎖緊、逆止閥無密合…」——內容完全正確。PLC、HMI、CNC、電弧爐節能全是同一回事:內容撈對了,是被切歪的 section 標題騙了我的尺。
最貴的一課:對一個指標調參之前,先驗證那個指標本身是對的。 「27% / 調不動 / 撞 36% 的牆」全是我拿 keyword 比對髒的 section 標題量出來的假象。reranker、prefix、hybrid 四招,全部是在追一個幻影——檢索本來就 ~91% 好。沒驗尺就開始調參,是最容易白忙一場、還下錯結論的坑。

所以真正的改變(+ 核心 code)

釐清後,要動的地方跟原本想的完全不同:檢索不用改(~91%)、生成走抽取式不用改。唯一真的壞掉的是 section 標籤被切歪 → 給工人看的「出處(書·章·頁)」會標錯章節。三個改變:

  1. 根治(build 端):修 section 標籤——讓每片掛到內文真正所屬的標題。這是唯一該投入的技術活。
  2. 止血(serving 端,現在就做):出處以「原文 + 頁碼 + 分數」為主,section 為輔,並加 abstain
  3. 評估:改判「內文對不對」,別用 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 自帶。這是「一次建、全公司用」的關鍵。
  • 維護只動一本:增量疊代不重來;唯一例外是換嵌入模型要全庫重建,所以把它鎖死。

留言

發佈留言

發佈留言必須填寫的電子郵件地址不會公開。 必填欄位標示為 *