<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:content="http://purl.org/rss/1.0/modules/content/"><channel><title>Vector-Database on Tarragon</title><link>https://tarrragon.github.io/blog/tags/vector-database/</link><description>Recent content in Vector-Database on Tarragon</description><generator>Hugo -- gohugo.io</generator><language>zh-TW</language><copyright>Tarragon (CC BY 4.0)</copyright><lastBuildDate>Wed, 01 Jul 2026 00:00:00 +0000</lastBuildDate><atom:link href="https://tarrragon.github.io/blog/tags/vector-database/index.xml" rel="self" type="application/rss+xml"/><item><title>Case Study：Blog 語意搜尋從 pickle 到 production</title><link>https://tarrragon.github.io/blog/llm/04-applications/hands-on/blog-vector-search/</link><pubDate>Wed, 01 Jul 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/llm/04-applications/hands-on/blog-vector-search/</guid><description>&lt;p>本案例記錄一個技術 blog（2,738 篇 markdown、24,216 chunks）的語意搜尋工具從 demo 到 production 的完整過程。每段標出對應 &lt;a href="https://tarrragon.github.io/blog/llm/04-applications/vector-storage-engineering/" data-link-title="4.22 RAG storage 工程：從 pickle 到 vector database 的選型判讀" data-link-desc="RAG storage backend 選型：規模到哪個階段該從 in-memory 升級到 vector DB、dependency chain 如何收窄選項">4.22 RAG storage 工程&lt;/a> 的哪個判讀步驟，讓讀者看到原理章的框架怎麼落到具體決策。&lt;/p>
&lt;blockquote>
&lt;p>&lt;strong>實測日期&lt;/strong>：2026-07-01
&lt;strong>環境&lt;/strong>：macOS Apple Silicon、Ollama 0.7.x、&lt;code>nomic-embed-text&lt;/code>（768 維）
&lt;strong>Corpus&lt;/strong>：&lt;code>content/&lt;/code> 全量 2,738 個 markdown 檔、24,216 chunks
&lt;strong>前置 demo&lt;/strong>：&lt;a href="https://tarrragon.github.io/blog/llm/01-local-llm-services/hands-on/rag-demo/" data-link-title="Hands-on：用 blog content 當 corpus 跑 RAG" data-link-desc="200 行 Python：embedding &amp;#43; cosine retrieval &amp;#43; Ollama chat、validating 4.0 RAG 原理">rag-demo&lt;/a>（pickle、463 chunks）&lt;/p>&lt;/blockquote>
&lt;h3 id="讀法建議">讀法建議&lt;/h3>
&lt;p>本案例用 Go 重寫了 RAG storage 層，Go 實作細節佔不少篇幅。依你的背景選讀法：&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Python 開發者、想選自己專案的 storage 方案&lt;/strong>：先跳到「通用可複製流程」（語言無關的五步驟）→「四方案 benchmark」→「二次選型評估」（結論/理由/前提三層框架），這三段跨語言可遷移。Go 實作段（架構、效能優化）可 skim。&lt;/li>
&lt;li>&lt;strong>Go 開發者、想做類似工具&lt;/strong>：從頭讀，每段都跟你相關。&lt;/li>
&lt;li>&lt;strong>只想看選型框架、不管實作&lt;/strong>：直接跳「二次選型評估」。&lt;/li>
&lt;/ul>
&lt;h2 id="從-demo-到-production-的重寫動機">從 demo 到 production 的重寫動機&lt;/h2>
&lt;p>&lt;a href="https://tarrragon.github.io/blog/llm/01-local-llm-services/hands-on/rag-demo/" data-link-title="Hands-on：用 blog content 當 corpus 跑 RAG" data-link-desc="200 行 Python：embedding &amp;#43; cosine retrieval &amp;#43; Ollama chat、validating 4.0 RAG 原理">rag-demo&lt;/a> 用 Python pickle 跑通了 RAG 概念驗證：71 篇 → 463 chunks → pickle 儲存 → cosine retrieval → Ollama 生成。概念層完全正確（4.1 的 retrieval + augmentation 骨架），但作為這個 blog 的日常工具有三個&lt;strong>專案特有的&lt;/strong>限制：&lt;/p>
&lt;ol>
&lt;li>&lt;strong>工具鏈語言不同&lt;/strong>：blog 的核心工具是 Go（lint / fmt / cards），加 Python dependency 讓其他維護者 clone 後多一步環境設定。Python 專案不會有這個問題 — pickle 綁 Python 對 Python 專案是優點而非缺點。&lt;/li>
&lt;li>&lt;strong>只索引部分 corpus&lt;/strong>：rag-demo 只跑 &lt;code>content/llm/&lt;/code>（71 篇），blog 全量有 2,738 篇、24 個 section。&lt;/li>
&lt;li>&lt;strong>Demo 定位&lt;/strong>：ingest.py / query.py 是教學程式碼，不是維護工具（沒有 status、沒有 section filter）。&lt;/li>
&lt;/ol>
&lt;p>這是一次&lt;strong>完整重寫&lt;/strong>、不是漸進升級 — rag-demo 的 Python 程式碼不會被修改或遷移，而是用 Go 重新實作相同的 RAG pipeline（chunk → embed → store → search）、保留相同的概念架構。rag-demo 作為教學 demo 繼續存在。&lt;/p>
&lt;p>升級目標：一個跟 &lt;code>mdtools&lt;/code> 同級的 Go CLI 工具，能對全量 content 做語意搜尋，其他維護者 clone 後 &lt;code>go build&lt;/code> 即可用。完整原始碼在 &lt;code>scripts/blogsearch/&lt;/code>。&lt;/p></description><content:encoded><![CDATA[<p>本案例記錄一個技術 blog（2,738 篇 markdown、24,216 chunks）的語意搜尋工具從 demo 到 production 的完整過程。每段標出對應 <a href="/blog/llm/04-applications/vector-storage-engineering/" data-link-title="4.22 RAG storage 工程：從 pickle 到 vector database 的選型判讀" data-link-desc="RAG storage backend 選型：規模到哪個階段該從 in-memory 升級到 vector DB、dependency chain 如何收窄選項">4.22 RAG storage 工程</a> 的哪個判讀步驟，讓讀者看到原理章的框架怎麼落到具體決策。</p>
<blockquote>
<p><strong>實測日期</strong>：2026-07-01
<strong>環境</strong>：macOS Apple Silicon、Ollama 0.7.x、<code>nomic-embed-text</code>（768 維）
<strong>Corpus</strong>：<code>content/</code> 全量 2,738 個 markdown 檔、24,216 chunks
<strong>前置 demo</strong>：<a href="/blog/llm/01-local-llm-services/hands-on/rag-demo/" data-link-title="Hands-on：用 blog content 當 corpus 跑 RAG" data-link-desc="200 行 Python：embedding &#43; cosine retrieval &#43; Ollama chat、validating 4.0 RAG 原理">rag-demo</a>（pickle、463 chunks）</p></blockquote>
<h3 id="讀法建議">讀法建議</h3>
<p>本案例用 Go 重寫了 RAG storage 層，Go 實作細節佔不少篇幅。依你的背景選讀法：</p>
<ul>
<li><strong>Python 開發者、想選自己專案的 storage 方案</strong>：先跳到「通用可複製流程」（語言無關的五步驟）→「四方案 benchmark」→「二次選型評估」（結論/理由/前提三層框架），這三段跨語言可遷移。Go 實作段（架構、效能優化）可 skim。</li>
<li><strong>Go 開發者、想做類似工具</strong>：從頭讀，每段都跟你相關。</li>
<li><strong>只想看選型框架、不管實作</strong>：直接跳「二次選型評估」。</li>
</ul>
<h2 id="從-demo-到-production-的重寫動機">從 demo 到 production 的重寫動機</h2>
<p><a href="/blog/llm/01-local-llm-services/hands-on/rag-demo/" data-link-title="Hands-on：用 blog content 當 corpus 跑 RAG" data-link-desc="200 行 Python：embedding &#43; cosine retrieval &#43; Ollama chat、validating 4.0 RAG 原理">rag-demo</a> 用 Python pickle 跑通了 RAG 概念驗證：71 篇 → 463 chunks → pickle 儲存 → cosine retrieval → Ollama 生成。概念層完全正確（4.1 的 retrieval + augmentation 骨架），但作為這個 blog 的日常工具有三個<strong>專案特有的</strong>限制：</p>
<ol>
<li><strong>工具鏈語言不同</strong>：blog 的核心工具是 Go（lint / fmt / cards），加 Python dependency 讓其他維護者 clone 後多一步環境設定。Python 專案不會有這個問題 — pickle 綁 Python 對 Python 專案是優點而非缺點。</li>
<li><strong>只索引部分 corpus</strong>：rag-demo 只跑 <code>content/llm/</code>（71 篇），blog 全量有 2,738 篇、24 個 section。</li>
<li><strong>Demo 定位</strong>：ingest.py / query.py 是教學程式碼，不是維護工具（沒有 status、沒有 section filter）。</li>
</ol>
<p>這是一次<strong>完整重寫</strong>、不是漸進升級 — rag-demo 的 Python 程式碼不會被修改或遷移，而是用 Go 重新實作相同的 RAG pipeline（chunk → embed → store → search）、保留相同的概念架構。rag-demo 作為教學 demo 繼續存在。</p>
<p>升級目標：一個跟 <code>mdtools</code> 同級的 Go CLI 工具，能對全量 content 做語意搜尋，其他維護者 clone 後 <code>go build</code> 即可用。完整原始碼在 <code>scripts/blogsearch/</code>。</p>
<h2 id="選型過程對應-422-演化階梯--工程約束">選型過程（對應 4.22 演化階梯 + 工程約束）</h2>
<h3 id="第一軸規模判讀">第一軸：規模判讀</h3>
<p>全量 content 產生 24,216 chunks（原本估計 ~1,500）。按 4.22 判讀樹，24K 落在「10K-100K → HNSW 或 brute-force」區間。預估 vs 實際的 16 倍落差揭露一個教訓：<strong>估計 chunk 數不能用篇數乘以常數</strong>，要看每篇的實際長度跟 chunking 策略。</p>
<h3 id="第二軸工程約束本專案特有">第二軸：工程約束（本專案特有）</h3>
<p>以下四個 constraint 反映<strong>這個 blog 專案的偏好</strong>、不是通用判準。換一組 constraint 會篩出完全不同的方案 — Python 專案不會有「Go 單 binary」constraint、已有 Docker 的團隊不會排斥外部 server。讀者套用時應先列出自己專案的 constraint、不是照搬這張表。</p>
<table>
  <thead>
      <tr>
          <th>Constraint</th>
          <th>砍掉什麼</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Go 單 binary</td>
          <td>Python-only 方案（pickle / FAISS）</td>
      </tr>
      <tr>
          <td>不要 CGo</td>
          <td>sqlite-vec（需要 <code>mattn/go-sqlite3</code>）</td>
      </tr>
      <tr>
          <td>不要外部 server</td>
          <td>Qdrant / Weaviate / Pinecone</td>
      </tr>
      <tr>
          <td>Ollama 原生</td>
          <td>OpenAI / Cohere embedding（多一個 API key）</td>
      </tr>
  </tbody>
</table>
<p>剩餘選項：<strong>Go + flat file + brute-force</strong>。</p>
<h3 id="第三軸延遲容忍">第三軸：延遲容忍</h3>
<p>CLI 工具、每天用幾次、不是 API server。&lt; 500ms 可接受。</p>
<p>結論：選階段二（flat file），brute-force cosine。</p>
<h2 id="實作架構">實作架構</h2>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="ln"> 1</span><span class="cl">scripts/blogsearch/
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">├── main.go                     # CLI: ingest / query / status
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">├── cmd/
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">│   ├── ingest.go               # walk content/ → chunk → embed → store
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">│   ├── query.go                # load → embed query → cosine top-K → lazy load text
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">│   └── status.go               # index stats
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">└── internal/
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">    ├── chunk/chunk.go           # paragraph-aware markdown chunking
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">    ├── embed/embed.go           # Ollama HTTP API wrapper
</span></span><span class="line"><span class="ln">10</span><span class="cl">    ├── search/search.go         # brute-force cosine similarity
</span></span><span class="line"><span class="ln">11</span><span class="cl">    └── store/store.go           # 三檔案 binary store</span></span></code></pre></div><h3 id="日常使用">日常使用</h3>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl"><span class="c1"># 語意搜尋</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">./bin/blogsearch query <span class="s2">&#34;retry 策略&#34;</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">
</span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="c1"># 只搜特定 section</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">./bin/blogsearch query -section backend <span class="s2">&#34;connection pool 設定&#34;</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl">
</span></span><span class="line"><span class="ln">7</span><span class="cl"><span class="c1"># 查 index 狀態</span>
</span></span><span class="line"><span class="ln">8</span><span class="cl">./bin/blogsearch status</span></span></code></pre></div><h3 id="storage-格式三檔案分離">Storage 格式（三檔案分離）</h3>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="ln">1</span><span class="cl">.blogsearch/
</span></span><span class="line"><span class="ln">2</span><span class="cl">├── vectors.bin    # float32 binary（70.9 MB）— bulk read + unsafe.Slice 零拷貝
</span></span><span class="line"><span class="ln">3</span><span class="cl">├── meta.json      # compact metadata 不含 text（7.3 MB）
</span></span><span class="line"><span class="ln">4</span><span class="cl">└── texts.bin      # length-prefixed chunk text（19.2 MB）— top-K 才 lazy load</span></span></code></pre></div><p>分離 text 的設計理由：query 時只需要 vectors + metadata 做 cosine search（78 MB），top-K 結果才從 texts.bin 按 offset 讀取 5 筆 text。省掉 19 MB 的 JSON 解析。</p>
<h2 id="效能優化歷程">效能優化歷程</h2>
<h3 id="初版95-秒">初版：9.5 秒</h3>
<p>初版用逐 4-byte Read 載入 vectors.bin（17.5M 次 <code>f.Read(buf)</code>），加上 27MB 的 index.json（含所有 chunk text）一次 JSON 解析。</p>
<h3 id="優化版034-秒28x">優化版：0.34 秒（28x）</h3>
<p>三項改動：</p>
<table>
  <thead>
      <tr>
          <th>改動</th>
          <th>從</th>
          <th>到</th>
          <th>效果</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>vectors.bin 讀法</td>
          <td>逐 4-byte Read</td>
          <td><code>os.ReadFile</code> + <code>unsafe.Slice</code></td>
          <td>I/O call 17.5M → 1</td>
      </tr>
      <tr>
          <td>metadata 格式</td>
          <td>含 text（27 MB）</td>
          <td>不含 text（7.3 MB）</td>
          <td>JSON parse 快 4x</td>
      </tr>
      <tr>
          <td>text 載入</td>
          <td>全量</td>
          <td>top-K lazy load（只讀 5 筆）</td>
          <td>省 19 MB 讀取</td>
      </tr>
  </tbody>
</table>
<p>瓶頸分析：0.34 秒裡、embedding API call（Ollama）約 77ms、file I/O + JSON parse 約 200ms、cosine 計算約 50ms。cosine 計算只佔 15%。</p>
<h2 id="通用可複製流程抽掉-goblog">通用可複製流程（抽掉 Go/blog）</h2>
<p>本案例的 Go 實作細節（<code>unsafe.Slice</code>、<code>os.ReadFile</code>）是語言特定的、但背後的流程步驟跨語言通用：</p>
<ol>
<li><strong>Walk corpus</strong>：遞迴掃描目標目錄的所有文件（markdown / code / 任意文字）</li>
<li><strong>Chunk</strong>：段落感知分割、soft token cap、保留語意邊界（原理見 <a href="/blog/llm/04-applications/rag-principles/" data-link-title="4.1 RAG 原理：retrieval &#43; augmentation 模式" data-link-desc="為什麼模型需要外掛知識、語意相似 vs 字面相似、chunking 的本質取捨、retrieval 失敗的根本原因">4.1 Chunking</a>）</li>
<li><strong>Embed</strong>：對每個 chunk 呼叫 embedding API（本地 Ollama 或 cloud API），得到固定維度向量</li>
<li><strong>Store</strong>：向量 + metadata + text 分離存檔（binary vectors / compact JSON / lazy-load text）</li>
<li><strong>Search</strong>：embed query → brute-force cosine → top-K → lazy load text for display</li>
</ol>
<p>Python 實作同流程只是把第 4 步的 binary 檔換成 pickle / FAISS index / SQLite DB、第 5 步的 cosine 換成 numpy / FAISS / sqlite-vec query。Node.js / Rust 同理。</p>
<p>關鍵優化原則也跨語言：「分離向量與文字、query 時只載入向量、top-K 才載入文字」讓 I/O 量從 ~98MB 降到 ~78MB、JSON parse 從 27MB 降到 7MB。這個原則用什麼語言實作都有效。</p>
<h2 id="四方案同-corpus-benchmark">四方案同 corpus Benchmark</h2>
<p>用同一個 corpus（24,216 chunks、768 維、nomic-embed-text）比較四種 storage 方案。Benchmark 腳本在 <code>scripts/blogsearch-bench/bench.py</code>。</p>
<h3 id="前置依賴">前置依賴</h3>
<p>Benchmark 腳本讀 Go 工具產生的 index（<code>.blogsearch/</code> 下的 <code>vectors.bin</code> + <code>meta.json</code>）。完整指令鏈：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl"><span class="nb">cd</span> scripts/blogsearch <span class="o">&amp;&amp;</span> go build -o ../../bin/blogsearch .   <span class="c1"># build Go 工具</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">ollama serve <span class="p">&amp;</span>                                                  <span class="c1"># 啟動 Ollama</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">ollama pull nomic-embed-text                                    <span class="c1"># pull embedding model</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">./bin/blogsearch ingest -content content -out .blogsearch       <span class="c1"># 建 index（~4 分鐘）</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">uv run --with sqlite-vec --with faiss-cpu --with numpy <span class="se">\
</span></span></span><span class="line"><span class="ln">6</span><span class="cl"><span class="se"></span>  scripts/blogsearch-bench/bench.py --index .blogsearch         <span class="c1"># 跑 benchmark</span></span></span></code></pre></div><p>若無 Go 環境，可用自己的 Python embedding 腳本產生相同格式的 <code>vectors.bin</code>（little-endian float32、n × dim 連續排列）+ <code>meta.json</code>（<code>{&quot;dim&quot;: 768, &quot;count&quot;: n, &quot;metas&quot;: [...]}</code>），benchmark 腳本只讀這兩個檔案、不依賴 Go binary 本身。Corpus 格式無硬性要求，任何目錄下的 <code>.md</code> 檔案都可索引。</p>
<h3 id="方法論">方法論</h3>
<ul>
<li><strong>Embedding</strong>：四方案共用同一組 embedding（從 Go index 載入），排除 embedding model 差異</li>
<li><strong>Query</strong>：同一句 query（&ldquo;RAG storage 選型&rdquo;），跑 5 次取 median</li>
<li><strong>Ingest 時間</strong>：只計 storage 操作（不含 embedding），Go 方案含 embedding 不可分離故標 —</li>
<li><strong>環境</strong>：macOS Apple Silicon、Python 3.12、Go 1.25</li>
</ul>
<h3 id="結果">結果</h3>
<table>
  <thead>
      <tr>
          <th>方案</th>
          <th>Ingest（純 storage）</th>
          <th>Query（median）</th>
          <th>Index 大小</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Go + flat file</td>
          <td>—</td>
          <td>151ms</td>
          <td>97.4 MB</td>
      </tr>
      <tr>
          <td>Python sqlite-vec</td>
          <td>2.9s</td>
          <td>19ms</td>
          <td>75.3 MB</td>
      </tr>
      <tr>
          <td>Python FAISS flat</td>
          <td>40ms</td>
          <td>1.8ms</td>
          <td>in-memory</td>
      </tr>
      <tr>
          <td>Python FAISS HNSW</td>
          <td>23.3s</td>
          <td>0.5ms</td>
          <td>in-memory</td>
      </tr>
  </tbody>
</table>
<h3 id="三個關鍵發現">三個關鍵發現</h3>
<p><strong>延遲瓶頸在 I/O 和實作、不在演算法</strong>。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 倍。</p>
<p><strong>HNSW 的 query 加速在此規模 ROI 低</strong>。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 夠用」）被數據驗證。</p>
<p><strong>sqlite-vec 的 19ms 是「DB overhead 換功能」</strong>。比 FAISS flat 慢 10 倍、但多了 SQL metadata filter、transaction 保護、disk persistence。對「需要 filter 但不想維運 server」的場景有意義。</p>
<h3 id="讀數據的注意事項">讀數據的注意事項</h3>
<ul>
<li>Go 151ms 含 file I/O（每次 query 重載 78MB）；如果做 daemon mode（常駐、載入一次），query 會降到 ~50ms（純 cosine + overhead）</li>
<li>FAISS 數字是 in-memory baseline（index 已載入），不含 index 檔案的載入時間</li>
<li>sqlite-vec 數字含 disk I/O（每次 query 從 SQLite 讀取），是 persistent storage 的真實代價</li>
<li>四方案都不含 Ollama embedding call 時間（~77ms），實際端到端延遲要加上</li>
</ul>
<h2 id="二次選型評估同結論理由鏈翻轉">二次選型評估：同結論、理由鏈翻轉</h2>
<p>Benchmark 數據出來後，80 倍效能差距讓原始選型（Go + flat file）受到質疑：「是否該換 Python + FAISS 或 sqlite-vec？」重新用 WRAP 框架評估，結論相同（維持 Go），但理由鏈完全不同。</p>
<h3 id="第一次選型的理由事前">第一次選型的理由（事前）</h3>
<p>「Go 工具鏈統一（mdtools 是 Go）+ 單 binary 分發（clone 後 <code>go build</code> 即可）。」</p>
<h3 id="實測推翻的前提">實測推翻的前提</h3>
<table>
  <thead>
      <tr>
          <th>原始假設</th>
          <th>實測</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Corpus ~1,500 chunks</td>
          <td>24,216 chunks（16 倍）</td>
      </tr>
      <tr>
          <td>Brute-force &lt; 10ms</td>
          <td>Go 151ms（I/O 瓶頸、不是計算）</td>
      </tr>
      <tr>
          <td>語言效能差異不大</td>
          <td>Go pure cosine vs numpy BLAS 差 80 倍</td>
      </tr>
      <tr>
          <td>「工具鏈統一」很重要</td>
          <td>mdtools（pre-commit、延遲敏感）跟 blogsearch（手動 CLI、每天幾次）使用模式不同，強制統一語言是用「同一棟建築」邏輯要求「不同用途房間用同一種建材」</td>
      </tr>
  </tbody>
</table>
<p>第一次的理由鏈幾乎全數被推翻。如果只看理由，應該換方案。</p>
<h3 id="第二次選型的理由事後">第二次選型的理由（事後）</h3>
<p>重新評估時加入三個第一次沒有的變數：</p>
<p><strong>端到端延遲 vs in-memory benchmark</strong>。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 倍」。</p>
<p><strong>使用頻率決定 ROI</strong>。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 語言重寫成本），但讀者套到自己場景時應先確定自己的查詢頻率。</p>
<p><strong>Ingest 瓶頸在 Ollama API、跟語言無關</strong>。~4 分鐘的 ingest 裡、embedding API call 佔 95% 以上。換 Python 不會改善 ingest 速度。</p>
<h3 id="維持的理由是痛點不存在">維持的理由是「痛點不存在」</h3>
<p>維持 Go 的理由是<strong>改善的絕對收益太小、投入回不了本</strong> — 151ms 對 CLI 使用模式不構成痛點，與「Go 好」或「工具鏈統一」無關。</p>
<h3 id="這個翻轉的教學意義">這個翻轉的教學意義</h3>
<p>正確的結論配錯誤的理由是脆弱的。第一次 WRAP 的結論（選 Go）在當時是對的，但理由鏈（工具鏈統一、&lt; 10ms）被實測推翻後，如果不重新建立正確的理由鏈，下次環境變動（比如 blogsearch 從 CLI 變成 API server）就會用已失效的理由做出錯誤判斷。</p>
<p>判讀工具選型時，要區分三層：</p>
<ol>
<li><strong>結論</strong>：選什麼方案</li>
<li><strong>理由</strong>：為什麼選（可能被推翻）</li>
<li><strong>前提</strong>：理由依賴的假設（規模、使用模式、效能數字）</li>
</ol>
<p>前提變了、理由就要重建，即使結論沒變。寫進決策紀錄時，三層都要記 — 只記結論的話，下次重新評估時沒有判讀基礎。</p>
<p>區分「正當理由重建」跟「動機性推理」（先有結論再找理由）的判準：新理由是否在看到數據之前也能成立？本例的「130ms 對 CLI 不痛」在實測前也成立（CLI 使用模式本來就低頻），所以是正當重建。如果新理由只能在看到特定數字之後才講得通（如「151ms 剛好在 200ms 閾值內」——但閾值是事後設的），就是 post-hoc rationalization。</p>
<h3 id="觸發換方案的訊號">觸發換方案的訊號</h3>
<table>
  <thead>
      <tr>
          <th>訊號</th>
          <th>門檻</th>
          <th>動作</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Query 延遲不可接受</td>
          <td>&gt; 500ms</td>
          <td>先加 mmap（最小改動）</td>
      </tr>
      <tr>
          <td>使用模式改變</td>
          <td>從 CLI 變 API server</td>
          <td>換 Python sqlite-vec</td>
      </tr>
      <tr>
          <td>查詢頻率跳增</td>
          <td>被整合進 MCP server / agent 工具</td>
          <td>評估 daemon mode 或換 sqlite-vec</td>
      </tr>
      <tr>
          <td>Corpus 規模跳增</td>
          <td>&gt; 50K chunks</td>
          <td>重跑 benchmark</td>
      </tr>
      <tr>
          <td>需要原生 metadata filter</td>
          <td>code filter 維護成本過高</td>
          <td>換 Python sqlite-vec</td>
      </tr>
  </tbody>
</table>
<h2 id="embedding-model-選型對應-412-constraint-優先序">Embedding model 選型（對應 4.12 constraint 優先序）</h2>
<p>選 <code>nomic-embed-text</code> 的理由鏈：</p>
<ol>
<li><strong>Ollama 原生支援</strong>：<code>ollama pull</code> 一行、不需要額外 Python library 或 API key</li>
<li><strong>體積小</strong>：274 MB、跟 chat model 共用記憶體不打架</li>
<li><strong>已有驗證基線</strong>：rag-demo 用同一個模型跑過 463 chunks、retrieval 命中率確認可用</li>
<li><strong>768 維 sweet spot</strong>：24K chunks × 768 dim × 4 bytes = 70.9 MB，brute-force 可行</li>
</ol>
<p>未來如果 CJK retrieval 品質不夠（目前可用但未做系統性評估），<code>multilingual-e5-large</code> 或 <code>bge-m3</code> 是備選。換模型只需改 <code>embed.go</code> 的 Model 變數 + 重新 <code>blogsearch ingest</code>（4.22 的「四層可替換」設計）。</p>
<h2 id="cjk-混合-chunking-觀察">CJK 混合 Chunking 觀察</h2>
<p>Blog 內容是繁體中文 + 英文術語混合。Chunking 策略沿用 rag-demo 的 paragraph-aware split（空白行切段、soft token cap 400）。</p>
<p>Token 估算用 <code>len(s) / 2</code> 的 heuristic（CJK 字元多算一次）。不精確但 chunking 只需要粗略估算。跟 tokenizer 精確計算的差異在 ±20%、對 chunking 品質影響小於 chunk 邊界選擇的影響。</p>
<p>實際觀察：24,216 chunks 的 retrieval 品質在語意搜尋場景（「哪些文章跟 retry 有關」「RAG storage 選型」）表現良好。keyword 精確搜尋場景（「找 RFC 7807」）表現較弱 — 這是 embedding-only retrieval 的已知限制（見 4.1 的語意 vs 字面相似度對比），未來可加 BM25 做 hybrid search。</p>
<h2 id="跟其他章節的對應">跟其他章節的對應</h2>
<table>
  <thead>
      <tr>
          <th>本案例的段落</th>
          <th>對應原理章節</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>選型過程</td>
          <td><a href="/blog/llm/04-applications/vector-storage-engineering/" data-link-title="4.22 RAG storage 工程：從 pickle 到 vector database 的選型判讀" data-link-desc="RAG storage backend 選型：規模到哪個階段該從 in-memory 升級到 vector DB、dependency chain 如何收窄選項">4.22 演化階梯 + 工程約束</a></td>
      </tr>
      <tr>
          <td>二次選型評估</td>
          <td><a href="/blog/llm/04-applications/vector-storage-engineering/" data-link-title="4.22 RAG storage 工程：從 pickle 到 vector database 的選型判讀" data-link-desc="RAG storage backend 選型：規模到哪個階段該從 in-memory 升級到 vector DB、dependency chain 如何收窄選項">4.22 同 corpus 實測比較</a></td>
      </tr>
      <tr>
          <td>Embedding 選型</td>
          <td><a href="/blog/llm/04-applications/embedding-model-internals/" data-link-title="4.12 Embedding model 內部：訓練、選型、in-domain fine-tune" data-link-desc="Embedding model 怎麼訓練（contrastive learning &#43; hard negative mining）、怎麼挑（MTEB / 大小 / domain）、何時該自己 fine-tune">4.12 實務選型 constraint 優先序</a></td>
      </tr>
      <tr>
          <td>Chunking</td>
          <td><a href="/blog/llm/04-applications/rag-principles/" data-link-title="4.1 RAG 原理：retrieval &#43; augmentation 模式" data-link-desc="為什麼模型需要外掛知識、語意相似 vs 字面相似、chunking 的本質取捨、retrieval 失敗的根本原因">4.1 Chunking 策略對比</a></td>
      </tr>
      <tr>
          <td>Benchmark 方法論</td>
          <td><a href="/blog/llm/04-applications/benchmarking-and-evaluation/" data-link-title="4.14 Benchmarking 與評估方法論" data-link-desc="判讀 model card benchmark 數字、做自己工作流的 in-house benchmark、量測本地推論速度的完整方法論">4.14 Benchmarking 方法論</a></td>
      </tr>
      <tr>
          <td>Storage 格式設計</td>
          <td><a href="/blog/llm/04-applications/artifact-management/" data-link-title="4.10 衍生產物管理原理：什麼進 git、什麼不該" data-link-desc="LLM 應用的 source / derived / external 三類產物對應 git / build cache / registry、與 production 部署的 reproducibility / cost / share 取捨">4.10 衍生產物管理</a></td>
      </tr>
      <tr>
          <td>Retrieval 品質</td>
          <td><a href="/blog/llm/04-applications/rag-principles/" data-link-title="4.1 RAG 原理：retrieval &#43; augmentation 模式" data-link-desc="為什麼模型需要外掛知識、語意相似 vs 字面相似、chunking 的本質取捨、retrieval 失敗的根本原因">4.1 Retrieval 失敗根因</a></td>
      </tr>
  </tbody>
</table>
]]></content:encoded></item><item><title>4.22 RAG storage 工程：從 pickle 到 vector database 的選型判讀</title><link>https://tarrragon.github.io/blog/llm/04-applications/vector-storage-engineering/</link><pubDate>Wed, 01 Jul 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/llm/04-applications/vector-storage-engineering/</guid><description>&lt;p>做完 RAG proof-of-concept 後最常見的問題是「現在的 in-memory 方案什麼時候該換成 vector database」。RAG pipeline 的儲存方案是&lt;strong>工程選擇、不是概念要件&lt;/strong>。&lt;a href="https://tarrragon.github.io/blog/llm/04-applications/rag-principles/" data-link-title="4.1 RAG 原理：retrieval &amp;#43; augmentation 模式" data-link-desc="為什麼模型需要外掛知識、語意相似 vs 字面相似、chunking 的本質取捨、retrieval 失敗的根本原因">4.1 RAG 原理&lt;/a>定義的 retrieval + augmentation 二段式結構，跟 embedding 存在 pickle、flat file、SQLite、還是 Pinecone 無關 — 只要能「給一個 query vector，找到最相似的 chunk vectors」，retrieval 這一段就成立。&lt;/p>
&lt;p>本章整理 storage layer 的工程設計空間：什麼規模用什麼儲存、什麼訊號觸發升級、index 怎麼建怎麼更新、schema 怎麼設計、dependency chain 怎麼影響選型。全篇以一個約 2,700 篇 markdown（24K chunks）、Go 工具鏈的個人技術 blog 作為 running example（從 &lt;a href="https://tarrragon.github.io/blog/llm/01-local-llm-services/hands-on/rag-demo/" data-link-title="Hands-on：用 blog content 當 corpus 跑 RAG" data-link-desc="200 行 Python：embedding &amp;#43; cosine retrieval &amp;#43; Ollama chat、validating 4.0 RAG 原理">pickle demo&lt;/a> 升級到 production 工具的過程）；Go-specific 的約束見「工程約束」段，Python 專案的路徑在各階段標示。&lt;/p>
&lt;h2 id="本章目標">本章目標&lt;/h2>
&lt;p>本章涵蓋：&lt;/p>
&lt;ol>
&lt;li>RAG pipeline 的四個可替換層、判斷當前瓶頸落在哪一層。&lt;/li>
&lt;li>Corpus 規模跟使用模式對應的 storage backend 選擇。&lt;/li>
&lt;li>Index 的 build / update / rebuild 生命週期設計。&lt;/li>
&lt;li>ANN index 策略（HNSW / IVF / brute-force）的適用邊界。&lt;/li>
&lt;li>Storage 選型的 dependency 約束（語言生態、build chain、環境管理）。&lt;/li>
&lt;/ol>
&lt;h2 id="rag-pipeline-的四個可替換層">RAG pipeline 的四個可替換層&lt;/h2>
&lt;p>RAG 不是一個 monolithic 系統。從 query 進來到 augmented prompt 送進 LLM，經過四個獨立可替換的層：&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>層&lt;/th>
 &lt;th>責任&lt;/th>
 &lt;th>可替換選項範例&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>Chunking strategy&lt;/td>
 &lt;td>把 corpus 切成 retrieval 單位&lt;/td>
 &lt;td>fixed-size / recursive / heading-aware / AST-based&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Embedding model&lt;/td>
 &lt;td>把 chunk text 轉成向量&lt;/td>
 &lt;td>nomic-embed-text / bge-large / jina-v3&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;strong>Storage backend&lt;/strong>&lt;/td>
 &lt;td>存向量 + metadata、支援相似度查詢&lt;/td>
 &lt;td>pickle / flat file / FAISS / SQLite-vec / Pinecone&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Retrieval algorithm&lt;/td>
 &lt;td>對 query vector 找 top-K 相似 chunk&lt;/td>
 &lt;td>brute-force cosine / HNSW / IVF / hybrid + rerank&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>四層各自演化、各自有不同的升級時機。Chunking 跟 embedding model 影響 retrieval &lt;strong>品質&lt;/strong>（找到的東西對不對）；storage backend 跟 retrieval algorithm 影響 retrieval &lt;strong>效能&lt;/strong>（找的速度跟規模上限）。&lt;/p></description><content:encoded><![CDATA[<p>做完 RAG proof-of-concept 後最常見的問題是「現在的 in-memory 方案什麼時候該換成 vector database」。RAG pipeline 的儲存方案是<strong>工程選擇、不是概念要件</strong>。<a href="/blog/llm/04-applications/rag-principles/" data-link-title="4.1 RAG 原理：retrieval &#43; augmentation 模式" data-link-desc="為什麼模型需要外掛知識、語意相似 vs 字面相似、chunking 的本質取捨、retrieval 失敗的根本原因">4.1 RAG 原理</a>定義的 retrieval + augmentation 二段式結構，跟 embedding 存在 pickle、flat file、SQLite、還是 Pinecone 無關 — 只要能「給一個 query vector，找到最相似的 chunk vectors」，retrieval 這一段就成立。</p>
<p>本章整理 storage layer 的工程設計空間：什麼規模用什麼儲存、什麼訊號觸發升級、index 怎麼建怎麼更新、schema 怎麼設計、dependency chain 怎麼影響選型。全篇以一個約 2,700 篇 markdown（24K chunks）、Go 工具鏈的個人技術 blog 作為 running example（從 <a href="/blog/llm/01-local-llm-services/hands-on/rag-demo/" data-link-title="Hands-on：用 blog content 當 corpus 跑 RAG" data-link-desc="200 行 Python：embedding &#43; cosine retrieval &#43; Ollama chat、validating 4.0 RAG 原理">pickle demo</a> 升級到 production 工具的過程）；Go-specific 的約束見「工程約束」段，Python 專案的路徑在各階段標示。</p>
<h2 id="本章目標">本章目標</h2>
<p>本章涵蓋：</p>
<ol>
<li>RAG pipeline 的四個可替換層、判斷當前瓶頸落在哪一層。</li>
<li>Corpus 規模跟使用模式對應的 storage backend 選擇。</li>
<li>Index 的 build / update / rebuild 生命週期設計。</li>
<li>ANN index 策略（HNSW / IVF / brute-force）的適用邊界。</li>
<li>Storage 選型的 dependency 約束（語言生態、build chain、環境管理）。</li>
</ol>
<h2 id="rag-pipeline-的四個可替換層">RAG pipeline 的四個可替換層</h2>
<p>RAG 不是一個 monolithic 系統。從 query 進來到 augmented prompt 送進 LLM，經過四個獨立可替換的層：</p>
<table>
  <thead>
      <tr>
          <th>層</th>
          <th>責任</th>
          <th>可替換選項範例</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Chunking strategy</td>
          <td>把 corpus 切成 retrieval 單位</td>
          <td>fixed-size / recursive / heading-aware / AST-based</td>
      </tr>
      <tr>
          <td>Embedding model</td>
          <td>把 chunk text 轉成向量</td>
          <td>nomic-embed-text / bge-large / jina-v3</td>
      </tr>
      <tr>
          <td><strong>Storage backend</strong></td>
          <td>存向量 + metadata、支援相似度查詢</td>
          <td>pickle / flat file / FAISS / SQLite-vec / Pinecone</td>
      </tr>
      <tr>
          <td>Retrieval algorithm</td>
          <td>對 query vector 找 top-K 相似 chunk</td>
          <td>brute-force cosine / HNSW / IVF / hybrid + rerank</td>
      </tr>
  </tbody>
</table>
<p>四層各自演化、各自有不同的升級時機。Chunking 跟 embedding model 影響 retrieval <strong>品質</strong>（找到的東西對不對）；storage backend 跟 retrieval algorithm 影響 retrieval <strong>效能</strong>（找的速度跟規模上限）。</p>
<p>常見的認知混淆是把「RAG」跟「vector database」綁在一起。這個綁定在 production 規模可能合理（10M chunks 不用 vector DB 很難做），但在小規模場景會導致過度工程 — 1500 個 chunks 用 Pinecone 就像用 PostgreSQL 存 10 筆 config。</p>
<h2 id="storage-backend-的演化階梯">Storage backend 的演化階梯</h2>
<p>Storage backend 的選擇是<strong>規模驅動</strong>的工程決策。每個階段都能做 RAG，差別在效能、持久性、query 能力。以下規模閾值基於 768 維 embedding、單機常見配置的經驗判斷，切點依向量維度與硬體規格移動；實測數字（如 20 chunks/sec）另行標示：</p>
<h3 id="階段一in-memorypickle--python-list">階段一：In-memory（pickle / Python list）</h3>
<p>把所有 chunk embeddings 載入記憶體，brute-force 算 cosine similarity。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="ln">1</span><span class="cl">適用規模：&lt; 10K chunks
</span></span><span class="line"><span class="ln">2</span><span class="cl">延遲：cosine 計算 &lt; 2ms（numpy BLAS、in-memory）；file-based 實作加 I/O 載入時間
</span></span><span class="line"><span class="ln">3</span><span class="cl">持久性：pickle 檔、每次啟動重載
</span></span><span class="line"><span class="ln">4</span><span class="cl">優點：零 dependency、程式碼 &lt; 50 行、debug 容易
</span></span><span class="line"><span class="ln">5</span><span class="cl">限制：記憶體受限、無 metadata filter、無 incremental update</span></span></code></pre></div><p>本 blog 的 <a href="/blog/llm/01-local-llm-services/hands-on/rag-demo/" data-link-title="Hands-on：用 blog content 當 corpus 跑 RAG" data-link-desc="200 行 Python：embedding &#43; cosine retrieval &#43; Ollama chat、validating 4.0 RAG 原理">rag-demo</a> 就在這個階段：71 篇 markdown、463 chunks、pickle 儲存、22 秒索引、query &lt; 10ms。概念驗證完全夠用。</p>
<h3 id="階段二flat-filebinary-embedding-store">階段二：Flat file（binary embedding store）</h3>
<p>把 embeddings 存成 binary 格式（而非 Python pickle），配 JSON metadata index。跟階段一的差異是 <strong>language-agnostic persistence</strong> — 不綁定 Python 的 pickle 格式、Go / Rust / Node 都能讀。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="ln">1</span><span class="cl">適用規模：&lt; 10K chunks
</span></span><span class="line"><span class="ln">2</span><span class="cl">延遲：cosine 計算 &lt; 2ms；加 file I/O 載入（70MB vectors ≈ 150ms Go / &lt; 50ms mmap）
</span></span><span class="line"><span class="ln">3</span><span class="cl">持久性：binary file + metadata JSON、可 rebuild
</span></span><span class="line"><span class="ln">4</span><span class="cl">優點：跨語言、單檔案部署、不需要 DB server
</span></span><span class="line"><span class="ln">5</span><span class="cl">限制：brute-force O(n)、metadata filter 靠程式碼、schema 演化需 rebuild（換 embedding 模型要重建整個 index）、無 transaction 保護（binary 損毀靠 rebuild 復原）、每次 query 重載 file 是效能瓶頸</span></span></code></pre></div><p>Running example 的 blog 選了這個方案。驅動選擇的是<strong>工具鏈約束</strong>：該 blog 的核心工具是 Go（單 binary 分發的 lint / fmt 工具），用 pickle 就綁定 Python runtime、其他維護者 clone 後多一步環境設定（同規模下效能無差異）。Binary flat file 讓 Go 工具直接讀寫、維持單 binary 分發。Python 專案留在 pickle 完全合理，規模到 10K 再跳階段三 FAISS 更自然。</p>
<h3 id="階段三embedded-libraryfaiss--hnswlib--annoy">階段三：Embedded library（FAISS / HNSWLib / Annoy）</h3>
<p>引入 ANN（Approximate Nearest Neighbor）index，查詢從 O(n) 變成 O(log n)。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="ln">1</span><span class="cl">適用規模：10K - 100K chunks
</span></span><span class="line"><span class="ln">2</span><span class="cl">延遲：&lt; 5ms（HNSW sublinear）
</span></span><span class="line"><span class="ln">3</span><span class="cl">持久性：index 檔案、可 rebuild
</span></span><span class="line"><span class="ln">4</span><span class="cl">優點：不需要 server、嵌入應用 process
</span></span><span class="line"><span class="ln">5</span><span class="cl">限制：需要安裝 library（FAISS 有平台相依的 wheel）、index build 較慢</span></span></code></pre></div><p>升級訊號：brute-force latency 開始感覺到（&gt; 50ms）、或 corpus 大到記憶體載入太慢。1M chunks × 768 dim × 4 bytes = 3GB，載入開始有感。</p>
<h3 id="階段三piggyback-既有-dbpgvector--redis-vector">階段三½：Piggyback 既有 DB（pgvector / Redis vector）</h3>
<p>已有 PostgreSQL 或 Redis 的專案有一條跳板路徑：直接在既有 DB 加向量能力、不引入新 server。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="ln">1</span><span class="cl">適用規模：10K - 1M chunks（pgvector）、10K - 500K（Redis vector）
</span></span><span class="line"><span class="ln">2</span><span class="cl">延遲：&lt; 10ms（HNSW、同 DB process）
</span></span><span class="line"><span class="ln">3</span><span class="cl">持久性：DB 管理、有 transaction / WAL / backup
</span></span><span class="line"><span class="ln">4</span><span class="cl">優點：不增 server、SQL metadata filter 原生支援、既有維運流程直接沿用
</span></span><span class="line"><span class="ln">5</span><span class="cl">限制：DB 本身要夠大（向量索引佔額外記憶體）、效能跟 DB 負載共享</span></span></code></pre></div><p>升級訊號：已有 Postgres / Redis、需要 metadata filtering、但不想維運獨立 vector DB server。pgvector 讓「有 SQL 能力 + 有向量搜尋」在同一個 DB 完成；Redis vector（RediSearch）適合已有 Redis 且延遲敏感的場景。</p>
<p>這條路徑跟階段四的差異：階段四（Qdrant / Weaviate）是專用 vector DB、向量搜尋效能更高、但多一個 server 維運。Piggyback 路徑犧牲一些向量搜尋效能、換來零新增 server 的維運簡化。選擇取決於「向量搜尋是核心能力（階段四）、還是輔助功能（piggyback）」。</p>
<h3 id="階段四self-hosted-vector-databaseqdrant--weaviate--milvus">階段四：Self-hosted vector database（Qdrant / Weaviate / Milvus）</h3>
<p>獨立 server process，專精向量搜尋，支援 metadata filtering、incremental update、backup、replication。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="ln">1</span><span class="cl">適用規模：100K - 10M chunks
</span></span><span class="line"><span class="ln">2</span><span class="cl">延遲：&lt; 10ms（HNSW + 網路 overhead）
</span></span><span class="line"><span class="ln">3</span><span class="cl">持久性：server 管理、disk-based
</span></span><span class="line"><span class="ln">4</span><span class="cl">優點：metadata filter（SQL-like）、REST/gRPC API、可水平擴展
</span></span><span class="line"><span class="ln">5</span><span class="cl">限制：需要維運 server、佔用資源、增加系統複雜度</span></span></code></pre></div><p>升級訊號：需要 metadata filtering（「只搜 report/ 下的卡片」且頻率高）、需要多 process 並發 query、需要 incremental update 而非全量 rebuild。</p>
<p>典型場景是十人以上的團隊共用 RAG 知識庫：多人同時 query、文件隨 sprint 密集更新、需要按 project / team / access level 做 metadata filter。單人或小團隊的 side project 通常停在階段二或三就夠。回退路徑是「關掉 server、退回 embedded library」— 向量跟 metadata 仍在、只是失去 incremental update 跟 REST API。</p>
<h3 id="階段五hosted-saaspinecone--weaviate-cloud--qdrant-cloud">階段五：Hosted SaaS（Pinecone / Weaviate Cloud / Qdrant Cloud）</h3>
<p>由 vendor 管理的 vector database，免維運。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="ln">1</span><span class="cl">適用規模：&gt; 10M chunks、或不想維運
</span></span><span class="line"><span class="ln">2</span><span class="cl">延遲：10-50ms（加上網路 round trip）
</span></span><span class="line"><span class="ln">3</span><span class="cl">持久性：vendor 管理
</span></span><span class="line"><span class="ln">4</span><span class="cl">優點：免維運、自動擴展、SLA
</span></span><span class="line"><span class="ln">5</span><span class="cl">限制：cost、vendor lock-in、資料離開本地</span></span></code></pre></div><p>升級訊號：corpus 超過單機記憶體（10M+ chunks 的 HNSW index 含 graph overhead 可達數十 GB）、或團隊沒有 infra 維運能力。</p>
<p>典型場景是跨國 SaaS 產品的 knowledge base：文件數百萬、多語言、需要 geo-distributed 部署。此規模下 self-hosted 的維運成本（on-call、capacity planning、backup）可能高於 SaaS 訂閱。風險是 vendor lock-in — 切換 vendor 要 re-index 全量資料、migration 成本跟 corpus 大小成正比。回退計畫是保留 ingest pipeline 的 vendor-agnostic 部分（chunking + embedding），只替換 storage layer。</p>
<h3 id="階梯的核心判讀">階梯的核心判讀</h3>
<p>每階段的升級都帶來新的 dependency 跟維護成本。判讀「該不該升級」看三個訊號：</p>
<ol>
<li><strong>目前這個階段有具體痛點嗎？</strong> 沒有就不升級。</li>
<li><strong>升級解的是效能瓶頸還是功能缺口？</strong> 效能瓶頸先量測再決定；功能缺口（如 metadata filter）看使用頻率。</li>
<li><strong>升級引入的 dependency 成本能接受嗎？</strong> 單人 blog 加一個 server process 的維護成本跟十人團隊不同。</li>
</ol>
<p>常見路徑速查：Python 小型 side project 留在 pickle（階段一），規模到 10K 再上 FAISS（階段三）；Go 專案跳階段二（flat file）避免 Python dependency；已有 Postgres 的專案直接評估 pgvector（階段三½）；已有 Docker 的團隊直接評估階段四（vector DB container）。</p>
<p>常見誤解：「FAISS 跟 Pinecone 選哪個」— 兩者差在規模量級（FAISS 是嵌入式 library、適合 &lt; 100K；Pinecone 是 hosted SaaS、適合 &gt; 10M 或免維運），不是同層級的互斥選項。</p>
<h3 id="同-corpus-實測比較">同 corpus 實測比較</h3>
<p>以下是同一個 corpus（24,216 chunks、768 維、nomic-embed-text）在四種 storage 方案的實測結果（2026-07 macOS Apple Silicon）：</p>
<table>
  <thead>
      <tr>
          <th>方案</th>
          <th>演化階段</th>
          <th>Ingest（純 storage）</th>
          <th>Query（median）</th>
          <th>Index 大小</th>
          <th>主要 dependency</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Go + flat file</td>
          <td>階段二</td>
          <td>—</td>
          <td>151ms</td>
          <td>97.4 MB</td>
          <td>Go binary + Ollama</td>
      </tr>
      <tr>
          <td>Python sqlite-vec</td>
          <td>階段三½</td>
          <td>2.9s</td>
          <td>19ms</td>
          <td>75.3 MB</td>
          <td>Python + sqlite-vec</td>
      </tr>
      <tr>
          <td>Python FAISS flat</td>
          <td>階段三</td>
          <td>40ms</td>
          <td>1.8ms</td>
          <td>in-memory</td>
          <td>Python + faiss-cpu</td>
      </tr>
      <tr>
          <td>Python FAISS HNSW</td>
          <td>階段三</td>
          <td>23.3s</td>
          <td>0.5ms</td>
          <td>in-memory</td>
          <td>Python + faiss-cpu</td>
      </tr>
  </tbody>
</table>
<p>這張表揭露三個容易被理論估計遮蓋的事實：</p>
<p><strong>延遲的瓶頸在 I/O 和實作、不在演算法</strong>。Go flat file 的 151ms 裡，cosine 計算約 50ms、其餘約 100ms 是檔案載入（70MB vectors + 7MB metadata）。FAISS flat 用 numpy BLAS 做同樣的 brute-force cosine，純計算只要 1.8ms — 計算層差約 28 倍（Go pure loop vs BLAS 向量化指令），加上 I/O 差異後端到端差 84 倍。</p>
<p><strong>HNSW 的 query 加速在此規模 ROI 低，但原因要看對</strong>。FAISS HNSW query 0.5ms vs flat 1.8ms，每次查詢省 1.3ms；但 HNSW build 要 23.3s。如果每天查 100 次，要 179 天才回本 build 成本。在 10 萬+ chunks 規模這個比例會翻轉。</p>
<p><strong>sqlite-vec 的 19ms 是「DB overhead 換功能」的真實代價</strong>。比 FAISS flat 慢 10 倍，但多了 SQL metadata filter、transaction 保護、disk persistence — 不需要另起 server。這個 trade-off 在「需要 filter 但不想維運 server」的場景有意義。</p>
<h2 id="ann-index-策略">ANN index 策略</h2>
<p>Storage backend 到了階段三以上，需要選 ANN（Approximate Nearest Neighbor）index 策略。<a href="/blog/llm/knowledge-cards/vector-database/" data-link-title="Vector Database" data-link-desc="為高維向量 (embedding) 設計的儲存 &#43; 近似最近鄰 (ANN) 檢索系統：RAG 從 prototype 跨到 production 的關鍵元件">Vector database 卡</a>列了三種主流演算法，本段補充工程判讀。</p>
<h3 id="brute-forceexhaustive-search">Brute-force（exhaustive search）</h3>
<p>對 query vector 跟所有 stored vectors 算 cosine similarity，取 top-K。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="ln">1</span><span class="cl">時間複雜度：O(n × d)（n = chunk 數、d = 向量維度）
</span></span><span class="line"><span class="ln">2</span><span class="cl">精確度：100%（exact nearest neighbor）
</span></span><span class="line"><span class="ln">3</span><span class="cl">記憶體：n × d × 4 bytes（float32）
</span></span><span class="line"><span class="ln">4</span><span class="cl">適用：&lt; 10K chunks</span></span></code></pre></div><p>1500 chunks × 768 dim 的 brute-force，現代 CPU 做一次 cosine similarity sweep 大約 1-5ms。在這個規模，HNSW 的建 index 時間（秒級）反而比它省下的查詢時間（毫秒級）長。</p>
<h3 id="hnswhierarchical-navigable-small-world">HNSW（Hierarchical Navigable Small World）</h3>
<p>建多層隨機圖，查詢時從稀疏高層往密集低層跳，sublinear 找到近似最近鄰。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="ln">1</span><span class="cl">時間複雜度：O(log n × d)
</span></span><span class="line"><span class="ln">2</span><span class="cl">精確度：95-99%（approximate、可調 ef_search 參數換精度）
</span></span><span class="line"><span class="ln">3</span><span class="cl">記憶體：n × d × 4 bytes + graph overhead（通常 1.2-1.5x）
</span></span><span class="line"><span class="ln">4</span><span class="cl">Build 時間：O(n × log n)、比 brute-force 慢
</span></span><span class="line"><span class="ln">5</span><span class="cl">適用：10K - 10M chunks、記憶體充足</span></span></code></pre></div><p>HNSW 是目前 vector DB 的主流 index。工程取捨在兩個參數：<code>ef_construction</code>（build 精度、越高越慢但 graph 品質越好）跟 <code>ef_search</code>（query 精度、越高越慢但 recall 越高）。多數 vector DB 的預設值已經針對「recall &gt; 95%」調過。</p>
<h3 id="ivfinverted-file-index">IVF（Inverted File Index）</h3>
<p>先把向量 K-means 分群，query 時只搜最近的幾個群。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="ln">1</span><span class="cl">時間複雜度：O(n/k × d)（k = 群數、nprobe = 搜幾個群）
</span></span><span class="line"><span class="ln">2</span><span class="cl">精確度：依 nprobe、通常 90-98%
</span></span><span class="line"><span class="ln">3</span><span class="cl">記憶體：可以 disk-based（比 HNSW 省）
</span></span><span class="line"><span class="ln">4</span><span class="cl">Build 時間：K-means 收斂需要時間
</span></span><span class="line"><span class="ln">5</span><span class="cl">適用：&gt; 1M chunks、記憶體受限、可接受較低 recall</span></span></code></pre></div><p>IVF 在超大規模（10M+）的 disk-based 場景有優勢，實務常配 product quantization（PQ）壓縮向量換記憶體。PQ / scalar quantization 跟 index 演算法（HNSW / IVF）正交 — 是記憶體受限時的壓縮手段，可疊加在任一 index 上。消費級場景通常不需要 quantization。</p>
<h3 id="判讀流程">判讀流程</h3>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="ln">1</span><span class="cl">Corpus 規模？
</span></span><span class="line"><span class="ln">2</span><span class="cl">├── &lt; 10K chunks   → Brute-force（此規模無需再評估）
</span></span><span class="line"><span class="ln">3</span><span class="cl">├── 10K - 100K     → HNSW（如果記憶體夠）或 brute-force（如果 latency 可接受）
</span></span><span class="line"><span class="ln">4</span><span class="cl">├── 100K - 10M     → HNSW（主流）
</span></span><span class="line"><span class="ln">5</span><span class="cl">└── &gt; 10M          → IVF 或 HNSW + sharding</span></span></code></pre></div><p>規模是第一軸。兩個修正軸在同規模下改變選擇：</p>
<ul>
<li><strong>Dependency constraint</strong>（見「工程約束」段）：規模小但工具鏈排除某些 storage（如 Go 專案排除 CGo dependency）→ 從可行選項中選。</li>
<li><strong>Metadata filter 需求</strong>：規模小但高頻需要按 section / tag 過濾 → 跳過 embedded library、直接評估 vector DB 或 code filter。</li>
</ul>
<p>一個常見的過度工程信號：corpus 只有幾千筆但花時間調 HNSW 的 <code>ef_construction</code>。實測數據（24K chunks）：FAISS HNSW query 0.5ms vs flat 1.8ms、每次省 1.3ms，但 HNSW build 要 23.3s。每天查 100 次要 179 天回本 build 成本（23.3s ÷ 0.13s/天）。此規模的 brute-force 絕對延遲已在感知閾值下，HNSW 的優化收益趨近零。</p>
<p>判讀流程之外還有一個容易忽略的變數：<strong>實作語言的計算效能差異</strong>。同一個 brute-force cosine，numpy BLAS 做 24K × 768 只要 1.8ms，Go pure cosine 做同樣運算約需 50-80ms（不含 I/O）。選 storage 方案時如果估「brute-force &lt; 10ms」、前提是用了向量化計算的 library；pure Go / pure Python loop 會慢一到兩個數量級。</p>
<h2 id="index-生命週期">Index 生命週期</h2>
<p>Index 的 build / update / rebuild 流程影響日常維護成本。</p>
<h3 id="full-rebuild">Full rebuild</h3>
<p>每次從 corpus 全量重建 index：walk 所有檔案 → chunk → embed → store。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="ln">1</span><span class="cl">適用：corpus 小（&lt; 10K chunks）、更新頻率低（每週幾次）
</span></span><span class="line"><span class="ln">2</span><span class="cl">優點：邏輯最簡單、index 跟 corpus 保證一致
</span></span><span class="line"><span class="ln">3</span><span class="cl">成本：依 corpus 規模線性成長（本地 Ollama sequential embedding 約 100 chunks/sec、24K chunks ≈ 4 分鐘）</span></span></code></pre></div><p>Running example 的 blog 選 full rebuild：2,738 篇 markdown 產生 24K chunks，全量 ingest 在本地 Ollama 約 4 分鐘。每天變動 0-3 篇，rebuild 頻率跟 <code>git push</code> 對齊就夠。</p>
<h3 id="incremental-update">Incremental update</h3>
<p>只處理有變動的檔案：偵測 diff → 刪除舊 chunks → 重新 chunk + embed 變動檔 → 插入新 chunks。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="ln">1</span><span class="cl">適用：corpus 大（&gt; 10K chunks）、更新頻繁
</span></span><span class="line"><span class="ln">2</span><span class="cl">優點：只處理 delta、省 embedding API cost
</span></span><span class="line"><span class="ln">3</span><span class="cl">複雜度：需要 chunk ID 穩定（file path + chunk offset）、刪除 orphan</span></span></code></pre></div><p>Incremental update 的工程難點是 <strong>chunk ID 穩定性</strong>。如果 chunking 策略對同一個檔案的切法會因為上游內容變動而改變（例如段落感知 chunking，加一段就改變後續所有 chunk 邊界），「只更新變動的 chunk」就需要 diff 整個 chunk 序列，邏輯接近全量重建。</p>
<p>判讀「該不該做 incremental」：</p>
<ul>
<li>Embedding 是 cost 瓶頸嗎？本地 Ollama 的 embedding 幾乎免費（約 50ms/chunk、sequential）；cloud API（OpenAI text-embedding-3-small 約 $0.02/1M tokens、Cohere 類似）按 token 計費、corpus 大時差異顯著。</li>
<li>全量 rebuild 的時間能接受嗎？1500 chunks 在本地約 60-90 秒可以接受；15 萬 chunks 約 2 小時可能不行。</li>
<li>能容忍短暫不一致嗎？Full rebuild 期間 index 可能是舊版；incremental update 隨改隨更新。</li>
</ul>
<h3 id="rebuild-trigger">Rebuild trigger</h3>
<p>不管 full 或 incremental，都要決定「什麼觸發 rebuild」：</p>
<table>
  <thead>
      <tr>
          <th>Trigger 類型</th>
          <th>做法</th>
          <th>適合</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>手動</td>
          <td><code>blogsearch ingest</code> 手動跑</td>
          <td>個人工具</td>
      </tr>
      <tr>
          <td>Git hook</td>
          <td>pre-push 或 post-commit 自動 rebuild</td>
          <td>小團隊</td>
      </tr>
      <tr>
          <td>CI/CD</td>
          <td>push to main 後 CI job 跑 ingest</td>
          <td>多人協作</td>
      </tr>
      <tr>
          <td>File watcher</td>
          <td>inotify / fsevents 偵測 content/ 變動自動更新</td>
          <td>開發中即時回饋</td>
      </tr>
  </tbody>
</table>
<p>Trigger 跟團隊協作模式對齊：單人用手動；多人但 review cycle 長（每天幾次 push）用 Git hook 或 CI/CD；開發中密集寫作想即時看 retrieval 結果用 file watcher。Git hook 跟 CI/CD 的差異在 rebuild 跑在本地（hook）還是 server（CI）— 本地 rebuild 快（&lt; 2 分鐘）就用 hook、慢就推到 CI 避免 push 卡住。</p>
<p>本 blog 目前用手動 trigger — 維護者在寫新文章、需要查相關內容時跑 <code>blogsearch ingest</code>，日常使用頻率不高、不需要即時同步。</p>
<h2 id="schema-設計">Schema 設計</h2>
<p>每個 chunk 存的不只向量。至少有三類資料需要管理：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="ln"> 1</span><span class="cl">chunk = {
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">    vector:   float32[768],       // embedding
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">    text:     string,             // 原始文字（generation 用）
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">    metadata: {                   // filtering + 溯源
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">        source:    string,        // 來源檔案路徑
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">        section:   string,        // 所屬 section（llm/ / backend/ / report/）
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">        title:     string,        // 文章標題
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">        date:      string,        // 文章日期
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">        tags:      []string,      // 文章 tags
</span></span><span class="line"><span class="ln">10</span><span class="cl">        chunk_idx: int,           // 該檔案內的第幾個 chunk
</span></span><span class="line"><span class="ln">11</span><span class="cl">    }
</span></span><span class="line"><span class="ln">12</span><span class="cl">}</span></span></code></pre></div><h3 id="metadata-filter-的設計取捨">Metadata filter 的設計取捨</h3>
<p>Metadata filter 是「在向量相似度之外加條件」：例如「只搜 report/ 下的卡片」「只搜 2026 年之後的文章」。</p>
<p>兩種實作路線：</p>
<p><strong>Code filter</strong>：先做 brute-force / ANN 取 top-N（N 大於最終需要的 K），再用程式碼 filter metadata，取 top-K。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="ln">1</span><span class="cl">優點：不需要 DB、flat file 就能做
</span></span><span class="line"><span class="ln">2</span><span class="cl">限制：filter 比例高時（如 90% 被 filter 掉）需要取很大的 N
</span></span><span class="line"><span class="ln">3</span><span class="cl">適用：filter 條件少、filter 比例低（&lt; 50%）</span></span></code></pre></div><p><strong>DB filter</strong>：在 vector DB 的 query 語法中直接加 metadata condition（如 Qdrant 的 <code>must</code> filter）。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="ln">1</span><span class="cl">優點：filter 在 index 層執行、效率高
</span></span><span class="line"><span class="ln">2</span><span class="cl">限制：需要 vector DB、schema 要先定好
</span></span><span class="line"><span class="ln">3</span><span class="cl">適用：filter 條件多、filter 比例高、query 頻繁</span></span></code></pre></div><p>本 blog 選 code filter：section 只有幾個值（llm / backend / report / work-log），filter 比例低，brute-force top-20 再 filter 到 top-5 就夠。</p>
<h3 id="hybrid-search-的-schema-考量">Hybrid search 的 schema 考量</h3>
<p><a href="/blog/llm/04-applications/rag-principles/" data-link-title="4.1 RAG 原理：retrieval &#43; augmentation 模式" data-link-desc="為什麼模型需要外掛知識、語意相似 vs 字面相似、chunking 的本質取捨、retrieval 失敗的根本原因">4.1 RAG 原理</a>介紹了 <a href="/blog/llm/knowledge-cards/hybrid-search/" data-link-title="Hybrid Search" data-link-desc="把字面 retrieval（BM25）跟語意 retrieval（embedding）的結果用 RRF 等方法合併、補單一路線的盲點">hybrid search</a>（BM25 關鍵字精確匹配 + embedding 語意相似度的加權合併），在 storage 層的 schema 影響是：需要同時存<strong>原始文字</strong>（給 BM25）跟<strong>向量</strong>（給 embedding search）。</p>
<ul>
<li>In-memory / flat file：BM25 自己實作（或用 library），原始文字本來就存了。</li>
<li>Vector DB：多數支援 hybrid search（Qdrant 有 full-text index、Weaviate 有 BM25 + vector 合併查詢）。</li>
<li>SQLite-vec + FTS5：SQLite 原生支援 full-text search（FTS5），配 sqlite-vec 可以在同一個 DB 做 hybrid search。</li>
</ul>
<p>判讀「要不要 hybrid」：先只用 embedding search，retrieval 品質不夠再加 BM25。多數場景 embedding-only 已經夠用；keyword 精確匹配需求高的場景（如搜特定 error message、RFC 編號）才需要 BM25 補。</p>
<h2 id="工程約束dependency-chain-與-build-system">工程約束：dependency chain 與 build system</h2>
<p>Storage 選型不只看功能跟效能，還受<strong>工程約束</strong>影響 — 包括 dependency chain 跟實作語言的計算效能。以下用 Go 專案示範這兩類 constraint 的思考方式；Python / Docker / 前端專案的 constraint 不同、結論見「不同專案的 constraint 不同」段。</p>
<h3 id="case-studygo-專案為什麼不選-sqlite-vec">Case study：Go 專案為什麼不選 SQLite-vec</h3>
<p>SQLite-vec 是 SQLite 的 C extension，提供向量搜尋能力。功能上完全符合需求。但在 Go 生態裡，CGo（Go 呼叫 C 程式碼的橋接機制）引入額外代價：</p>
<table>
  <thead>
      <tr>
          <th>SQLite Go binding</th>
          <th>能用 sqlite-vec？</th>
          <th>代價</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><code>modernc.org/sqlite</code>（純 Go）</td>
          <td>不能</td>
          <td>純 Go 重寫的 SQLite 不支援載入 C extension</td>
      </tr>
      <tr>
          <td><code>mattn/go-sqlite3</code>（CGo binding）</td>
          <td>能</td>
          <td>需要 C compiler、交叉編譯困難、build 時間增加</td>
      </tr>
  </tbody>
</table>
<p>選 <code>mattn/go-sqlite3</code> 意味著：</p>
<ul>
<li>其他維護者 clone 後需要裝 C compiler（macOS 要 Xcode CLI tools、Linux 要 gcc）</li>
<li>CI/CD 需要配 CGo 環境</li>
<li>單 binary 分發的優勢消失（動態連結 libc）</li>
</ul>
<p>這些代價在大團隊可能值得，但對一個個人 blog 的工具來說，dependency chain 的複雜度超過功能收益。</p>
<h3 id="判讀-dependency-約束的反射">判讀 dependency 約束的反射</h3>
<p>每個 storage 選項都帶一條 dependency chain。評估時要問：</p>
<ol>
<li><strong>新維護者 clone 後要裝什麼？</strong> pip install / go build / docker pull / apt install？</li>
<li><strong>CI 要加什麼？</strong> C compiler / Python runtime / Docker image？</li>
<li><strong>哪些平台要支援？</strong> macOS / Linux / Windows？交叉編譯需求？</li>
<li><strong>runtime dependency 還是 build-time dependency？</strong> Runtime（要 server 跑著）的維護成本遠高於 build-time（build 完就不需要了）。</li>
</ol>
<p>本 blog 的 constraint 是：Go 單 binary、clone 後 <code>go build</code> 即可、不需要外部 server。這個 constraint 排除了 CGo dependency 跟任何 server-based 方案，把選項收窄到 flat file。代價是 Go pure cosine + file I/O 讓 query 延遲（151ms）比 Python FAISS（1.8ms）慢 80 倍 — 對 CLI 工具可接受，對高頻 API server 則是致命瓶頸。選型時把 dependency chain 跟計算效能一起評估，避免「dependency 輕但效能差」或「效能好但 dependency 重」的單軸判斷。</p>
<h3 id="不同專案的-constraint-不同">不同專案的 constraint 不同</h3>
<p>這個 constraint 是本 blog 的特定情境。其他專案的 constraint 可能完全不同：</p>
<ul>
<li>Python 生態的專案：pip install 是標準流程，但 FAISS 的 CPU/GPU wheel 有平台相依（M1 Mac 需要 <code>faiss-cpu</code> 特定版本、glibc 版本影響 Linux wheel），不是完全零 constraint。</li>
<li>已有 Docker 的專案：加一個 Qdrant container 看似 <code>docker-compose.yml</code> 多三行，但要考慮 image 體積（數百 MB）、記憶體分配、冷啟動時間、以及 CI 環境是否支援 Docker-in-Docker。</li>
<li>前端專案：WebAssembly 版 HNSW 可行但受 bundle size 跟瀏覽器記憶體上限約束，跟 backend storage 的 constraint 型態完全不同。</li>
</ul>
<p>Storage 選型沒有「最佳方案」— 只有在特定 constraint 下的最適方案。</p>
<h2 id="何時過時--何時不過時">何時過時 / 何時不過時</h2>
<p><strong>不會過時的部分</strong>：</p>
<ul>
<li>RAG pipeline 的四層可替換結構。</li>
<li>Storage 升級的判讀訊號（規模驅動、痛點驅動、不是技術驅動）。</li>
<li>Index 生命週期的 full rebuild vs incremental update 取捨。</li>
<li>Dependency chain 作為選型約束的思考框架。</li>
<li>ANN 策略的複雜度分析（brute-force O(n) vs HNSW O(log n) vs IVF O(n/k)）。</li>
</ul>
<p><strong>會變的部分</strong>：</p>
<ul>
<li>具體 vector DB 的市場格局（Pinecone / Qdrant / Weaviate 的功能差異會持續變動）。</li>
<li>ANN library 的實作效能（新演算法可能比 HNSW 更好）。</li>
<li>語言生態的 binding 成熟度（Go 的 SQLite-vec 純 Go binding 可能出現）。</li>
<li>具體規模閾值（隨硬體進步、「brute-force 可行」的上限會提高）。</li>
</ul>
<h2 id="跟其他章節的關係">跟其他章節的關係</h2>
<table>
  <thead>
      <tr>
          <th>章節</th>
          <th>跟本章的分工</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><a href="/blog/llm/04-applications/rag-principles/" data-link-title="4.1 RAG 原理：retrieval &#43; augmentation 模式" data-link-desc="為什麼模型需要外掛知識、語意相似 vs 字面相似、chunking 的本質取捨、retrieval 失敗的根本原因">4.1 RAG 原理</a></td>
          <td>定義 retrieval + augmentation 本質、本章處理 storage layer</td>
      </tr>
      <tr>
          <td><a href="/blog/llm/04-applications/rag-retrieval-enhancements/" data-link-title="4.2 RAG 檢索增強：query rewriting / HyDE / multi-step / context packing" data-link-desc="Query 端增強（rewriting / expansion / HyDE）、multi-step iterative retrieval、retrieve 後的 context packing（dedup / ordering / summarization）、adaptive retrieval：vanilla RAG 不夠時的下一層工具箱">4.2 RAG 檢索增強</a></td>
          <td>處理 retrieval algorithm 層的增強、本章處理 storage 層</td>
      </tr>
      <tr>
          <td><a href="/blog/llm/04-applications/embedding-model-internals/" data-link-title="4.12 Embedding model 內部：訓練、選型、in-domain fine-tune" data-link-desc="Embedding model 怎麼訓練（contrastive learning &#43; hard negative mining）、怎麼挑（MTEB / 大小 / domain）、何時該自己 fine-tune">4.12 Embedding model</a></td>
          <td>處理向量怎麼生成（含實務選型 constraint 優先序）、本章處理向量怎麼存</td>
      </tr>
      <tr>
          <td><a href="/blog/llm/04-applications/artifact-management/" data-link-title="4.10 衍生產物管理原理：什麼進 git、什麼不該" data-link-desc="LLM 應用的 source / derived / external 三類產物對應 git / build cache / registry、與 production 部署的 reproducibility / cost / share 取捨">4.10 衍生產物管理</a></td>
          <td>Index 是 derived artifact、不進 git、用 manifest 描述</td>
      </tr>
      <tr>
          <td><a href="/blog/llm/knowledge-cards/vector-database/" data-link-title="Vector Database" data-link-desc="為高維向量 (embedding) 設計的儲存 &#43; 近似最近鄰 (ANN) 檢索系統：RAG 從 prototype 跨到 production 的關鍵元件">Vector database 卡</a></td>
          <td>概念定義與 ANN 演算法摘要、本章補工程判讀</td>
      </tr>
  </tbody>
</table>
<h2 id="下一步">下一步</h2>
<p>本章整理的是跨場景的 storage 工程原則。Running example 的 blog 基於這些原則選了「Go + flat file + brute-force」方案，完整實作過程（選型→重寫→效能優化→四方案 benchmark→二次選型評估）見 <a href="/blog/llm/04-applications/hands-on/blog-vector-search/" data-link-title="Case Study：Blog 語意搜尋從 pickle 到 production" data-link-desc="為 CLI 或個人工具選 RAG storage backend、或原始選型理由被 benchmark 推翻但結論不變時，如何區分結論、理由與前提">Case Study：Blog 語意搜尋從 pickle 到 production</a>。</p>
<p>想看 retrieval 品質不夠時的增強手段（query rewriting / HyDE / multi-step），回到 <a href="/blog/llm/04-applications/rag-retrieval-enhancements/" data-link-title="4.2 RAG 檢索增強：query rewriting / HyDE / multi-step / context packing" data-link-desc="Query 端增強（rewriting / expansion / HyDE）、multi-step iterative retrieval、retrieve 後的 context packing（dedup / ordering / summarization）、adaptive retrieval：vanilla RAG 不夠時的下一層工具箱">4.2 RAG 檢索增強</a>。想看 embedding 模型怎麼選（含工程 constraint 如何先砍選項再比品質）、怎麼判讀 MTEB 分數，回到 <a href="/blog/llm/04-applications/embedding-model-internals/" data-link-title="4.12 Embedding model 內部：訓練、選型、in-domain fine-tune" data-link-desc="Embedding model 怎麼訓練（contrastive learning &#43; hard negative mining）、怎麼挑（MTEB / 大小 / domain）、何時該自己 fine-tune">4.12 Embedding model 內部</a>。</p>
]]></content:encoded></item></channel></rss>