本案例記錄一個技術 blog(2,738 篇 markdown、24,216 chunks)的語意搜尋工具從 demo 到 production 的完整過程。每段標出對應 4.22 RAG storage 工程 的哪個判讀步驟,讓讀者看到原理章的框架怎麼落到具體決策。

實測日期:2026-07-01 環境:macOS Apple Silicon、Ollama 0.7.x、nomic-embed-text(768 維) Corpuscontent/ 全量 2,738 個 markdown 檔、24,216 chunks 前置 demorag-demo(pickle、463 chunks)

讀法建議

本案例用 Go 重寫了 RAG storage 層,Go 實作細節佔不少篇幅。依你的背景選讀法:

  • Python 開發者、想選自己專案的 storage 方案:先跳到「通用可複製流程」(語言無關的五步驟)→「四方案 benchmark」→「二次選型評估」(結論/理由/前提三層框架),這三段跨語言可遷移。Go 實作段(架構、效能優化)可 skim。
  • Go 開發者、想做類似工具:從頭讀,每段都跟你相關。
  • 只想看選型框架、不管實作:直接跳「二次選型評估」。

從 demo 到 production 的重寫動機

rag-demo 用 Python pickle 跑通了 RAG 概念驗證:71 篇 → 463 chunks → pickle 儲存 → cosine retrieval → Ollama 生成。概念層完全正確(4.1 的 retrieval + augmentation 骨架),但作為這個 blog 的日常工具有三個專案特有的限制:

  1. 工具鏈語言不同:blog 的核心工具是 Go(lint / fmt / cards),加 Python dependency 讓其他維護者 clone 後多一步環境設定。Python 專案不會有這個問題 — pickle 綁 Python 對 Python 專案是優點而非缺點。
  2. 只索引部分 corpus:rag-demo 只跑 content/llm/(71 篇),blog 全量有 2,738 篇、24 個 section。
  3. Demo 定位:ingest.py / query.py 是教學程式碼,不是維護工具(沒有 status、沒有 section filter)。

這是一次完整重寫、不是漸進升級 — rag-demo 的 Python 程式碼不會被修改或遷移,而是用 Go 重新實作相同的 RAG pipeline(chunk → embed → store → search)、保留相同的概念架構。rag-demo 作為教學 demo 繼續存在。

升級目標:一個跟 mdtools 同級的 Go CLI 工具,能對全量 content 做語意搜尋,其他維護者 clone 後 go build 即可用。完整原始碼在 scripts/blogsearch/

選型過程(對應 4.22 演化階梯 + 工程約束)

第一軸:規模判讀

全量 content 產生 24,216 chunks(原本估計 ~1,500)。按 4.22 判讀樹,24K 落在「10K-100K → HNSW 或 brute-force」區間。預估 vs 實際的 16 倍落差揭露一個教訓:估計 chunk 數不能用篇數乘以常數,要看每篇的實際長度跟 chunking 策略。

第二軸:工程約束(本專案特有)

以下四個 constraint 反映這個 blog 專案的偏好、不是通用判準。換一組 constraint 會篩出完全不同的方案 — Python 專案不會有「Go 單 binary」constraint、已有 Docker 的團隊不會排斥外部 server。讀者套用時應先列出自己專案的 constraint、不是照搬這張表。

Constraint砍掉什麼
Go 單 binaryPython-only 方案(pickle / FAISS)
不要 CGosqlite-vec(需要 mattn/go-sqlite3
不要外部 serverQdrant / Weaviate / Pinecone
Ollama 原生OpenAI / Cohere embedding(多一個 API key)

剩餘選項:Go + flat file + brute-force

第三軸:延遲容忍

CLI 工具、每天用幾次、不是 API server。< 500ms 可接受。

結論:選階段二(flat file),brute-force cosine。

實作架構

 1scripts/blogsearch/
 2├── main.go                     # CLI: ingest / query / status
 3├── cmd/
 4│   ├── ingest.go               # walk content/ → chunk → embed → store
 5│   ├── query.go                # load → embed query → cosine top-K → lazy load text
 6│   └── status.go               # index stats
 7└── internal/
 8    ├── chunk/chunk.go           # paragraph-aware markdown chunking
 9    ├── embed/embed.go           # Ollama HTTP API wrapper
10    ├── search/search.go         # brute-force cosine similarity
11    └── store/store.go           # 三檔案 binary store

日常使用

1# 語意搜尋
2./bin/blogsearch query "retry 策略"
3
4# 只搜特定 section
5./bin/blogsearch query -section backend "connection pool 設定"
6
7# 查 index 狀態
8./bin/blogsearch status

Storage 格式(三檔案分離)

1.blogsearch/
2├── vectors.bin    # float32 binary(70.9 MB)— bulk read + unsafe.Slice 零拷貝
3├── meta.json      # compact metadata 不含 text(7.3 MB)
4└── texts.bin      # length-prefixed chunk text(19.2 MB)— top-K 才 lazy load

分離 text 的設計理由:query 時只需要 vectors + metadata 做 cosine search(78 MB),top-K 結果才從 texts.bin 按 offset 讀取 5 筆 text。省掉 19 MB 的 JSON 解析。

效能優化歷程

初版:9.5 秒

初版用逐 4-byte Read 載入 vectors.bin(17.5M 次 f.Read(buf)),加上 27MB 的 index.json(含所有 chunk text)一次 JSON 解析。

優化版:0.34 秒(28x)

三項改動:

改動效果
vectors.bin 讀法逐 4-byte Reados.ReadFile + unsafe.SliceI/O call 17.5M → 1
metadata 格式含 text(27 MB)不含 text(7.3 MB)JSON parse 快 4x
text 載入全量top-K lazy load(只讀 5 筆)省 19 MB 讀取

瓶頸分析:0.34 秒裡、embedding API call(Ollama)約 77ms、file I/O + JSON parse 約 200ms、cosine 計算約 50ms。cosine 計算只佔 15%。

通用可複製流程(抽掉 Go/blog)

本案例的 Go 實作細節(unsafe.Sliceos.ReadFile)是語言特定的、但背後的流程步驟跨語言通用:

  1. Walk corpus:遞迴掃描目標目錄的所有文件(markdown / code / 任意文字)
  2. Chunk:段落感知分割、soft token cap、保留語意邊界(原理見 4.1 Chunking
  3. Embed:對每個 chunk 呼叫 embedding API(本地 Ollama 或 cloud API),得到固定維度向量
  4. Store:向量 + metadata + text 分離存檔(binary vectors / compact JSON / lazy-load text)
  5. Search:embed query → brute-force cosine → top-K → lazy load text for display

Python 實作同流程只是把第 4 步的 binary 檔換成 pickle / FAISS index / SQLite DB、第 5 步的 cosine 換成 numpy / FAISS / sqlite-vec query。Node.js / Rust 同理。

關鍵優化原則也跨語言:「分離向量與文字、query 時只載入向量、top-K 才載入文字」讓 I/O 量從 ~98MB 降到 ~78MB、JSON parse 從 27MB 降到 7MB。這個原則用什麼語言實作都有效。

四方案同 corpus Benchmark

用同一個 corpus(24,216 chunks、768 維、nomic-embed-text)比較四種 storage 方案。Benchmark 腳本在 scripts/blogsearch-bench/bench.py

前置依賴

Benchmark 腳本讀 Go 工具產生的 index(.blogsearch/ 下的 vectors.bin + meta.json)。完整指令鏈:

1cd scripts/blogsearch && go build -o ../../bin/blogsearch .   # build Go 工具
2ollama serve &                                                  # 啟動 Ollama
3ollama pull nomic-embed-text                                    # pull embedding model
4./bin/blogsearch ingest -content content -out .blogsearch       # 建 index(~4 分鐘)
5uv run --with sqlite-vec --with faiss-cpu --with numpy \
6  scripts/blogsearch-bench/bench.py --index .blogsearch         # 跑 benchmark

若無 Go 環境,可用自己的 Python embedding 腳本產生相同格式的 vectors.bin(little-endian float32、n × dim 連續排列)+ meta.json{"dim": 768, "count": n, "metas": [...]}),benchmark 腳本只讀這兩個檔案、不依賴 Go binary 本身。Corpus 格式無硬性要求,任何目錄下的 .md 檔案都可索引。

方法論

  • Embedding:四方案共用同一組 embedding(從 Go index 載入),排除 embedding model 差異
  • Query:同一句 query(“RAG storage 選型”),跑 5 次取 median
  • Ingest 時間:只計 storage 操作(不含 embedding),Go 方案含 embedding 不可分離故標 —
  • 環境:macOS Apple Silicon、Python 3.12、Go 1.25

結果

方案Ingest(純 storage)Query(median)Index 大小
Go + flat file151ms97.4 MB
Python sqlite-vec2.9s19ms75.3 MB
Python FAISS flat40ms1.8msin-memory
Python FAISS HNSW23.3s0.5msin-memory

三個關鍵發現

延遲瓶頸在 I/O 和實作、不在演算法。Go flat file 的 151ms 裡、cosine 計算約 50ms、file I/O 約 100ms。FAISS flat 用 numpy BLAS 做同樣的 brute-force cosine、純計算 1.8ms — 計算層差約 28 倍(Go pure loop vs BLAS 向量化指令),加上 I/O 後端到端差 84 倍。

HNSW 的 query 加速在此規模 ROI 低。FAISS HNSW query 0.5ms vs flat 1.8ms、每次省 1.3ms。但 HNSW build 要 23.3s。每天查 100 次、要 179 天才回本 build 成本(23.3s ÷ 0.13s/天)。4.22 的判讀結論(「此規模 brute-force 夠用」)被數據驗證。

sqlite-vec 的 19ms 是「DB overhead 換功能」。比 FAISS flat 慢 10 倍、但多了 SQL metadata filter、transaction 保護、disk persistence。對「需要 filter 但不想維運 server」的場景有意義。

讀數據的注意事項

  • Go 151ms 含 file I/O(每次 query 重載 78MB);如果做 daemon mode(常駐、載入一次),query 會降到 ~50ms(純 cosine + overhead)
  • FAISS 數字是 in-memory baseline(index 已載入),不含 index 檔案的載入時間
  • sqlite-vec 數字含 disk I/O(每次 query 從 SQLite 讀取),是 persistent storage 的真實代價
  • 四方案都不含 Ollama embedding call 時間(~77ms),實際端到端延遲要加上

二次選型評估:同結論、理由鏈翻轉

Benchmark 數據出來後,80 倍效能差距讓原始選型(Go + flat file)受到質疑:「是否該換 Python + FAISS 或 sqlite-vec?」重新用 WRAP 框架評估,結論相同(維持 Go),但理由鏈完全不同。

第一次選型的理由(事前)

「Go 工具鏈統一(mdtools 是 Go)+ 單 binary 分發(clone 後 go build 即可)。」

實測推翻的前提

原始假設實測
Corpus ~1,500 chunks24,216 chunks(16 倍)
Brute-force < 10msGo 151ms(I/O 瓶頸、不是計算)
語言效能差異不大Go pure cosine vs numpy BLAS 差 80 倍
「工具鏈統一」很重要mdtools(pre-commit、延遲敏感)跟 blogsearch(手動 CLI、每天幾次)使用模式不同,強制統一語言是用「同一棟建築」邏輯要求「不同用途房間用同一種建材」

第一次的理由鏈幾乎全數被推翻。如果只看理由,應該換方案。

第二次選型的理由(事後)

重新評估時加入三個第一次沒有的變數:

端到端延遲 vs in-memory benchmark。84 倍是端到端的數字(Go 151ms 含 I/O vs FAISS 1.8ms in-memory)。但 FAISS 從 disk 載入 index 也要 ~100-200ms,端到端差距縮小到 2 倍。sqlite-vec 是唯一不需要全量載入的方案(disk-based HNSW、端到端 19ms),差距從「84 倍」變成「8 倍」。

使用頻率決定 ROI。CLI 工具、每天 ~10 次手動 query。每次省 130ms(151 vs 19),一天省 1.3 秒。重寫投入 2-3 小時,回本時間 ≈ 19 年。注意這個計算對頻率極敏感:每天 100 次(如被整合進 MCP server 當 agent 工具)回本縮短到 1.9 年、每天 1000 次則 69 天。上方 HNSW ROI 也用每天 100 次計算 — 兩處頻率假設不同是因為比較對象不同(HNSW build 成本 vs 語言重寫成本),但讀者套到自己場景時應先確定自己的查詢頻率。

Ingest 瓶頸在 Ollama API、跟語言無關。~4 分鐘的 ingest 裡、embedding API call 佔 95% 以上。換 Python 不會改善 ingest 速度。

維持的理由是「痛點不存在」

維持 Go 的理由是改善的絕對收益太小、投入回不了本 — 151ms 對 CLI 使用模式不構成痛點,與「Go 好」或「工具鏈統一」無關。

這個翻轉的教學意義

正確的結論配錯誤的理由是脆弱的。第一次 WRAP 的結論(選 Go)在當時是對的,但理由鏈(工具鏈統一、< 10ms)被實測推翻後,如果不重新建立正確的理由鏈,下次環境變動(比如 blogsearch 從 CLI 變成 API server)就會用已失效的理由做出錯誤判斷。

判讀工具選型時,要區分三層:

  1. 結論:選什麼方案
  2. 理由:為什麼選(可能被推翻)
  3. 前提:理由依賴的假設(規模、使用模式、效能數字)

前提變了、理由就要重建,即使結論沒變。寫進決策紀錄時,三層都要記 — 只記結論的話,下次重新評估時沒有判讀基礎。

區分「正當理由重建」跟「動機性推理」(先有結論再找理由)的判準:新理由是否在看到數據之前也能成立?本例的「130ms 對 CLI 不痛」在實測前也成立(CLI 使用模式本來就低頻),所以是正當重建。如果新理由只能在看到特定數字之後才講得通(如「151ms 剛好在 200ms 閾值內」——但閾值是事後設的),就是 post-hoc rationalization。

觸發換方案的訊號

訊號門檻動作
Query 延遲不可接受> 500ms先加 mmap(最小改動)
使用模式改變從 CLI 變 API server換 Python sqlite-vec
查詢頻率跳增被整合進 MCP server / agent 工具評估 daemon mode 或換 sqlite-vec
Corpus 規模跳增> 50K chunks重跑 benchmark
需要原生 metadata filtercode filter 維護成本過高換 Python sqlite-vec

Embedding model 選型(對應 4.12 constraint 優先序)

nomic-embed-text 的理由鏈:

  1. Ollama 原生支援ollama pull 一行、不需要額外 Python library 或 API key
  2. 體積小:274 MB、跟 chat model 共用記憶體不打架
  3. 已有驗證基線:rag-demo 用同一個模型跑過 463 chunks、retrieval 命中率確認可用
  4. 768 維 sweet spot:24K chunks × 768 dim × 4 bytes = 70.9 MB,brute-force 可行

未來如果 CJK retrieval 品質不夠(目前可用但未做系統性評估),multilingual-e5-largebge-m3 是備選。換模型只需改 embed.go 的 Model 變數 + 重新 blogsearch ingest(4.22 的「四層可替換」設計)。

CJK 混合 Chunking 觀察

Blog 內容是繁體中文 + 英文術語混合。Chunking 策略沿用 rag-demo 的 paragraph-aware split(空白行切段、soft token cap 400)。

Token 估算用 len(s) / 2 的 heuristic(CJK 字元多算一次)。不精確但 chunking 只需要粗略估算。跟 tokenizer 精確計算的差異在 ±20%、對 chunking 品質影響小於 chunk 邊界選擇的影響。

實際觀察:24,216 chunks 的 retrieval 品質在語意搜尋場景(「哪些文章跟 retry 有關」「RAG storage 選型」)表現良好。keyword 精確搜尋場景(「找 RFC 7807」)表現較弱 — 這是 embedding-only retrieval 的已知限制(見 4.1 的語意 vs 字面相似度對比),未來可加 BM25 做 hybrid search。

跟其他章節的對應

本案例的段落對應原理章節
選型過程4.22 演化階梯 + 工程約束
二次選型評估4.22 同 corpus 實測比較
Embedding 選型4.12 實務選型 constraint 優先序
Chunking4.1 Chunking 策略對比
Benchmark 方法論4.14 Benchmarking 方法論
Storage 格式設計4.10 衍生產物管理
Retrieval 品質4.1 Retrieval 失敗根因