<?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>Embedding on Tarragon</title><link>https://tarrragon.github.io/blog/tags/embedding/</link><description>Recent content in Embedding 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/embedding/index.xml" rel="self" type="application/rss+xml"/><item><title>Word2Vec</title><link>https://tarrragon.github.io/blog/llm/knowledge-cards/word2vec/</link><pubDate>Thu, 14 May 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/llm/knowledge-cards/word2vec/</guid><description>&lt;p>Word2Vec 的核心概念是「&lt;strong>用上下文預測任務學出靜態詞向量&lt;/strong>」。它讓語意相近的詞在向量空間中靠近，是理解 &lt;a href="https://tarrragon.github.io/blog/llm/knowledge-cards/embedding-model/" data-link-title="Embedding Model" data-link-desc="把文字轉成向量的模型：用於 codebase 索引與語意搜尋">embedding model&lt;/a> 與 embedding space 的經典起點。&lt;/p>
&lt;h2 id="概念位置">概念位置&lt;/h2>
&lt;p>Word2Vec 屬於 LLM 前一代的 static embedding 方法，常見訓練方式是 skip-gram 與 CBOW。它跟現代 &lt;a href="https://tarrragon.github.io/blog/llm/knowledge-cards/embedding-model/" data-link-title="Embedding Model" data-link-desc="把文字轉成向量的模型：用於 codebase 索引與語意搜尋">embedding model&lt;/a> 的差異是：Word2Vec 對同一個詞給固定向量，現代 Transformer 會依上下文產生 contextual representation。&lt;/p>
&lt;h2 id="可觀察訊號與例子">可觀察訊號與例子&lt;/h2>
&lt;p>經典例子是 &lt;code>king - man + woman ≈ queen&lt;/code> 這類向量類比。它展示 embedding space 可以承載語意方向，但也暴露靜態詞向量對多義詞與上下文的限制。&lt;/p>
&lt;h2 id="設計責任">設計責任&lt;/h2>
&lt;p>讀 embedding 章節看到 Word2Vec 時，把它當成「embedding 概念的歷史基線」。實務 RAG 選型通常看現代 embedding model 與 &lt;a href="https://tarrragon.github.io/blog/llm/knowledge-cards/mteb-benchmark/" data-link-title="MTEB" data-link-desc="Massive Text Embedding Benchmark：8 大類 56 任務、評估 embedding model 跨任務通用能力的標準">MTEB&lt;/a>，不是直接使用 Word2Vec。&lt;/p></description><content:encoded><![CDATA[<p>Word2Vec 的核心概念是「<strong>用上下文預測任務學出靜態詞向量</strong>」。它讓語意相近的詞在向量空間中靠近，是理解 <a href="/blog/llm/knowledge-cards/embedding-model/" data-link-title="Embedding Model" data-link-desc="把文字轉成向量的模型：用於 codebase 索引與語意搜尋">embedding model</a> 與 embedding space 的經典起點。</p>
<h2 id="概念位置">概念位置</h2>
<p>Word2Vec 屬於 LLM 前一代的 static embedding 方法，常見訓練方式是 skip-gram 與 CBOW。它跟現代 <a href="/blog/llm/knowledge-cards/embedding-model/" data-link-title="Embedding Model" data-link-desc="把文字轉成向量的模型：用於 codebase 索引與語意搜尋">embedding model</a> 的差異是：Word2Vec 對同一個詞給固定向量，現代 Transformer 會依上下文產生 contextual representation。</p>
<h2 id="可觀察訊號與例子">可觀察訊號與例子</h2>
<p>經典例子是 <code>king - man + woman ≈ queen</code> 這類向量類比。它展示 embedding space 可以承載語意方向，但也暴露靜態詞向量對多義詞與上下文的限制。</p>
<h2 id="設計責任">設計責任</h2>
<p>讀 embedding 章節看到 Word2Vec 時，把它當成「embedding 概念的歷史基線」。實務 RAG 選型通常看現代 embedding model 與 <a href="/blog/llm/knowledge-cards/mteb-benchmark/" data-link-title="MTEB" data-link-desc="Massive Text Embedding Benchmark：8 大類 56 任務、評估 embedding model 跨任務通用能力的標準">MTEB</a>，不是直接使用 Word2Vec。</p>
]]></content:encoded></item><item><title>Contrastive Learning</title><link>https://tarrragon.github.io/blog/llm/knowledge-cards/contrastive-learning/</link><pubDate>Tue, 12 May 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/llm/knowledge-cards/contrastive-learning/</guid><description>&lt;p>Contrastive learning（對比學習）的核心概念是「&lt;strong>訓練模型讓相關樣本的 embedding 在向量空間中靠近、無關樣本遠離&lt;/strong>」。是現代 &lt;a href="https://tarrragon.github.io/blog/llm/knowledge-cards/embedding-model/" data-link-title="Embedding Model" data-link-desc="把文字轉成向量的模型：用於 codebase 索引與語意搜尋">embedding model&lt;/a> 的標準訓練 paradigm、跟 LLM pretrain 的 next-token prediction 完全不同的訓練目標。&lt;/p>
&lt;h2 id="概念位置">概念位置&lt;/h2>
&lt;p>Contrastive learning 的核心訓練形態：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-text" data-lang="text">&lt;span class="line">&lt;span class="ln"> 1&lt;/span>&lt;span class="cl">正向對（positive pair）：
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 2&lt;/span>&lt;span class="cl"> (query, relevant_doc) — 應該在 embedding 空間靠近
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 3&lt;/span>&lt;span class="cl"> 例：(&amp;#34;Python how to read file&amp;#34;, &amp;#34;Python file reading tutorial...&amp;#34;)
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 4&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 5&lt;/span>&lt;span class="cl">負向對（negative pair）：
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 6&lt;/span>&lt;span class="cl"> (query, irrelevant_doc) — 應該在 embedding 空間遠離
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 7&lt;/span>&lt;span class="cl"> 例：(&amp;#34;Python how to read file&amp;#34;, &amp;#34;CSS flexbox guide...&amp;#34;)
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 8&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 9&lt;/span>&lt;span class="cl">Loss（簡化的 InfoNCE loss）：
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">10&lt;/span>&lt;span class="cl"> pull positive pair 靠近
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">11&lt;/span>&lt;span class="cl"> push negative pair 遠離（多個 negative samples 對比）&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>主流形式：&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>形式&lt;/th>
 &lt;th>Loss 設計&lt;/th>
 &lt;th>代表模型&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>Triplet loss&lt;/td>
 &lt;td>(anchor, positive, negative)、要求 anchor-positive 距離 &amp;lt; anchor-negative&lt;/td>
 &lt;td>早期 sentence-BERT&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>InfoNCE / NCE&lt;/td>
 &lt;td>Cross-entropy over batch、把 batch 內其他樣本當 hard negative&lt;/td>
 &lt;td>OpenAI ada-002、bge 系列&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>MultipleNegativesRankingLoss&lt;/td>
 &lt;td>上述變體、用 batch 內隨機其他樣本當 negative&lt;/td>
 &lt;td>Sentence-Transformers 主流&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>關鍵特性：&lt;/p>
&lt;ol>
&lt;li>&lt;strong>資料量需求大&lt;/strong>：contrastive learning 需要億級的正向對才能訓出好 embedding；資料來源是 query-doc click log、StackExchange QA pair、CC-paraphrase 等&lt;/li>
&lt;li>&lt;strong>Hard negative mining 是品質關鍵&lt;/strong>：隨機選 negative 容易（從 batch 取就行）、找「看似相關但實際無關」的 hard negative 更挑戰、是 embedding quality 提升的關鍵&lt;/li>
&lt;li>&lt;strong>不能直接拿 pretrained LLM 用&lt;/strong>：LLM 的 hidden state 不是「為 retrieval 優化」的、要再 fine-tune 一輪 contrastive learning 才能當 embedding model&lt;/li>
&lt;/ol>
&lt;h2 id="設計責任">設計責任&lt;/h2>
&lt;p>讀 embedding model paper / 訓練 code 看到「InfoNCE」「triplet」「hard negatives」「mining strategy」就是這 paradigm。寫 code 場景的判讀：&lt;/p>
&lt;ol>
&lt;li>&lt;strong>挑 embedding model 看訓練資料 domain&lt;/strong>：通用 retrieval（如 bge-large、nomic-embed）vs code-specific（如 jina-embeddings-v2-code、CodeT5+）、訓練資料分佈影響大&lt;/li>
&lt;li>&lt;strong>不能拿任意 LLM 抽 hidden state 當 embedding&lt;/strong>：如「Llama 的 last hidden state 當 embedding」這類做法在 retrieval 上通常顯著輸給專門 contrastive-trained embedding model&lt;/li>
&lt;li>&lt;strong>Fine-tune embedding model 通常用 LoRA + contrastive loss&lt;/strong>：在自己 domain 資料上 fine-tune、提升 in-domain retrieval；標準 pipeline 是 sentence-transformers + LoRA&lt;/li>
&lt;/ol></description><content:encoded><![CDATA[<p>Contrastive learning（對比學習）的核心概念是「<strong>訓練模型讓相關樣本的 embedding 在向量空間中靠近、無關樣本遠離</strong>」。是現代 <a href="/blog/llm/knowledge-cards/embedding-model/" data-link-title="Embedding Model" data-link-desc="把文字轉成向量的模型：用於 codebase 索引與語意搜尋">embedding model</a> 的標準訓練 paradigm、跟 LLM pretrain 的 next-token prediction 完全不同的訓練目標。</p>
<h2 id="概念位置">概念位置</h2>
<p>Contrastive learning 的核心訓練形態：</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">正向對（positive pair）：
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">  (query, relevant_doc) — 應該在 embedding 空間靠近
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">  例：(&#34;Python how to read file&#34;, &#34;Python file reading tutorial...&#34;)
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">負向對（negative pair）：
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">  (query, irrelevant_doc) — 應該在 embedding 空間遠離
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">  例：(&#34;Python how to read file&#34;, &#34;CSS flexbox guide...&#34;)
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">Loss（簡化的 InfoNCE loss）：
</span></span><span class="line"><span class="ln">10</span><span class="cl">  pull positive pair 靠近
</span></span><span class="line"><span class="ln">11</span><span class="cl">  push negative pair 遠離（多個 negative samples 對比）</span></span></code></pre></div><p>主流形式：</p>
<table>
  <thead>
      <tr>
          <th>形式</th>
          <th>Loss 設計</th>
          <th>代表模型</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Triplet loss</td>
          <td>(anchor, positive, negative)、要求 anchor-positive 距離 &lt; anchor-negative</td>
          <td>早期 sentence-BERT</td>
      </tr>
      <tr>
          <td>InfoNCE / NCE</td>
          <td>Cross-entropy over batch、把 batch 內其他樣本當 hard negative</td>
          <td>OpenAI ada-002、bge 系列</td>
      </tr>
      <tr>
          <td>MultipleNegativesRankingLoss</td>
          <td>上述變體、用 batch 內隨機其他樣本當 negative</td>
          <td>Sentence-Transformers 主流</td>
      </tr>
  </tbody>
</table>
<p>關鍵特性：</p>
<ol>
<li><strong>資料量需求大</strong>：contrastive learning 需要億級的正向對才能訓出好 embedding；資料來源是 query-doc click log、StackExchange QA pair、CC-paraphrase 等</li>
<li><strong>Hard negative mining 是品質關鍵</strong>：隨機選 negative 容易（從 batch 取就行）、找「看似相關但實際無關」的 hard negative 更挑戰、是 embedding quality 提升的關鍵</li>
<li><strong>不能直接拿 pretrained LLM 用</strong>：LLM 的 hidden state 不是「為 retrieval 優化」的、要再 fine-tune 一輪 contrastive learning 才能當 embedding model</li>
</ol>
<h2 id="設計責任">設計責任</h2>
<p>讀 embedding model paper / 訓練 code 看到「InfoNCE」「triplet」「hard negatives」「mining strategy」就是這 paradigm。寫 code 場景的判讀：</p>
<ol>
<li><strong>挑 embedding model 看訓練資料 domain</strong>：通用 retrieval（如 bge-large、nomic-embed）vs code-specific（如 jina-embeddings-v2-code、CodeT5+）、訓練資料分佈影響大</li>
<li><strong>不能拿任意 LLM 抽 hidden state 當 embedding</strong>：如「Llama 的 last hidden state 當 embedding」這類做法在 retrieval 上通常顯著輸給專門 contrastive-trained embedding model</li>
<li><strong>Fine-tune embedding model 通常用 LoRA + contrastive loss</strong>：在自己 domain 資料上 fine-tune、提升 in-domain retrieval；標準 pipeline 是 sentence-transformers + LoRA</li>
</ol>
]]></content:encoded></item><item><title>MTEB</title><link>https://tarrragon.github.io/blog/llm/knowledge-cards/mteb-benchmark/</link><pubDate>Tue, 12 May 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/llm/knowledge-cards/mteb-benchmark/</guid><description>&lt;p>MTEB（Massive Text Embedding Benchmark、Muennighoff et al., 2022）的核心概念是「&lt;strong>評估 &lt;a href="https://tarrragon.github.io/blog/llm/knowledge-cards/embedding-model/" data-link-title="Embedding Model" data-link-desc="把文字轉成向量的模型：用於 codebase 索引與語意搜尋">embedding model&lt;/a> 跨多種任務通用能力的標準 benchmark&lt;/strong>」。覆蓋 8 大類任務（classification、clustering、pair classification、reranking、retrieval、STS、summarization、bitext mining）、56 個 dataset、112 種語言。是現在挑選 embedding model 最常用的 leaderboard。&lt;/p>
&lt;h2 id="概念位置">概念位置&lt;/h2>
&lt;p>MTEB 的 8 大任務類別：&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>Classification&lt;/td>
 &lt;td>用 embedding 做下游分類（如情感分析）&lt;/td>
 &lt;td>分類 accuracy&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Clustering&lt;/td>
 &lt;td>把相似 doc 聚到一起&lt;/td>
 &lt;td>V-measure、NMI&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Pair classification&lt;/td>
 &lt;td>判斷兩段文字「相關 / 不相關」&lt;/td>
 &lt;td>F1、AP&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;strong>Reranking&lt;/strong>&lt;/td>
 &lt;td>對 retrieval 結果用 embedding 重新排序&lt;/td>
 &lt;td>mAP、MRR&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;strong>Retrieval&lt;/strong>&lt;/td>
 &lt;td>給 query、從大量 corpus 找相關 doc&lt;/td>
 &lt;td>nDCG@10、&lt;a href="https://tarrragon.github.io/blog/llm/knowledge-cards/retrieval-recall/" data-link-title="Retrieval Recall" data-link-desc="衡量 RAG 檢索是否把應該命中的文件或 chunk 放進 top-k 結果，是 component-level eval 的核心指標">Recall@k&lt;/a>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>STS（Semantic Textual Similarity）&lt;/td>
 &lt;td>預測句對相似度（連續分數）&lt;/td>
 &lt;td>Spearman correlation&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Summarization&lt;/td>
 &lt;td>embedding-based summary quality&lt;/td>
 &lt;td>Correlation with human rating&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Bitext mining&lt;/td>
 &lt;td>跨語言找翻譯對&lt;/td>
 &lt;td>F1&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>&lt;strong>對寫 code / RAG 場景最相關&lt;/strong>：Retrieval、Reranking 兩類（粗體）。其他類別反映通用能力、但不直接影響 RAG 應用品質。&lt;/p>
&lt;p>主流 embedding model 在 MTEB Retrieval 的代表性能（2026/5 估計、會持續變動）：&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>模型&lt;/th>
 &lt;th>模型大小&lt;/th>
 &lt;th>MTEB Retrieval avg&lt;/th>
 &lt;th>適合場景&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>BAAI/bge-large-en-v1.5&lt;/td>
 &lt;td>~335M&lt;/td>
 &lt;td>~55&lt;/td>
 &lt;td>開源通用、英文 retrieval 主力&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>nomic-embed-text-v1.5&lt;/td>
 &lt;td>~137M&lt;/td>
 &lt;td>~52&lt;/td>
 &lt;td>開源、小巧、Ollama 內建&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>jina-embeddings-v3&lt;/td>
 &lt;td>~570M&lt;/td>
 &lt;td>~58&lt;/td>
 &lt;td>開源、多語、code 友善&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>mxbai-embed-large-v1&lt;/td>
 &lt;td>~335M&lt;/td>
 &lt;td>~55&lt;/td>
 &lt;td>開源通用&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>OpenAI text-embedding-3-large&lt;/td>
 &lt;td>API only&lt;/td>
 &lt;td>~64&lt;/td>
 &lt;td>雲端旗艦&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>voyage-3&lt;/td>
 &lt;td>API only&lt;/td>
 &lt;td>~62&lt;/td>
 &lt;td>雲端、Anthropic 推薦&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;blockquote>
&lt;p>&lt;strong>事實查核註&lt;/strong>：MTEB 數字依模型版本、評估配置變動、上述為 2026/5 大致排名、引用前以 &lt;a href="https://huggingface.co/spaces/mteb/leaderboard">MTEB Leaderboard&lt;/a> 當前狀態為準。&lt;/p>&lt;/blockquote>
&lt;h2 id="設計責任">設計責任&lt;/h2>
&lt;p>讀 embedding model 比較看到「MTEB score」就是這 benchmark。寫 code / RAG 場景的判讀：&lt;/p>
&lt;ol>
&lt;li>&lt;strong>看 Retrieval 子分數、不是 overall&lt;/strong>：MTEB overall 含 8 類、跟 RAG 場景關係最大的是 Retrieval 子分；通用 retrieval 分數高、reranking 分數高、就值得試&lt;/li>
&lt;li>&lt;strong>跟自己 domain 對齊&lt;/strong>：MTEB 多為通用語料、自己 domain（如 code、medical、legal）可能跟 MTEB 落差大；in-domain benchmark 比 MTEB 更重要&lt;/li>
&lt;li>&lt;strong>大小 / 速度 / 品質 trade-off&lt;/strong>：bge-large（335M）vs nomic-embed（137M）、後者跑得快、適合本地 RAG；前者品質略高、適合雲端或 latency 不敏感場景&lt;/li>
&lt;li>&lt;strong>MTEB 高分不代表「適合你」&lt;/strong>：高分模型可能是 instruction-tuned embedding（query 需要加特定前綴）、用法跟簡單模型不同、要看 model card&lt;/li>
&lt;/ol></description><content:encoded><![CDATA[<p>MTEB（Massive Text Embedding Benchmark、Muennighoff et al., 2022）的核心概念是「<strong>評估 <a href="/blog/llm/knowledge-cards/embedding-model/" data-link-title="Embedding Model" data-link-desc="把文字轉成向量的模型：用於 codebase 索引與語意搜尋">embedding model</a> 跨多種任務通用能力的標準 benchmark</strong>」。覆蓋 8 大類任務（classification、clustering、pair classification、reranking、retrieval、STS、summarization、bitext mining）、56 個 dataset、112 種語言。是現在挑選 embedding model 最常用的 leaderboard。</p>
<h2 id="概念位置">概念位置</h2>
<p>MTEB 的 8 大任務類別：</p>
<table>
  <thead>
      <tr>
          <th>類別</th>
          <th>任務本質</th>
          <th>衡量</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Classification</td>
          <td>用 embedding 做下游分類（如情感分析）</td>
          <td>分類 accuracy</td>
      </tr>
      <tr>
          <td>Clustering</td>
          <td>把相似 doc 聚到一起</td>
          <td>V-measure、NMI</td>
      </tr>
      <tr>
          <td>Pair classification</td>
          <td>判斷兩段文字「相關 / 不相關」</td>
          <td>F1、AP</td>
      </tr>
      <tr>
          <td><strong>Reranking</strong></td>
          <td>對 retrieval 結果用 embedding 重新排序</td>
          <td>mAP、MRR</td>
      </tr>
      <tr>
          <td><strong>Retrieval</strong></td>
          <td>給 query、從大量 corpus 找相關 doc</td>
          <td>nDCG@10、<a href="/blog/llm/knowledge-cards/retrieval-recall/" data-link-title="Retrieval Recall" data-link-desc="衡量 RAG 檢索是否把應該命中的文件或 chunk 放進 top-k 結果，是 component-level eval 的核心指標">Recall@k</a></td>
      </tr>
      <tr>
          <td>STS（Semantic Textual Similarity）</td>
          <td>預測句對相似度（連續分數）</td>
          <td>Spearman correlation</td>
      </tr>
      <tr>
          <td>Summarization</td>
          <td>embedding-based summary quality</td>
          <td>Correlation with human rating</td>
      </tr>
      <tr>
          <td>Bitext mining</td>
          <td>跨語言找翻譯對</td>
          <td>F1</td>
      </tr>
  </tbody>
</table>
<p><strong>對寫 code / RAG 場景最相關</strong>：Retrieval、Reranking 兩類（粗體）。其他類別反映通用能力、但不直接影響 RAG 應用品質。</p>
<p>主流 embedding model 在 MTEB Retrieval 的代表性能（2026/5 估計、會持續變動）：</p>
<table>
  <thead>
      <tr>
          <th>模型</th>
          <th>模型大小</th>
          <th>MTEB Retrieval avg</th>
          <th>適合場景</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>BAAI/bge-large-en-v1.5</td>
          <td>~335M</td>
          <td>~55</td>
          <td>開源通用、英文 retrieval 主力</td>
      </tr>
      <tr>
          <td>nomic-embed-text-v1.5</td>
          <td>~137M</td>
          <td>~52</td>
          <td>開源、小巧、Ollama 內建</td>
      </tr>
      <tr>
          <td>jina-embeddings-v3</td>
          <td>~570M</td>
          <td>~58</td>
          <td>開源、多語、code 友善</td>
      </tr>
      <tr>
          <td>mxbai-embed-large-v1</td>
          <td>~335M</td>
          <td>~55</td>
          <td>開源通用</td>
      </tr>
      <tr>
          <td>OpenAI text-embedding-3-large</td>
          <td>API only</td>
          <td>~64</td>
          <td>雲端旗艦</td>
      </tr>
      <tr>
          <td>voyage-3</td>
          <td>API only</td>
          <td>~62</td>
          <td>雲端、Anthropic 推薦</td>
      </tr>
  </tbody>
</table>
<blockquote>
<p><strong>事實查核註</strong>：MTEB 數字依模型版本、評估配置變動、上述為 2026/5 大致排名、引用前以 <a href="https://huggingface.co/spaces/mteb/leaderboard">MTEB Leaderboard</a> 當前狀態為準。</p></blockquote>
<h2 id="設計責任">設計責任</h2>
<p>讀 embedding model 比較看到「MTEB score」就是這 benchmark。寫 code / RAG 場景的判讀：</p>
<ol>
<li><strong>看 Retrieval 子分數、不是 overall</strong>：MTEB overall 含 8 類、跟 RAG 場景關係最大的是 Retrieval 子分；通用 retrieval 分數高、reranking 分數高、就值得試</li>
<li><strong>跟自己 domain 對齊</strong>：MTEB 多為通用語料、自己 domain（如 code、medical、legal）可能跟 MTEB 落差大；in-domain benchmark 比 MTEB 更重要</li>
<li><strong>大小 / 速度 / 品質 trade-off</strong>：bge-large（335M）vs nomic-embed（137M）、後者跑得快、適合本地 RAG；前者品質略高、適合雲端或 latency 不敏感場景</li>
<li><strong>MTEB 高分不代表「適合你」</strong>：高分模型可能是 instruction-tuned embedding（query 需要加特定前綴）、用法跟簡單模型不同、要看 model card</li>
</ol>
]]></content:encoded></item><item><title>3.1 Embedding 空間</title><link>https://tarrragon.github.io/blog/llm/03-theoretical-foundations/embedding-spaces/</link><pubDate>Mon, 11 May 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/llm/03-theoretical-foundations/embedding-spaces/</guid><description>&lt;p>Embedding 是 LLM 把離散 &lt;a href="https://tarrragon.github.io/blog/llm/knowledge-cards/token/" data-link-title="Token" data-link-desc="LLM 處理文字時的最小單位：介於字元與單字之間">token&lt;/a> 轉成連續向量的關鍵步驟。模型內部的每一層運算都對向量做、token 本身的整數 ID 只在 input / output 端用到。理解 embedding 怎麼運作、能解釋「為什麼模型能理解 token 之間的語意關係」「為什麼 &lt;a href="https://tarrragon.github.io/blog/llm/knowledge-cards/embedding-model/" data-link-title="Embedding Model" data-link-desc="把文字轉成向量的模型：用於 codebase 索引與語意搜尋">embedding 模型&lt;/a> 能做 semantic search」「為什麼不同 model 的 embedding 互不相容」。&lt;/p>
&lt;p>本章拆開 embedding 的三件事：怎麼從 token ID 變成向量、向量空間怎麼承載語意、embedding 是怎麼學出來的。&lt;/p>
&lt;h2 id="本章目標">本章目標&lt;/h2>
&lt;p>讀完本章後、你應該能：&lt;/p>
&lt;ol>
&lt;li>解釋 embedding layer 在 LLM 中的位置。&lt;/li>
&lt;li>看到「embedding dimension = 4096」時、知道指什麼。&lt;/li>
&lt;li>解釋 RAG / semantic search 為什麼用 embedding similarity。&lt;/li>
&lt;li>區分 &lt;a href="https://tarrragon.github.io/blog/llm/knowledge-cards/word2vec/" data-link-title="Word2Vec" data-link-desc="早期靜態詞向量方法，用 skip-gram / CBOW 從上下文學出詞語 embedding">word2vec&lt;/a>、句子 embedding、contextual embedding 的差別。&lt;/li>
&lt;/ol>
&lt;h2 id="embedding-layer從-token-id-到向量">&lt;a href="https://tarrragon.github.io/blog/llm/knowledge-cards/embedding-layer/" data-link-title="Embedding Layer" data-link-desc="Transformer 第一層的查表結構、把整數 token ID 轉成可運算的向量">Embedding Layer&lt;/a>：從 token ID 到向量&lt;/h2>
&lt;p>Embedding layer（嵌入層）的核心結構是「一個 lookup table、把 token ID（整數）map 到向量」。形式上是一個 &lt;code>(vocab_size, hidden_dim)&lt;/code> 的矩陣 E：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-text" data-lang="text">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">token_id = 12345
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">embedding = E[12345] ← 取出第 12345 row、得到 hidden_dim 維向量&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Gemma 4 31B 的 embedding matrix：&lt;/p>
&lt;ul>
&lt;li>vocab_size = 256,000&lt;/li>
&lt;li>hidden_dim = 5120&lt;/li>
&lt;li>總參數 = 256,000 × 5120 ≈ 1.3 billion&lt;/li>
&lt;/ul>
&lt;p>光是 embedding layer 就佔 31B 中的 1.3B（約 4%）。70B 模型的 embedding layer 更大、可達 2B 以上。&lt;/p>
&lt;p>實作上 embedding lookup 比矩陣乘法便宜（只是查表）、但記憶體佔用顯著。&lt;/p>
&lt;h2 id="向量空間用-hidden_dim-維空間編碼語意">向量空間：用 hidden_dim 維空間編碼語意&lt;/h2>
&lt;p>Embedding 的設計目標是「讓相似 token 在向量空間中靠近、不相似的遠」。具體用&lt;a href="https://tarrragon.github.io/blog/llm/02-math-foundations/linear-algebra-for-llm/" data-link-title="2.0 線性代數：向量、矩陣、空間" data-link-desc="LLM 內部運算的基底：向量、矩陣、向量空間、內積、norm、矩陣乘法的角色">內積&lt;/a> 或 cosine similarity 衡量相似度：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-text" data-lang="text">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">cosine_sim(a, b) = (a · b) / (||a|| × ||b||)&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>訓練後的 embedding 會展現語意結構：&lt;/p>
&lt;ul>
&lt;li>&lt;code>embedding(cat)&lt;/code> 跟 &lt;code>embedding(kitten)&lt;/code> 內積大。&lt;/li>
&lt;li>&lt;code>embedding(cat)&lt;/code> 跟 &lt;code>embedding(algorithm)&lt;/code> 內積小。&lt;/li>
&lt;li>著名的「king - man + woman ≈ queen」現象（word2vec 時代發現、Transformer 上也成立）。&lt;/li>
&lt;/ul>
&lt;p>這個性質讓 embedding 能做：&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Semantic search&lt;/strong>：把 query 跟 documents 都轉成 embedding、用 cosine similarity 找相似的。&lt;/li>
&lt;li>&lt;strong>RAG&lt;/strong>：把 codebase chunks embed、用 query embedding 找相關片段。&lt;/li>
&lt;li>&lt;strong>Clustering&lt;/strong>：embedding 上跑 k-means、把語意相近的 document 分組。&lt;/li>
&lt;li>&lt;strong>Anomaly detection&lt;/strong>：embedding 離 cluster 中心遠的就是異常。&lt;/li>
&lt;/ul>
&lt;h2 id="embedding-怎麼學出來">Embedding 怎麼學出來&lt;/h2>
&lt;p>Embedding layer 跟其他 layer 一樣、是訓練過程中學出來的。具體機制：&lt;/p></description><content:encoded><![CDATA[<p>Embedding 是 LLM 把離散 <a href="/blog/llm/knowledge-cards/token/" data-link-title="Token" data-link-desc="LLM 處理文字時的最小單位：介於字元與單字之間">token</a> 轉成連續向量的關鍵步驟。模型內部的每一層運算都對向量做、token 本身的整數 ID 只在 input / output 端用到。理解 embedding 怎麼運作、能解釋「為什麼模型能理解 token 之間的語意關係」「為什麼 <a href="/blog/llm/knowledge-cards/embedding-model/" data-link-title="Embedding Model" data-link-desc="把文字轉成向量的模型：用於 codebase 索引與語意搜尋">embedding 模型</a> 能做 semantic search」「為什麼不同 model 的 embedding 互不相容」。</p>
<p>本章拆開 embedding 的三件事：怎麼從 token ID 變成向量、向量空間怎麼承載語意、embedding 是怎麼學出來的。</p>
<h2 id="本章目標">本章目標</h2>
<p>讀完本章後、你應該能：</p>
<ol>
<li>解釋 embedding layer 在 LLM 中的位置。</li>
<li>看到「embedding dimension = 4096」時、知道指什麼。</li>
<li>解釋 RAG / semantic search 為什麼用 embedding similarity。</li>
<li>區分 <a href="/blog/llm/knowledge-cards/word2vec/" data-link-title="Word2Vec" data-link-desc="早期靜態詞向量方法，用 skip-gram / CBOW 從上下文學出詞語 embedding">word2vec</a>、句子 embedding、contextual embedding 的差別。</li>
</ol>
<h2 id="embedding-layer從-token-id-到向量"><a href="/blog/llm/knowledge-cards/embedding-layer/" data-link-title="Embedding Layer" data-link-desc="Transformer 第一層的查表結構、把整數 token ID 轉成可運算的向量">Embedding Layer</a>：從 token ID 到向量</h2>
<p>Embedding layer（嵌入層）的核心結構是「一個 lookup table、把 token ID（整數）map 到向量」。形式上是一個 <code>(vocab_size, hidden_dim)</code> 的矩陣 E：</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">token_id = 12345
</span></span><span class="line"><span class="ln">2</span><span class="cl">embedding = E[12345]   ← 取出第 12345 row、得到 hidden_dim 維向量</span></span></code></pre></div><p>Gemma 4 31B 的 embedding matrix：</p>
<ul>
<li>vocab_size = 256,000</li>
<li>hidden_dim = 5120</li>
<li>總參數 = 256,000 × 5120 ≈ 1.3 billion</li>
</ul>
<p>光是 embedding layer 就佔 31B 中的 1.3B（約 4%）。70B 模型的 embedding layer 更大、可達 2B 以上。</p>
<p>實作上 embedding lookup 比矩陣乘法便宜（只是查表）、但記憶體佔用顯著。</p>
<h2 id="向量空間用-hidden_dim-維空間編碼語意">向量空間：用 hidden_dim 維空間編碼語意</h2>
<p>Embedding 的設計目標是「讓相似 token 在向量空間中靠近、不相似的遠」。具體用<a href="/blog/llm/02-math-foundations/linear-algebra-for-llm/" data-link-title="2.0 線性代數：向量、矩陣、空間" data-link-desc="LLM 內部運算的基底：向量、矩陣、向量空間、內積、norm、矩陣乘法的角色">內積</a> 或 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">cosine_sim(a, b) = (a · b) / (||a|| × ||b||)</span></span></code></pre></div><p>訓練後的 embedding 會展現語意結構：</p>
<ul>
<li><code>embedding(cat)</code> 跟 <code>embedding(kitten)</code> 內積大。</li>
<li><code>embedding(cat)</code> 跟 <code>embedding(algorithm)</code> 內積小。</li>
<li>著名的「king - man + woman ≈ queen」現象（word2vec 時代發現、Transformer 上也成立）。</li>
</ul>
<p>這個性質讓 embedding 能做：</p>
<ul>
<li><strong>Semantic search</strong>：把 query 跟 documents 都轉成 embedding、用 cosine similarity 找相似的。</li>
<li><strong>RAG</strong>：把 codebase chunks embed、用 query embedding 找相關片段。</li>
<li><strong>Clustering</strong>：embedding 上跑 k-means、把語意相近的 document 分組。</li>
<li><strong>Anomaly detection</strong>：embedding 離 cluster 中心遠的就是異常。</li>
</ul>
<h2 id="embedding-怎麼學出來">Embedding 怎麼學出來</h2>
<p>Embedding layer 跟其他 layer 一樣、是訓練過程中學出來的。具體機制：</p>
<ol>
<li>訓練初期 embedding 是隨機初始化。</li>
<li>Forward pass 用這些 embedding 跑模型、預測下一個 token。</li>
<li>預測錯了、loss 大、<a href="/blog/llm/02-math-foundations/calculus-and-optimization/" data-link-title="2.2 微積分與最佳化" data-link-desc="從 gradient、chain rule 到 SGD / Adam：LLM 訓練如何更新數十億參數">backprop</a> 算 gradient、更新 embedding。</li>
<li>反覆 trillion token 訓練、embedding 收斂到能表達語意。</li>
</ol>
<p>訓練機制讓「常在類似 context 出現的 token」拿到相似的 embedding。例如 <code>cat</code> 跟 <code>kitten</code> 在訓練資料中常出現在類似句子（「The ___ is sleeping」「I have a pet ___」等）、模型最佳化的方向會自然讓兩者的 embedding 接近。</p>
<p>這就是「distributional semantics」（分佈式語意）的核心假設：<strong>字詞的意義由它周圍的字詞決定</strong>（&ldquo;You shall know a word by the company it keeps&rdquo;, J. R. Firth, 1957）。</p>
<h2 id="word2vecembedding-的早期實作">Word2Vec：embedding 的早期實作</h2>
<p>Word2Vec（Mikolov et al., 2013）是 embedding 的經典實作、影響後續所有 NLP。它的核心是「用淺層網路專門學 embedding」、不做下游任務：</p>
<ul>
<li><strong>Skip-gram</strong>：給一個中心字、預測周圍字。</li>
<li><strong>CBOW</strong>：給周圍字、預測中心字。</li>
</ul>
<p>訓練後 embedding 展現語意結構（包括「king - man + woman ≈ queen」這個著名實驗、近年研究指出該類比有 cherry-picking 質疑、Linzen 2016 / Nissim et al. 2020、是入門啟發、非嚴格 evidence）。Word2Vec 在大型語意理解場景已被 contextual embedding 取代、但在「靜態查表、邊緣計算、輕量 baseline」等情境仍有用、不是完全淘汰。</p>
<h2 id="word-level-vs-contextual-embedding">Word-level vs Contextual Embedding</h2>
<p>Word-level embedding（Word2Vec、GloVe 等）每個字一個固定向量、不考慮 context：</p>
<ul>
<li><code>bank</code> 在「river bank」跟「bank account」中拿到同樣的 embedding。</li>
<li>簡單、可預先計算、查表快。</li>
<li>限制：無法區分多義詞。</li>
</ul>
<p>Contextual embedding（BERT、GPT 等 Transformer-based）的向量隨 context 改變：</p>
<ul>
<li><code>bank</code> 在「river bank」跟「bank account」中拿到不同的向量。</li>
<li>模型每層輸出都可視為一種 contextual embedding、越深越抽象。</li>
<li>缺點：需要跑完整模型、不能預先計算。</li>
</ul>
<p>LLM 內部用的是 contextual embedding。輸入端的 embedding layer 是 word-level（每個 token ID 對應固定向量）、但經過 attention 後變成 context-dependent。</p>
<h2 id="sentence--paragraph-embedding">Sentence / Paragraph Embedding</h2>
<p>句子或段落級別的 embedding 是把整段文字壓成一個向量、用於 retrieval 與分類任務。常見實作：</p>
<table>
  <thead>
      <tr>
          <th>模型</th>
          <th>特性</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Sentence-BERT (SBERT)</td>
          <td>用 siamese BERT 訓練、retrieval 經典</td>
      </tr>
      <tr>
          <td>nomic-embed-text</td>
          <td>開源、Continue.dev 預設</td>
      </tr>
      <tr>
          <td>OpenAI text-embedding-3</td>
          <td>商業 API、品質高</td>
      </tr>
      <tr>
          <td>BGE / E5 系列</td>
          <td>多語言、SOTA 開源</td>
      </tr>
  </tbody>
</table>
<p><a href="/blog/llm/knowledge-cards/embedding-model/" data-link-title="Embedding Model" data-link-desc="把文字轉成向量的模型：用於 codebase 索引與語意搜尋">Embedding 模型</a> 跟 chat model 是不同訓練流程：</p>
<ul>
<li>Chat model 學「下個 token 機率分佈」。</li>
<li>Embedding model 學「整段文字壓成一個向量、用 cosine similarity 衡量語意相似度」。</li>
</ul>
<p>兩者底層架構都是 Transformer、但訓練 objective 不同、得到的向量空間不通用。</p>
<h2 id="向量空間互不相容">向量空間互不相容</h2>
<p>不同 embedding 模型的向量空間互不相容：</p>
<ul>
<li>nomic-embed-text 輸出 768 維向量。</li>
<li>OpenAI text-embedding-3-small 輸出 1536 維向量。</li>
<li>兩者各自的座標軸有獨立意義、不能拿 nomic 的向量跟 OpenAI 的向量算 cosine。</li>
</ul>
<p>實務啟示：</p>
<ol>
<li>換 embedding 模型要重建 vector database。</li>
<li>一個 retrieval 系統用同一個 embedding 模型 throughout、混用會壞。</li>
<li>模型升級時要 backfill 舊資料。</li>
</ol>
<h2 id="embedding-similarity-的失效情境">Embedding similarity 的失效情境</h2>
<p>Embedding similarity 在多數 retrieval / semantic search 場景能用、但有幾類已知失效模式、影響 RAG / <code>@codebase</code> 的回答品質：</p>
<table>
  <thead>
      <tr>
          <th>失效模式</th>
          <th>判讀訊號</th>
          <th>修法</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Anisotropy（向量擠在窄錐）</td>
          <td>隨機 query 對的 cosine score 平均 &gt; 0.7、相對排序失準</td>
          <td>換較強 embedding model、做 mean-centering / whitening 後處理</td>
      </tr>
      <tr>
          <td>否定句被當相似</td>
          <td>「我能買牛奶」跟「我不能買牛奶」cosine 接近</td>
          <td>結構性區分 / 補 BM25 lexical retrieval 取交集、或用 reranker 做最終排序</td>
      </tr>
      <tr>
          <td>Lexical mismatch</td>
          <td>query 用同義詞、retrieval 找不到原文</td>
          <td>加 hybrid retrieval（embedding + BM25）、或在 <a href="/blog/llm/knowledge-cards/query-expansion/" data-link-title="Query Expansion" data-link-desc="RAG 檢索前把一個 query 擴成多個語意變體，增加 coverage，再合併 retrieval 結果">query expansion</a> 做改寫</td>
      </tr>
      <tr>
          <td>長尾稀有詞</td>
          <td>專有名詞 / 縮寫 / domain 術語 retrieval 結果飄</td>
          <td>跑 domain fine-tune embedding、或保留 BM25 作為 backup 排序</td>
      </tr>
      <tr>
          <td>跨語言混合 token</td>
          <td>中英混雜文件查不準</td>
          <td>用多語言 embedding（BGE-m3 / multilingual-e5）取代英文 only embedding</td>
      </tr>
  </tbody>
</table>
<p>實作層級的修法多半是 hybrid retrieval（embedding + BM25 / TF-IDF 各跑一次、合併分數）或加 reranker 做最終排序、純依賴 cosine similarity 風險高的場景值得納入這層。</p>
<h2 id="位置編碼把順序資訊加進-embedding">位置編碼：把順序資訊加進 embedding</h2>
<p>純 embedding layer 沒有「順序資訊」、<code>[cat, dog]</code> 跟 <code>[dog, cat]</code> 的 embedding 序列只是 order 不同的 set。Transformer 用 <a href="/blog/llm/knowledge-cards/positional-encoding/" data-link-title="Positional Encoding" data-link-desc="把 token 位置資訊注入 Transformer 的機制，讓 attention 能分辨順序與距離">positional encoding</a> 把位置資訊加進去。</p>
<p>主流位置編碼方法：</p>
<table>
  <thead>
      <tr>
          <th>方法</th>
          <th>機制</th>
          <th>主要使用模型 / 取捨</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Sinusoidal</td>
          <td>用 sin / cos 不同頻率生成固定位置向量、加進 embedding</td>
          <td>原始 Transformer paper、現已少見、長度外推能力弱</td>
      </tr>
      <tr>
          <td>Learned</td>
          <td>學一個 <code>(max_seq_len, hidden_dim)</code> 的位置矩陣、加進 embedding</td>
          <td>GPT-2 / BERT 系列、被綁死在訓練長度、無法外推</td>
      </tr>
      <tr>
          <td>RoPE</td>
          <td>Rotary Position Embedding、把位置編碼到 Q/K 的旋轉中</td>
          <td>Llama / Gemma / Qwen 主流、長度外推能力佳、實作上是相對位置</td>
      </tr>
      <tr>
          <td>ALiBi</td>
          <td>Attention with Linear Biases、在 attention scores 加位置 bias</td>
          <td>MPT 系列、長度外推極佳、但 LLM 主流仍偏 RoPE</td>
      </tr>
  </tbody>
</table>
<p>RoPE 是 2026 年的主流選擇。詳細展開見 <a href="/blog/llm/03-theoretical-foundations/transformer-architecture/" data-link-title="3.3 Transformer 架構細節" data-link-desc="Decoder-only 結構、Transformer block、positional encoding、layer norm、residual stream">3.3 Transformer 架構</a>。</p>
<h2 id="tied-vs-untied-embedding">Tied vs Untied Embedding</h2>
<p>「Tied embedding」指「input embedding（token → vector）跟 output projection（hidden → logits）共用同一個矩陣」。實作上 input embedding 矩陣 <code>E</code> 的 shape 是 <code>(vocab_size, hidden_dim)</code>、output projection 矩陣的 shape 是 <code>(hidden_dim, vocab_size)</code>；tied 模式直接用 <code>E^T</code>（轉置）當 output projection、省下一份 <code>(vocab_size, hidden_dim)</code> 大小的權重。GPT-2 系列預設 tied、節省參數。</p>
<p>「Untied embedding」是兩者各自獨立、各自訓練。Llama 系列預設 untied、品質略好（兩個矩陣可以各自最佳化）、但 embedding layer 跟 output layer 都要存。</p>
<p>實務上、大模型（30B+）幾乎都採 untied、用較多參數換較高品質；小模型（1B 以下）為了壓縮參數量仍會 tied。</p>
<h2 id="embedding-在-llm-forward-pass-中的位置">Embedding 在 LLM forward pass 中的位置</h2>
<p>LLM 的 forward pass 概略：</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">tokens (整數序列)
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">  ↓ embedding lookup
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">embeddings (向量序列、shape: [seq_len, hidden_dim])
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">  ↓ + positional encoding
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">positioned embeddings
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">  ↓ Transformer block × N
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">final hidden states
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">  ↓ output projection
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">logits (shape: [seq_len, vocab_size])
</span></span><span class="line"><span class="ln">10</span><span class="cl">  ↓ softmax
</span></span><span class="line"><span class="ln">11</span><span class="cl">機率分佈</span></span></code></pre></div><p>每個 Transformer block 內部都對向量做變換、向量維度保持 hidden_dim 不變、只有 input embedding 跟 output projection 在 vocab_size 跟 hidden_dim 之間轉換。</p>
<h2 id="下一章">下一章</h2>
<p>下一章：<a href="/blog/llm/03-theoretical-foundations/attention-mechanism/" data-link-title="3.2 Attention 機制" data-link-desc="Query / Key / Value、scaled dot-product attention、multi-head attention：Transformer 的核心運算">3.2 attention 機制</a>、Transformer 的招牌技術。</p>
]]></content:encoded></item><item><title>4.1 RAG 原理：retrieval + augmentation 模式</title><link>https://tarrragon.github.io/blog/llm/04-applications/rag-principles/</link><pubDate>Mon, 11 May 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/llm/04-applications/rag-principles/</guid><description>&lt;p>&lt;a href="https://tarrragon.github.io/blog/llm/knowledge-cards/rag/" data-link-title="RAG" data-link-desc="Retrieval-Augmented Generation：動態外掛知識給 LLM、繞開模型參數記憶的靜態限制">RAG&lt;/a>（Retrieval-Augmented Generation）的核心是「給 LLM 動態外掛一份知識、讓它在生成時拿這份知識當 context」。它的存在解的是 LLM 「靜態參數記憶」的根本限制：模型訓練完之後權重就凍結、無法存取訓練資料外的事實、無法看到 cutoff 之後發生的事、也無法存取私有資料。&lt;/p>
&lt;p>本章把 RAG 拆成不會隨工具世代消失的部分：retrieval 的本質、chunking 的取捨、失敗模式的分類、跟 fine-tuning / long context 三種路線的比較。LangChain、LlamaIndex、Vector database 選型等具體實作不在本章範圍——這些半年一個版本、教程價值低於壽命。本章寫的是「為什麼 retrieval 會這樣設計、什麼時候會失敗、什麼時候改用其他方案」。&lt;/p>
&lt;h2 id="本章目標">本章目標&lt;/h2>
&lt;p>讀完本章後你能：&lt;/p>
&lt;ol>
&lt;li>解釋為什麼 LLM 需要外掛知識、純靠模型參數記憶解不了什麼問題。&lt;/li>
&lt;li>區分「語意相似」與「字面相似」對 retrieval 的影響、看到 retrieval 結果不理想時、判斷是哪一類失配。&lt;/li>
&lt;li>看到 chunking 參數時、知道背後的 resolution vs context 取捨。&lt;/li>
&lt;li>在「RAG / fine-tuning / long context」三者之間、依任務做合理選擇。&lt;/li>
&lt;/ol>
&lt;h2 id="為什麼模型需要外掛知識">為什麼模型需要外掛知識&lt;/h2>
&lt;p>LLM 的參數記憶是「壓縮過的訓練資料」：權重把預訓練看過的所有文字壓進一個固定大小的數值結構、推論時用這份壓縮表示生成下一個 token。這個結構有三個天然限制：&lt;/p>
&lt;ol>
&lt;li>&lt;strong>訓練 cutoff&lt;/strong>：模型只認識訓練資料截止前的世界、cutoff 之後發生的事完全看不見。Claude 4 cutoff 是 2026/1、2026/5 的新聞模型不知道。&lt;/li>
&lt;li>&lt;strong>私有資料缺席&lt;/strong>：訓練資料是公開來源、私有 codebase、內部文件、個人筆記都不在裡面。再強的模型也不會「知道你 repo 的內部慣例」。&lt;/li>
&lt;li>&lt;strong>長尾事實壓縮損失&lt;/strong>：訓練資料中出現很多次的常識（如 Python 語法）模型記得清楚、出現一兩次的長尾事實（如某個 obscure library 的某個 function）會被壓縮損失。&lt;/li>
&lt;/ol>
&lt;p>RAG 把這三個限制都繞開：retrieval 階段從動態外部 &lt;a href="https://tarrragon.github.io/blog/llm/knowledge-cards/retrieval-source/" data-link-title="Retrieval Source" data-link-desc="RAG 從哪個 corpus、index、tool 或外部系統取回內容，決定來源可信度、freshness、權限與引用責任">retrieval source&lt;/a>（可即時更新、可放私有資料、可保留長尾完整內容）拉出相關片段、augmentation 階段把這些片段塞進 prompt 當 context。模型不需要「知道」這份知識、只需要「讀懂」當下 prompt 裡的這份知識。&lt;/p>
&lt;p>這個結構的根本價值是「把知識從模型權重解耦」。模型負責「語言理解 + 推理」、知識負責「事實儲存 + 動態更新」、兩者各自演化：模型升級不需重建知識庫、知識更新不需重訓模型。具體 retrieval 機制依賴 &lt;a href="https://tarrragon.github.io/blog/llm/knowledge-cards/embedding-model/" data-link-title="Embedding Model" data-link-desc="把文字轉成向量的模型：用於 codebase 索引與語意搜尋">embedding model&lt;/a> 把文字轉成向量、用相似度衡量「相關性」。&lt;/p>
&lt;h2 id="retrieval-的核心問題語意相似-vs-字面相似">Retrieval 的核心問題：語意相似 vs 字面相似&lt;/h2>
&lt;p>Retrieval 解的是「給一個 query、找出相關的 document」這個問題、但「相關」有兩種定義：&lt;/p>
&lt;ul>
&lt;li>&lt;strong>字面相似&lt;/strong>（lexical similarity）：query 跟 document 共用多少 keyword。傳統 search engine 用這套（如 Elasticsearch / OpenSearch 的 BM25 算法、以 keyword 出現頻率加權的傳統檢索演算法、不考慮語意）。&lt;/li>
&lt;li>&lt;strong>語意相似&lt;/strong>（semantic similarity）：query 跟 document 表達的意思接近、即使共用 keyword 少。Embedding-based retrieval 用這套。&lt;/li>
&lt;/ul>
&lt;p>兩種模式的失敗模式恰好互補：&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>場景&lt;/th>
 &lt;th>字面 retrieval&lt;/th>
 &lt;th>語意 retrieval&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>Query 跟 document 用同樣 keyword&lt;/td>
 &lt;td>找得到（強項）&lt;/td>
 &lt;td>也找得到（多數情況）&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Query 用同義詞、document 用另一字&lt;/td>
 &lt;td>找不到&lt;/td>
 &lt;td>找得到（強項）&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>文件用 jargon、query 用通俗描述&lt;/td>
 &lt;td>找不到&lt;/td>
 &lt;td>找得到（強項）&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>兩個 document 字面像但語意不同&lt;/td>
 &lt;td>都找出來（False+）&lt;/td>
 &lt;td>通常能分開（強項）&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>兩個 document 語意一樣但字面差很多&lt;/td>
 &lt;td>找不到一個（False-）&lt;/td>
 &lt;td>都找出來（強項）&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Embedding 模型不熟悉的 domain&lt;/td>
 &lt;td>不受影響&lt;/td>
 &lt;td>表現崩、retrieval 像隨機（弱項）&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>實務上現代 RAG 多半用「hybrid retrieval」：BM25 + embedding 分數加權合併、補單一模式的失敗模式。但理解兩者本質的差異、能解釋為什麼 retrieval 結果有時很準、有時莫名其妙。&lt;/p>
&lt;p>語意 retrieval 還帶來一個容易忽略的限制：&lt;strong>embedding 模型本身有訓練分佈&lt;/strong>。它在 Wikipedia / Common Crawl 風格的文字上表現好、在你的內部 codebase 風格上表現未必好。Domain shift 是 retrieval 失敗的常見根本原因、不是「embedding 不夠強」、是「embedding 沒見過這類資料」。&lt;/p></description><content:encoded><![CDATA[<p><a href="/blog/llm/knowledge-cards/rag/" data-link-title="RAG" data-link-desc="Retrieval-Augmented Generation：動態外掛知識給 LLM、繞開模型參數記憶的靜態限制">RAG</a>（Retrieval-Augmented Generation）的核心是「給 LLM 動態外掛一份知識、讓它在生成時拿這份知識當 context」。它的存在解的是 LLM 「靜態參數記憶」的根本限制：模型訓練完之後權重就凍結、無法存取訓練資料外的事實、無法看到 cutoff 之後發生的事、也無法存取私有資料。</p>
<p>本章把 RAG 拆成不會隨工具世代消失的部分：retrieval 的本質、chunking 的取捨、失敗模式的分類、跟 fine-tuning / long context 三種路線的比較。LangChain、LlamaIndex、Vector database 選型等具體實作不在本章範圍——這些半年一個版本、教程價值低於壽命。本章寫的是「為什麼 retrieval 會這樣設計、什麼時候會失敗、什麼時候改用其他方案」。</p>
<h2 id="本章目標">本章目標</h2>
<p>讀完本章後你能：</p>
<ol>
<li>解釋為什麼 LLM 需要外掛知識、純靠模型參數記憶解不了什麼問題。</li>
<li>區分「語意相似」與「字面相似」對 retrieval 的影響、看到 retrieval 結果不理想時、判斷是哪一類失配。</li>
<li>看到 chunking 參數時、知道背後的 resolution vs context 取捨。</li>
<li>在「RAG / fine-tuning / long context」三者之間、依任務做合理選擇。</li>
</ol>
<h2 id="為什麼模型需要外掛知識">為什麼模型需要外掛知識</h2>
<p>LLM 的參數記憶是「壓縮過的訓練資料」：權重把預訓練看過的所有文字壓進一個固定大小的數值結構、推論時用這份壓縮表示生成下一個 token。這個結構有三個天然限制：</p>
<ol>
<li><strong>訓練 cutoff</strong>：模型只認識訓練資料截止前的世界、cutoff 之後發生的事完全看不見。Claude 4 cutoff 是 2026/1、2026/5 的新聞模型不知道。</li>
<li><strong>私有資料缺席</strong>：訓練資料是公開來源、私有 codebase、內部文件、個人筆記都不在裡面。再強的模型也不會「知道你 repo 的內部慣例」。</li>
<li><strong>長尾事實壓縮損失</strong>：訓練資料中出現很多次的常識（如 Python 語法）模型記得清楚、出現一兩次的長尾事實（如某個 obscure library 的某個 function）會被壓縮損失。</li>
</ol>
<p>RAG 把這三個限制都繞開：retrieval 階段從動態外部 <a href="/blog/llm/knowledge-cards/retrieval-source/" data-link-title="Retrieval Source" data-link-desc="RAG 從哪個 corpus、index、tool 或外部系統取回內容，決定來源可信度、freshness、權限與引用責任">retrieval source</a>（可即時更新、可放私有資料、可保留長尾完整內容）拉出相關片段、augmentation 階段把這些片段塞進 prompt 當 context。模型不需要「知道」這份知識、只需要「讀懂」當下 prompt 裡的這份知識。</p>
<p>這個結構的根本價值是「把知識從模型權重解耦」。模型負責「語言理解 + 推理」、知識負責「事實儲存 + 動態更新」、兩者各自演化：模型升級不需重建知識庫、知識更新不需重訓模型。具體 retrieval 機制依賴 <a href="/blog/llm/knowledge-cards/embedding-model/" data-link-title="Embedding Model" data-link-desc="把文字轉成向量的模型：用於 codebase 索引與語意搜尋">embedding model</a> 把文字轉成向量、用相似度衡量「相關性」。</p>
<h2 id="retrieval-的核心問題語意相似-vs-字面相似">Retrieval 的核心問題：語意相似 vs 字面相似</h2>
<p>Retrieval 解的是「給一個 query、找出相關的 document」這個問題、但「相關」有兩種定義：</p>
<ul>
<li><strong>字面相似</strong>（lexical similarity）：query 跟 document 共用多少 keyword。傳統 search engine 用這套（如 Elasticsearch / OpenSearch 的 BM25 算法、以 keyword 出現頻率加權的傳統檢索演算法、不考慮語意）。</li>
<li><strong>語意相似</strong>（semantic similarity）：query 跟 document 表達的意思接近、即使共用 keyword 少。Embedding-based retrieval 用這套。</li>
</ul>
<p>兩種模式的失敗模式恰好互補：</p>
<table>
  <thead>
      <tr>
          <th>場景</th>
          <th>字面 retrieval</th>
          <th>語意 retrieval</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Query 跟 document 用同樣 keyword</td>
          <td>找得到（強項）</td>
          <td>也找得到（多數情況）</td>
      </tr>
      <tr>
          <td>Query 用同義詞、document 用另一字</td>
          <td>找不到</td>
          <td>找得到（強項）</td>
      </tr>
      <tr>
          <td>文件用 jargon、query 用通俗描述</td>
          <td>找不到</td>
          <td>找得到（強項）</td>
      </tr>
      <tr>
          <td>兩個 document 字面像但語意不同</td>
          <td>都找出來（False+）</td>
          <td>通常能分開（強項）</td>
      </tr>
      <tr>
          <td>兩個 document 語意一樣但字面差很多</td>
          <td>找不到一個（False-）</td>
          <td>都找出來（強項）</td>
      </tr>
      <tr>
          <td>Embedding 模型不熟悉的 domain</td>
          <td>不受影響</td>
          <td>表現崩、retrieval 像隨機（弱項）</td>
      </tr>
  </tbody>
</table>
<p>實務上現代 RAG 多半用「hybrid retrieval」：BM25 + embedding 分數加權合併、補單一模式的失敗模式。但理解兩者本質的差異、能解釋為什麼 retrieval 結果有時很準、有時莫名其妙。</p>
<p>語意 retrieval 還帶來一個容易忽略的限制：<strong>embedding 模型本身有訓練分佈</strong>。它在 Wikipedia / Common Crawl 風格的文字上表現好、在你的內部 codebase 風格上表現未必好。Domain shift 是 retrieval 失敗的常見根本原因、不是「embedding 不夠強」、是「embedding 沒見過這類資料」。</p>
<h2 id="chunking-的本質取捨">Chunking 的本質取捨</h2>
<p>RAG 若把整份文件當 retrieval 單位、document 太長、retrieval 拿到的太粗、實務上要先切成 chunk。Chunk 大小的選擇是 retrieval 設計最關鍵也最容易誤判的決定。</p>
<p>Chunk 太小（如每段 100 token）的失敗模式：</p>
<ul>
<li>每塊資訊不完整、retrieval 拿到的 fragment 無法獨立理解（如「他在第三章提到這個概念」、但「他」「這個概念」需要前文才解得開）。</li>
<li>跨 chunk 的語意關聯被切斷、retrieval 拿到一個 chunk 但相關的補充資訊在下個 chunk。</li>
<li>同一個概念可能切到多個 chunk、retrieval 拿其中一個是不完整論述。</li>
</ul>
<p>Chunk 太大（如每段 2000 token）的失敗模式：</p>
<ul>
<li>Retrieval 精確度低、一個 chunk 包含多個主題、相似度計算被無關內容稀釋。</li>
<li>塞進 prompt 浪費 <a href="/blog/llm/knowledge-cards/token/" data-link-title="Token" data-link-desc="LLM 處理文字時的最小單位：介於字元與單字之間">token</a>、context 利用率差。</li>
<li>重要訊號可能埋在 chunk 中間、被前後 noise 蓋過。</li>
</ul>
<p>「resolution vs context loss」是無法兩全的設計問題：細粒度精確但缺脈絡、粗粒度有脈絡但精度差。不同任務有不同最適點：</p>
<ul>
<li>問答任務（答案是短句）：偏細粒度、500 token 左右常見。</li>
<li>摘要任務（答案需要長段脈絡）：偏粗粒度、1500-2000 token 常見。</li>
<li>Code retrieval：以邏輯單位切（function、class）、不是按 token 數切。</li>
<li>規格 / 法律文件：按章節結構切、保留 hierarchy。</li>
</ul>
<p>Chunking 還有兩個常被忽略的設計維度：</p>
<ul>
<li><strong>Overlap</strong>：相鄰 chunk 之間留 10-20% overlap、避免「重要訊號剛好被切斷」。</li>
<li><strong>語意邊界 vs 字數邊界</strong>：純按字數切會穿過句子或段落中間；按段落 / heading / 邏輯單位切保留語意完整、但實作複雜。</li>
</ul>
<p>寫 code 場景的 retrieval（如 Continue.dev 的 <code>@codebase</code>、即 IDE 內把整個 codebase 當 retrieval 來源的指令）多半按邏輯單位切 code（function、class、import block）、配合 AST 解析、比純文字 chunking 收益高很多。</p>
<h2 id="retrieval-失敗的根本原因">Retrieval 失敗的根本原因</h2>
<p>Retrieval 結果不理想時、根本原因通常落在這幾類：</p>
<h3 id="語意-gap">語意 gap</h3>
<p>Query 跟 document 描述的是同一個東西、但用詞、立場、抽象層級都差很多，這是 <a href="/blog/llm/knowledge-cards/query-document-gap/" data-link-title="Query-Document Gap" data-link-desc="使用者 query 與文件語言在詞彙、形態、抽象層級或領域分佈上的落差，是 RAG retrieval miss 的常見原因">query-document gap</a>。例：query 是「怎麼讓 API 跑快」、document 是「latency optimization techniques」。Embedding 模型訓練得好的話可以對齊、訓練不好或 domain 不熟就 miss。緩解：query rewriting（讓 LLM 把 query 改成更接近 document 的 phrasing）、HyDE（hypothetical document embeddings、用 LLM 生成「假設的答案」、用這個假答案的 embedding 去 retrieval）。</p>
<h3 id="超出訓練分佈">超出訓練分佈</h3>
<p>Embedding 模型對某個 domain 表現崩（如金融術語、醫療 jargon、特殊 codebase 慣例）。判讀訊號：retrieval 結果看起來「隨機」、語意相關性低。緩解：換 domain-specific embedding 模型、或退回 BM25。</p>
<h3 id="chunk-邊界穿過語意單位">Chunk 邊界穿過語意單位</h3>
<p>正確答案被切到兩個 chunk、retrieval 拿到的只是其中半邊。判讀訊號：模型回答不完整或「我看到 X 但不知道 Y」、檢查發現 Y 在相鄰 chunk。緩解：加 overlap、改用語意邊界 chunking。</p>
<h3 id="query-過短缺乏-disambiguation-context">Query 過短缺乏 disambiguation context</h3>
<p>Query 太短、模型不知道使用者真正想要什麼（如 query 「python」可以指語言、shell binary、套件、文件章節）。Retrieval 拿到的可能語意完全錯。緩解：在 retrieval 前讓 LLM expand query、加上對話歷史當 context。</p>
<h3 id="embedding-跟下游-llm-訓練分佈不一致">Embedding 跟下游 LLM 訓練分佈不一致</h3>
<p>Embedding 模型擅長把「相關」拉近、但「相關」的定義可能跟下游 LLM 「能用」的定義不同。例：embedding 把同義詞拉近、但下游 LLM 需要的是「能完整回答 query 的 document」、不是「跟 query 同義」。判讀訊號：retrieval 看起來合理但回答品質差。緩解：retrieval + re-ranker（用較強模型對 retrieval candidates 再排序）。</p>
<p>這五類失敗各有自己的訊號、根本原因不同、緩解策略也不同。Retrieval 出問題時、先用症狀分類、再對應到根因、比「換更大 embedding 模型」這種反射式修法有效得多。</p>
<h2 id="production-retrieval-pipelinehybrid--reranker">Production retrieval pipeline：hybrid + reranker</h2>
<p>實務 production RAG 多不只用單一 embedding-based retrieval、而是「<a href="/blog/llm/knowledge-cards/hybrid-search/" data-link-title="Hybrid Search" data-link-desc="把字面 retrieval（BM25）跟語意 retrieval（embedding）的結果用 RRF 等方法合併、補單一路線的盲點">hybrid search</a> + <a href="/blog/llm/knowledge-cards/reranker/" data-link-title="Reranker" data-link-desc="對 retrieval top-K 結果用 cross-encoder 重新排序的 RAG 第二階段、品質提升顯著但 latency / cost 增加">reranker</a>」兩段式：</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">User query
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">   ↓
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">[Stage 1: Hybrid retrieve top-50]
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">   ├── BM25（字面）retrieve top-25      ← 抓精確 keyword、識別碼、罕見 entity
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">   └── Embedding（語意）retrieve top-25  ← 抓同義詞、jargon、語意相似
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">   ↓ Reciprocal Rank Fusion 合併
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">   top-50 candidates
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">   ↓
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">[Stage 2: Reranker rerank to top-5]
</span></span><span class="line"><span class="ln">10</span><span class="cl">   Cross-encoder 對每對 (query, doc) 算 fine-grained relevance
</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">   top-5 給 LLM</span></span></code></pre></div><p>為什麼兩段式：</p>
<table>
  <thead>
      <tr>
          <th>路線</th>
          <th>強項</th>
          <th>盲點</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>BM25-only</td>
          <td>精確 keyword、識別碼、術語</td>
          <td>語意相似抓不到（同義詞、不同表述）</td>
      </tr>
      <tr>
          <td>Embedding-only</td>
          <td>語意相似強</td>
          <td>罕見 entity、嚴格 keyword 容易漏</td>
      </tr>
      <tr>
          <td>Hybrid（BM25 + embedding）</td>
          <td>互補、覆蓋更廣</td>
          <td>但 top-50 仍有「相關但不精確」</td>
      </tr>
      <tr>
          <td>Hybrid + reranker</td>
          <td>兩段式、最終 top-5 精確度高</td>
          <td>每對 reranker call 慢、需要 cost / latency budget</td>
      </tr>
  </tbody>
</table>
<p>何時不需要 reranker：</p>
<ul>
<li>小語料（&lt; 1000 docs）、embedding 已準</li>
<li>純 keyword 任務、BM25 已準</li>
<li>極低 latency 要求（reranker 加幾百 ms）</li>
</ul>
<p>主流 reranker：Cohere Rerank 3（SaaS）、Jina Reranker v2（OSS）、BGE Reranker（OSS、中文友善）、Voyage rerank-2。詳細選型見 <a href="/blog/llm/knowledge-cards/reranker/" data-link-title="Reranker" data-link-desc="對 retrieval top-K 結果用 cross-encoder 重新排序的 RAG 第二階段、品質提升顯著但 latency / cost 增加">reranker 卡</a>。</p>
<h2 id="chunking-策略對比">Chunking 策略對比</h2>
<p><a href="/blog/llm/knowledge-cards/chunking/" data-link-title="Chunking" data-link-desc="把長文件切成可檢索片段的設計決策：resolution vs context loss 的本質取捨">chunking 卡</a> 講概念、實務有五種主流策略：</p>
<table>
  <thead>
      <tr>
          <th>策略</th>
          <th>機制</th>
          <th>適合</th>
          <th>失敗模式</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Fixed-size</td>
          <td>按 token 數固定切（如每 512 token）</td>
          <td>通用 baseline、簡單</td>
          <td>切壞句子 / 段落邊界、語意斷裂</td>
      </tr>
      <tr>
          <td>Recursive</td>
          <td>按分隔符遞迴切（先段落、再句、再固定大小）</td>
          <td>通用文字、保留段落結構</td>
          <td>仍可能切壞表格 / 程式碼</td>
      </tr>
      <tr>
          <td>Markdown header</td>
          <td>按 markdown 標題切（H1/H2/H3）</td>
          <td>文檔、技術文章、有明確 structure</td>
          <td>標題層級不一致時破</td>
      </tr>
      <tr>
          <td>Code-aware（tree-sitter）</td>
          <td>按 AST 切（function / class 邊界）</td>
          <td>程式碼 retrieval</td>
          <td>跨檔案邏輯抓不到</td>
      </tr>
      <tr>
          <td>Semantic</td>
          <td>用 embedding 判段落語意邊界、切在語意斷點</td>
          <td>知識文章、長 narrative</td>
          <td>慢、需要 pre-process embedding</td>
      </tr>
  </tbody>
</table>
<p>判讀流程：</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">內容類型？
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">├── 純文字 / 文章       → Recursive 或 Semantic
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">├── Markdown 文檔       → Markdown header（fallback recursive）
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">├── 程式碼              → Code-aware（tree-sitter）
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">├── 混合（文章 + code） → Markdown header 主、code block 用 tree-sitter
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">└── PDF                 → 先轉 Markdown 再用 Markdown header
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">Chunk 大小？
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">├── 一般 RAG            → 512-1024 token、overlap 50-100 token
</span></span><span class="line"><span class="ln">10</span><span class="cl">├── 短回答 / 精確匹配  → 256-512 token、更精確
</span></span><span class="line"><span class="ln">11</span><span class="cl">└── 整段理解 / 長 narrative → 1024-2048 token、配合 long context model</span></span></code></pre></div><p>實務常見錯誤：</p>
<ol>
<li><strong>拿 raw PDF 直接 chunking</strong>：PDF 結構亂、應該先轉 markdown</li>
<li><strong>過大 chunk 套小 context embedding</strong>：bge-large context limit 512、塞 2048 chunk 直接截斷</li>
<li><strong>不加 overlap</strong>：句子被切斷、retrieval 漏前後文</li>
<li><strong>混合語料用同樣 chunking</strong>：technical doc + casual blog + code 一視同仁、品質都差</li>
</ol>
<h2 id="rag-vs-fine-tuning-vs-long-context">RAG vs Fine-tuning vs Long Context</h2>
<p>「讓模型知道新東西」有三條路、解的問題層級不同：</p>
<table>
  <thead>
      <tr>
          <th>路線</th>
          <th>機制</th>
          <th>適合場景</th>
          <th>不適合場景</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>RAG</td>
          <td>動態外掛知識、prompt 時 retrieval</td>
          <td>動態更新、知識量大、需要 traceable</td>
          <td>需要 holistic 理解、知識高度結構化</td>
      </tr>
      <tr>
          <td>Fine-tuning</td>
          <td>改變模型權重、教新行為 / 領域知識</td>
          <td>風格 / 領域特化、有專屬 training data</td>
          <td>知識常變、訓練資料少</td>
      </tr>
      <tr>
          <td>Long context</td>
          <td>整份知識直接塞 prompt</td>
          <td>知識量小（&lt; context 上限）、單次任務</td>
          <td>知識重複用（每次塞 cost 高）</td>
      </tr>
  </tbody>
</table>
<p>三者不互斥、實際應用常組合使用：fine-tune 模型懂 domain jargon、RAG 拉動態知識、long context 在單一任務塞完整脈絡。</p>
<p>判讀「該用哪一條」的核心問題：</p>
<ul>
<li>知識會不會變？常變 → RAG。穩定 → fine-tune 或 long context。</li>
<li>知識量多大？小（&lt; 100K tokens、塞得進 <a href="/blog/llm/knowledge-cards/context-window/" data-link-title="Context Window" data-link-desc="模型一次能處理的最大 token 數量：prompt 加生成的總和上限">context window</a>）→ long context。大 → RAG。</li>
<li>需要 traceable（知道答案來源）？是 → RAG（每個 chunk 有 source）。否 → fine-tune 也可。</li>
<li>是行為 / 風格還是事實？行為 → fine-tune（教模型「該怎麼回應」）。事實 → RAG（教模型「該知道什麼」）。</li>
</ul>
<p>寫 code 場景：codebase 變得快、量大、需要 traceable（要知道參考的是哪個 file）——RAG 是預設選擇。Fine-tune 在「想讓模型懂特定 codebase 風格 / 慣例」時補上、但在 codebase 變動頻繁的多數場景成本壓過收益；少數穩定大型 codebase 且風格規範強的情境（如金融 / 醫療 SDK）才值得評估 fine-tune。</p>
<h2 id="何時不適合-rag">何時不適合 RAG</h2>
<p>RAG 適用面有邊界、下列情境改用其他方案更划算：</p>
<ul>
<li><strong>需要 holistic 理解整份文件</strong>：如改寫整篇文章的風格、跨段邏輯重組。Retrieval 拿到的是片段、看不到整體。改用 long context 把整份塞進 prompt、或先讓 LLM summarize 再對 summary 操作。</li>
<li><strong>知識是高度結構化資料</strong>：如使用者資料庫、產品目錄。直接用 SQL query 比 embedding retrieval 精確得多。RAG 變成繞遠路。</li>
<li><strong>知識量小、每次都會用到</strong>：如系統 prompt 的角色設定、不變的規則。直接寫進 system prompt 比每次 retrieval 簡單。</li>
<li><strong><a href="/blog/llm/knowledge-cards/retrieval-cost/" data-link-title="Retrieval Cost" data-link-desc="RAG 檢索帶來的 latency、token、embedding、reranker、LLM call 與維護成本，用來判斷增強是否划算">Retrieval cost</a> 高於 long context</strong>：知識量壓過 context 但壓力不大（如 50K tokens）、retrieval pipeline 維護成本可能高於直接塞長 context。值不值得做 RAG 看 query 頻率：偶爾用就 long context、高頻用才值得建 retrieval。</li>
<li><strong>Latency 敏感場景</strong>：RAG 加一輪 retrieval、<a href="/blog/llm/knowledge-cards/ttft/" data-link-title="TTFT" data-link-desc="Time To First Token：送出 prompt 到第一個 token 出現的等待時間">TTFT</a> 變長。即時補完場景可能受不了。</li>
</ul>
<p>判讀「該不該做 RAG」的反射：先問「不做 RAG 會怎樣」、再評估 RAG 的維護成本。RAG 不是免費的——需要 ingestion pipeline、embedding 服務、vector database、retrieval logic、re-ranker、評估系統。判讀 overengineering 的訊號：查詢量 &lt; 100/day、文件 &lt; 1000 份、變動頻率 &lt; 月一次、這類規模通常 long context + 簡單檔案讀取已足夠；超過這個量級才值得建完整 RAG stack。</p>
<h2 id="何時過時--何時不過時">何時過時 / 何時不過時</h2>
<p><strong>不會過時的部分</strong>：</p>
<ul>
<li>Retrieval + augmentation 的二段式結構：retrieve 找相關內容、augment 塞進 prompt。這個 framing 跟具體實作無關。</li>
<li>語意 vs 字面相似的差異跟互補性。</li>
<li>Chunking 的 resolution vs context loss 取捨。</li>
<li>五類 retrieval 失敗模式的分類。</li>
<li>RAG / fine-tuning / long context 三條路線的判讀框架。</li>
</ul>
<p><strong>會變的部分</strong>：</p>
<ul>
<li>具體 embedding 模型（nomic-embed、bge、mxbai 等會持續更新）。</li>
<li>Vector database 選型（Pinecone / Weaviate / Chroma / pgvector 等市場格局會變）。Storage layer 的工程判讀（規模驅動升級、dependency 約束、index 生命週期）見 <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>。</li>
<li>Framework API（LangChain / LlamaIndex 的具體呼叫方式半年一變）。</li>
<li>最佳 chunk size 數字（隨 embedding 模型跟 LLM context 能力演化）。</li>
<li>Hybrid retrieval / re-ranker 的具體實作（會持續優化）。</li>
</ul>
<p>當這篇文章「過時」的時候、過時的是參考數字跟工具選型；retrieval 本質、失敗模式、跟其他路線的取捨判讀仍會成立。看到新 RAG 工具時、回到本章的 framing：它解的是哪類問題、它的 chunking 策略是什麼、它如何處理五類失敗模式——能很快判斷它解的問題跟你的場景是否對齊。</p>
<h2 id="下一章">下一章</h2>
<p>本章預設「有 backend」、沒 backend 的場景（個人 blog、docs site 加 RAG）的 deployment 取捨見 <a href="/blog/llm/04-applications/static-and-serverless-rag-deployment/" data-link-title="4.16 靜態 / serverless RAG deployment：架構選擇與資安取捨" data-link-desc="沒 backend 的場景怎麼做 RAG：四種 deployment 方案、API key 暴露問題、CORS / abuse / 第三方信任、跟模組六的 routing">4.16 靜態 / serverless RAG deployment</a>。</p>
<p>下一章：<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>、看 vanilla RAG 不夠用時的下一層工具箱（query rewriting / HyDE / multi-step / <a href="/blog/llm/knowledge-cards/context-packing/" data-link-title="Context Packing" data-link-desc="RAG retrieve 後把 chunks 去重、排序、壓縮、標來源，再塞進 prompt 的組裝決策">context packing</a>）。把 LLM 從讀資料延伸到對外部世界做事見 <a href="/blog/llm/04-applications/tool-use-principles/" data-link-title="4.3 Tool use 原理：LLM 跟外部世界互動" data-link-desc="Structured output 是 LLM 跨入工程系統的橋、function calling 取捨、為什麼本地小模型 tool use 表現崩潰">4.3 Tool use 原理</a>。Retrieval 把外部內容引入 prompt 本身就是攻擊面（同個機制讓 codebase 內容、外部文件、剪貼簿都能間接影響模型輸出）、IDE 場景的 prompt injection 判讀見 <a href="/blog/llm/06-security/prompt-injection-in-ide/" data-link-title="6.3 IDE 場景的 prompt injection" data-link-desc="個人 dev 場景下 IDE 寫 code 工作流的 prompt injection：codebase 內容、外部文件、剪貼簿作為攻擊面、跟雲端 LLM 場景的差異">6.3 IDE 場景的 prompt injection</a>。</p>
]]></content:encoded></item><item><title>Hands-on：用 blog content 當 corpus 跑 RAG</title><link>https://tarrragon.github.io/blog/llm/01-local-llm-services/hands-on/rag-demo/</link><pubDate>Tue, 12 May 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/llm/01-local-llm-services/hands-on/rag-demo/</guid><description>&lt;p>本篇把 &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> 的概念落到一個能跑的最小實作：用本 blog 的 &lt;code>content/llm/&lt;/code> 當 corpus、Ollama 的 &lt;code>nomic-embed-text&lt;/code> 做 embedding、&lt;code>gemma3:1b&lt;/code> 做生成、兩個 Python 檔案完成 ingest + query 整條鏈。實作刻意保持 minimal、為的是把每一段都看清楚、跟原理對應。&lt;/p>
&lt;blockquote>
&lt;p>&lt;strong>驗證日期&lt;/strong>：2026-05-12
&lt;strong>環境&lt;/strong>：macOS、Ollama 0.23.2、&lt;code>nomic-embed-text&lt;/code>、&lt;code>gemma3:1b&lt;/code>
&lt;strong>Corpus&lt;/strong>：本 blog 的 &lt;code>content/llm/&lt;/code>、71 個 markdown 檔
&lt;strong>結果&lt;/strong>：22 秒索引 463 個 chunk、retrieval 命中率好、generation 受 1B 模型能力限制——剛好示範「retrieval 跟 generation 各自會失敗」的兩段式失敗模式&lt;/p>&lt;/blockquote>
&lt;h2 id="前置設定">前置設定&lt;/h2>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>項目&lt;/th>
 &lt;th>來源 / 指令&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>Ollama 跑著&lt;/td>
 &lt;td>見 &lt;a href="https://tarrragon.github.io/blog/llm/01-local-llm-services/hands-on/ollama-setup/" data-link-title="Hands-on：安裝 Ollama &amp;#43; 拉第一個 Gemma 模型" data-link-desc="brew install ollama、launchd service、ollama pull、curl 驗證 OpenAI 相容 API">Ollama 安裝&lt;/a>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Embedding 模型&lt;/td>
 &lt;td>&lt;code>ollama pull nomic-embed-text&lt;/code>（274 MB、768 維）&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Chat 模型&lt;/td>
 &lt;td>&lt;code>ollama pull gemma3:1b&lt;/code>（815 MB）。能力弱但夠驗證流程；上 31B 級才能拿到「真正能用」的 answer 品質&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Python&lt;/td>
 &lt;td>3.11+（標準 lib &lt;code>urllib&lt;/code> / &lt;code>pickle&lt;/code> 即可、不需要外部依賴）&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;h3 id="驗證-embedding-api-可用">驗證 embedding API 可用&lt;/h3>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">curl -s http://localhost:11434/api/embeddings &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">&lt;span class="se">&lt;/span> -d &lt;span class="s1">&amp;#39;{&amp;#34;model&amp;#34;:&amp;#34;nomic-embed-text&amp;#34;,&amp;#34;prompt&amp;#34;:&amp;#34;hello world&amp;#34;}&amp;#39;&lt;/span> &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl">&lt;span class="se">&lt;/span> &lt;span class="p">|&lt;/span> python3 -c &lt;span class="s2">&amp;#34;import json,sys; r=json.load(sys.stdin); print(&amp;#39;dim:&amp;#39;, len(r[&amp;#39;embedding&amp;#39;]))&amp;#34;&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>逐項說明：&lt;/p>
&lt;ul>
&lt;li>&lt;code>curl -s&lt;/code>：&lt;code>-s&lt;/code> 是 silent 模式、不顯示下載進度條（不然會混進 stdout、後面 python parse 會炸）。&lt;/li>
&lt;li>&lt;code>http://localhost:11434/api/embeddings&lt;/code>：用 Ollama &lt;strong>原生&lt;/strong> embedding endpoint。也有 &lt;code>/v1/embeddings&lt;/code>（OpenAI 相容）、但原生回應結構較簡（直接 &lt;code>{&amp;quot;embedding&amp;quot;: [...]}&lt;/code>、不是 OpenAI 那種 &lt;code>{&amp;quot;data&amp;quot;: [{&amp;quot;embedding&amp;quot;: [...]}]}&lt;/code> 巢狀）。本 demo 用原生、parse 更直接。&lt;/li>
&lt;li>&lt;code>-d '{&amp;quot;model&amp;quot;:&amp;quot;...&amp;quot;,&amp;quot;prompt&amp;quot;:&amp;quot;...&amp;quot;}'&lt;/code>：JSON payload。&lt;code>model&lt;/code> 是 Ollama tag、&lt;code>prompt&lt;/code> 是要 embed 的文字。&lt;/li>
&lt;li>&lt;code>python3 -c &amp;quot;...&amp;quot;&lt;/code>：stdin 接 curl 輸出、parse JSON、印 embedding 長度。&lt;/li>
&lt;li>&lt;strong>為什麼測 &lt;code>dim: 768&lt;/code>&lt;/strong>：&lt;code>nomic-embed-text&lt;/code> 模型架構決定 embedding 維度是 768。每次 embed 任何文字都會回固定 768 維向量、是 retrieval 的基本資料形狀。看到 &lt;code>dim: 768&lt;/code> 表示：API 通了、模型載入了、輸出形狀對。&lt;/li>
&lt;/ul>
&lt;h2 id="設計取捨">設計取捨&lt;/h2>
&lt;p>實作前先對齊 &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> 提的設計取捨、決定每段怎麼做：&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>取捨點&lt;/th>
 &lt;th>本 demo 的選擇&lt;/th>
 &lt;th>Trade-off&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>Chunking 粒度&lt;/td>
 &lt;td>段落感知 + 軟 token cap（~400 token）&lt;/td>
 &lt;td>簡單、保留段落邊界；不做語意 chunking&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Embedding 模型&lt;/td>
 &lt;td>&lt;code>nomic-embed-text&lt;/code>（768 維）&lt;/td>
 &lt;td>主流、Ollama 內建、英文為主；中文混合場景仍可運作&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>向量儲存&lt;/td>
 &lt;td>Python pickle 檔&lt;/td>
 &lt;td>463 chunks 用 in-memory 完全夠；何時該換見 &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;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Retrieval&lt;/td>
 &lt;td>Cosine similarity、top-K&lt;/td>
 &lt;td>無 hybrid、無 re-ranker；夠驗證、品質受 embedding 限制&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Generation&lt;/td>
 &lt;td>&lt;code>gemma3:1b&lt;/code> 純 Ollama OpenAI 相容 API&lt;/td>
 &lt;td>1B 模型能力弱、會編造；用來示範 retrieval 跟 generation 兩段分離&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>這些選擇都對應到 4.0 章節的「會變的部分」清單——可預期半年後 embedding 模型有新選擇、chunking 有更好策略、re-ranker 變主流。但骨架（retrieval + augmentation 兩段式）不變。&lt;/p></description><content:encoded><![CDATA[<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> 的概念落到一個能跑的最小實作：用本 blog 的 <code>content/llm/</code> 當 corpus、Ollama 的 <code>nomic-embed-text</code> 做 embedding、<code>gemma3:1b</code> 做生成、兩個 Python 檔案完成 ingest + query 整條鏈。實作刻意保持 minimal、為的是把每一段都看清楚、跟原理對應。</p>
<blockquote>
<p><strong>驗證日期</strong>：2026-05-12
<strong>環境</strong>：macOS、Ollama 0.23.2、<code>nomic-embed-text</code>、<code>gemma3:1b</code>
<strong>Corpus</strong>：本 blog 的 <code>content/llm/</code>、71 個 markdown 檔
<strong>結果</strong>：22 秒索引 463 個 chunk、retrieval 命中率好、generation 受 1B 模型能力限制——剛好示範「retrieval 跟 generation 各自會失敗」的兩段式失敗模式</p></blockquote>
<h2 id="前置設定">前置設定</h2>
<table>
  <thead>
      <tr>
          <th>項目</th>
          <th>來源 / 指令</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Ollama 跑著</td>
          <td>見 <a href="/blog/llm/01-local-llm-services/hands-on/ollama-setup/" data-link-title="Hands-on：安裝 Ollama &#43; 拉第一個 Gemma 模型" data-link-desc="brew install ollama、launchd service、ollama pull、curl 驗證 OpenAI 相容 API">Ollama 安裝</a></td>
      </tr>
      <tr>
          <td>Embedding 模型</td>
          <td><code>ollama pull nomic-embed-text</code>（274 MB、768 維）</td>
      </tr>
      <tr>
          <td>Chat 模型</td>
          <td><code>ollama pull gemma3:1b</code>（815 MB）。能力弱但夠驗證流程；上 31B 級才能拿到「真正能用」的 answer 品質</td>
      </tr>
      <tr>
          <td>Python</td>
          <td>3.11+（標準 lib <code>urllib</code> / <code>pickle</code> 即可、不需要外部依賴）</td>
      </tr>
  </tbody>
</table>
<h3 id="驗證-embedding-api-可用">驗證 embedding API 可用</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">curl -s http://localhost:11434/api/embeddings <span class="se">\
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="se"></span>  -d <span class="s1">&#39;{&#34;model&#34;:&#34;nomic-embed-text&#34;,&#34;prompt&#34;:&#34;hello world&#34;}&#39;</span> <span class="se">\
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="se"></span>  <span class="p">|</span> python3 -c <span class="s2">&#34;import json,sys; r=json.load(sys.stdin); print(&#39;dim:&#39;, len(r[&#39;embedding&#39;]))&#34;</span></span></span></code></pre></div><p>逐項說明：</p>
<ul>
<li><code>curl -s</code>：<code>-s</code> 是 silent 模式、不顯示下載進度條（不然會混進 stdout、後面 python parse 會炸）。</li>
<li><code>http://localhost:11434/api/embeddings</code>：用 Ollama <strong>原生</strong> embedding endpoint。也有 <code>/v1/embeddings</code>（OpenAI 相容）、但原生回應結構較簡（直接 <code>{&quot;embedding&quot;: [...]}</code>、不是 OpenAI 那種 <code>{&quot;data&quot;: [{&quot;embedding&quot;: [...]}]}</code> 巢狀）。本 demo 用原生、parse 更直接。</li>
<li><code>-d '{&quot;model&quot;:&quot;...&quot;,&quot;prompt&quot;:&quot;...&quot;}'</code>：JSON payload。<code>model</code> 是 Ollama tag、<code>prompt</code> 是要 embed 的文字。</li>
<li><code>python3 -c &quot;...&quot;</code>：stdin 接 curl 輸出、parse JSON、印 embedding 長度。</li>
<li><strong>為什麼測 <code>dim: 768</code></strong>：<code>nomic-embed-text</code> 模型架構決定 embedding 維度是 768。每次 embed 任何文字都會回固定 768 維向量、是 retrieval 的基本資料形狀。看到 <code>dim: 768</code> 表示：API 通了、模型載入了、輸出形狀對。</li>
</ul>
<h2 id="設計取捨">設計取捨</h2>
<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> 提的設計取捨、決定每段怎麼做：</p>
<table>
  <thead>
      <tr>
          <th>取捨點</th>
          <th>本 demo 的選擇</th>
          <th>Trade-off</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Chunking 粒度</td>
          <td>段落感知 + 軟 token cap（~400 token）</td>
          <td>簡單、保留段落邊界；不做語意 chunking</td>
      </tr>
      <tr>
          <td>Embedding 模型</td>
          <td><code>nomic-embed-text</code>（768 維）</td>
          <td>主流、Ollama 內建、英文為主；中文混合場景仍可運作</td>
      </tr>
      <tr>
          <td>向量儲存</td>
          <td>Python pickle 檔</td>
          <td>463 chunks 用 in-memory 完全夠；何時該換見 <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></td>
      </tr>
      <tr>
          <td>Retrieval</td>
          <td>Cosine similarity、top-K</td>
          <td>無 hybrid、無 re-ranker；夠驗證、品質受 embedding 限制</td>
      </tr>
      <tr>
          <td>Generation</td>
          <td><code>gemma3:1b</code> 純 Ollama OpenAI 相容 API</td>
          <td>1B 模型能力弱、會編造；用來示範 retrieval 跟 generation 兩段分離</td>
      </tr>
  </tbody>
</table>
<p>這些選擇都對應到 4.0 章節的「會變的部分」清單——可預期半年後 embedding 模型有新選擇、chunking 有更好策略、re-ranker 變主流。但骨架（retrieval + augmentation 兩段式）不變。</p>
<h2 id="ingest把-corpus-變索引">Ingest：把 corpus 變索引</h2>
<p>完整檔案：<code>scripts/rag-demo/ingest.py</code>（本 repo 下）。三段 function：切 chunk、embed、走訪 + 持久化。</p>
<h3 id="1-slice_markdown段落感知的-chunk-切割">1. <code>slice_markdown</code>：段落感知的 chunk 切割</h3>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="k">def</span> <span class="nf">slice_markdown</span><span class="p">(</span><span class="n">text</span><span class="p">:</span> <span class="nb">str</span><span class="p">,</span> <span class="n">soft_token_cap</span><span class="p">:</span> <span class="nb">int</span> <span class="o">=</span> <span class="mi">400</span><span class="p">)</span> <span class="o">-&gt;</span> <span class="nb">list</span><span class="p">[</span><span class="nb">str</span><span class="p">]:</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">    <span class="n">paragraphs</span> <span class="o">=</span> <span class="p">[</span><span class="n">p</span><span class="o">.</span><span class="n">strip</span><span class="p">()</span> <span class="k">for</span> <span class="n">p</span> <span class="ow">in</span> <span class="n">re</span><span class="o">.</span><span class="n">split</span><span class="p">(</span><span class="sa">r</span><span class="s2">&#34;\n\s*\n&#34;</span><span class="p">,</span> <span class="n">text</span><span class="p">)</span> <span class="k">if</span> <span class="n">p</span><span class="o">.</span><span class="n">strip</span><span class="p">()]</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">    <span class="n">chunks</span> <span class="o">=</span> <span class="p">[]</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">    <span class="n">buf</span><span class="p">,</span> <span class="n">buf_len</span> <span class="o">=</span> <span class="p">[],</span> <span class="mi">0</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">    <span class="k">for</span> <span class="n">p</span> <span class="ow">in</span> <span class="n">paragraphs</span><span class="p">:</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">        <span class="n">plen</span> <span class="o">=</span> <span class="nb">len</span><span class="p">(</span><span class="n">p</span><span class="p">)</span> <span class="o">/</span> <span class="mi">2</span>  <span class="c1"># char-count / 2 ≈ token (CJK + English heuristic)</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">        <span class="k">if</span> <span class="n">buf</span> <span class="ow">and</span> <span class="n">buf_len</span> <span class="o">+</span> <span class="n">plen</span> <span class="o">&gt;</span> <span class="n">soft_token_cap</span><span class="p">:</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">            <span class="n">chunks</span><span class="o">.</span><span class="n">append</span><span class="p">(</span><span class="s2">&#34;</span><span class="se">\n\n</span><span class="s2">&#34;</span><span class="o">.</span><span class="n">join</span><span class="p">(</span><span class="n">buf</span><span class="p">))</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">            <span class="n">buf</span><span class="p">,</span> <span class="n">buf_len</span> <span class="o">=</span> <span class="p">[],</span> <span class="mi">0</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">        <span class="n">buf</span><span class="o">.</span><span class="n">append</span><span class="p">(</span><span class="n">p</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">        <span class="n">buf_len</span> <span class="o">+=</span> <span class="n">plen</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl">    <span class="k">if</span> <span class="n">buf</span><span class="p">:</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl">        <span class="n">chunks</span><span class="o">.</span><span class="n">append</span><span class="p">(</span><span class="s2">&#34;</span><span class="se">\n\n</span><span class="s2">&#34;</span><span class="o">.</span><span class="n">join</span><span class="p">(</span><span class="n">buf</span><span class="p">))</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl">    <span class="k">return</span> <span class="n">chunks</span></span></span></code></pre></div><p><strong>每段做什麼</strong>：</p>
<ol>
<li><strong><code>re.split(r&quot;\n\s*\n&quot;, text)</code></strong>：用「空白行」當分隔符切段落。<code>\n\s*\n</code> 比 <code>\n\n</code> 寬一點、允許中間有 whitespace（空白、tab）。Markdown 段落的標準分隔是空白行、這個 regex 捕捉所有段落邊界。</li>
<li><strong><code>[p.strip() for ... if p.strip()]</code></strong>：每段去除前後空白、過濾掉純空段落。</li>
<li><strong><code>buf, buf_len = [], 0</code></strong>：累積一個正在構建的 chunk。<code>buf</code> 是段落 list、<code>buf_len</code> 是該 chunk 的 token 累計估算。</li>
<li><strong><code>plen = len(p) / 2</code></strong>：估算這段的 token 數。</li>
<li><strong><code>if buf and buf_len + plen &gt; soft_token_cap</code></strong>：「greedy pack」邏輯——如果加上這段就會超過 cap、把目前 buffer flush 成一個 chunk、再開新 buffer 裝這段。</li>
<li><strong><code>if buf: chunks.append(...)</code></strong>：迴圈結束後、最後一個 buffer 還沒 flush、補上。</li>
</ol>
<p><strong>為什麼這樣設計</strong>：</p>
<ul>
<li><strong>為什麼 paragraph-aware、不是固定 token cap</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> 提的 chunking 設計取捨——固定 token cap 容易切過句子或段落中間、語意被截斷。Paragraph-aware 切在自然邊界、保留段落內語意完整。</li>
<li><strong>為什麼 <code>soft</code> token cap（軟限制）而不是硬切</strong>：硬切會把一個 800-token 段落切成兩半；軟切讓「目前 chunk + 下一段超過 cap」時 flush 目前 chunk、下一段獨立成新 chunk（即使超過 cap 也保留段落完整）。代價：個別 chunk 可能超過 cap、retrieval 拿到的塊較大、但內容完整。</li>
<li><strong>為什麼 <code>len(p) / 2</code> 估 token</strong>：英文約 4 字元 / token、中文約 1.5 字元 / token、混合平均 / 2 在兩種場景都合理。要精確用 tokenizer（如 <code>tiktoken</code>）、但 demo 不需要——這個 heuristic 在 ±20% 內、夠用來做 chunking 決策。</li>
<li><strong>為什麼 <code>\n\n</code>.join(buf)`</strong>：flush 成 chunk 時、段落間保留空白行分隔、讀者看到 chunk 仍是合法 markdown 結構、不是平鋪文字。</li>
</ul>
<h3 id="2-embed呼叫-ollama-embedding-api">2. <code>embed</code>：呼叫 Ollama embedding API</h3>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="ln">1</span><span class="cl"><span class="k">def</span> <span class="nf">embed</span><span class="p">(</span><span class="n">text</span><span class="p">:</span> <span class="nb">str</span><span class="p">)</span> <span class="o">-&gt;</span> <span class="nb">list</span><span class="p">[</span><span class="nb">float</span><span class="p">]:</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">    <span class="n">payload</span> <span class="o">=</span> <span class="n">json</span><span class="o">.</span><span class="n">dumps</span><span class="p">({</span><span class="s2">&#34;model&#34;</span><span class="p">:</span> <span class="s2">&#34;nomic-embed-text&#34;</span><span class="p">,</span> <span class="s2">&#34;prompt&#34;</span><span class="p">:</span> <span class="n">text</span><span class="p">})</span><span class="o">.</span><span class="n">encode</span><span class="p">()</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">    <span class="n">req</span> <span class="o">=</span> <span class="n">urllib</span><span class="o">.</span><span class="n">request</span><span class="o">.</span><span class="n">Request</span><span class="p">(</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">        <span class="s2">&#34;http://localhost:11434/api/embeddings&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">        <span class="n">data</span><span class="o">=</span><span class="n">payload</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl">        <span class="n">headers</span><span class="o">=</span><span class="p">{</span><span class="s2">&#34;Content-Type&#34;</span><span class="p">:</span> <span class="s2">&#34;application/json&#34;</span><span class="p">},</span>
</span></span><span class="line"><span class="ln">7</span><span class="cl">    <span class="p">)</span>
</span></span><span class="line"><span class="ln">8</span><span class="cl">    <span class="k">with</span> <span class="n">urllib</span><span class="o">.</span><span class="n">request</span><span class="o">.</span><span class="n">urlopen</span><span class="p">(</span><span class="n">req</span><span class="p">,</span> <span class="n">timeout</span><span class="o">=</span><span class="mi">60</span><span class="p">)</span> <span class="k">as</span> <span class="n">resp</span><span class="p">:</span>
</span></span><span class="line"><span class="ln">9</span><span class="cl">        <span class="k">return</span> <span class="n">json</span><span class="o">.</span><span class="n">loads</span><span class="p">(</span><span class="n">resp</span><span class="o">.</span><span class="n">read</span><span class="p">())[</span><span class="s2">&#34;embedding&#34;</span><span class="p">]</span></span></span></code></pre></div><p><strong>每行做什麼</strong>：</p>
<ol>
<li><strong><code>payload = json.dumps(...).encode()</code></strong>：把 dict 轉成 JSON 字串、再 encode 成 bytes。HTTP body 必須是 bytes、不能直接傳 str。</li>
<li><strong><code>urllib.request.Request(...)</code></strong>：建立 HTTP request 物件。沒寫 <code>method</code> 預設是 GET、但有 <code>data</code> 參數會自動變 POST。</li>
<li><strong><code>headers={&quot;Content-Type&quot;: &quot;application/json&quot;}</code></strong>：告訴 server payload 是 JSON。少了這個、Ollama 可能 parse 不出 body。</li>
<li><strong><code>urlopen(req, timeout=60)</code></strong>：發送 request、<code>timeout=60</code> 是 socket-level timeout（連線 + 讀取總共最多 60 秒）。</li>
<li><strong><code>json.loads(resp.read())[&quot;embedding&quot;]</code></strong>：讀回應 body、parse JSON、取 <code>embedding</code> 欄位（768 維 list of float）。</li>
</ol>
<p><strong>為什麼這樣設計</strong>：</p>
<ul>
<li><strong>為什麼用 stdlib <code>urllib</code> 而不是 <code>requests</code></strong>：完全沒有外部 dependency、<code>urllib</code> 是 Python stdlib 內建。<code>requests</code> 較友善但要 <code>pip install</code>、本 demo 想 minimal。</li>
<li><strong>為什麼 timeout=60</strong>：embed 一段文字通常 &lt; 200ms、60 秒夠 buffer 意外（首次 model 載入記憶體可能 5-10 秒）。設無限會在 Ollama 掛掉時整個 script hang。</li>
<li><strong>為什麼 <code>/api/embeddings</code>、不是 <code>/v1/embeddings</code></strong>：兩者都可。原生 endpoint 回應結構平、parse 直接（<code>r[&quot;embedding&quot;]</code>）；OpenAI 相容回應較巢狀（<code>r[&quot;data&quot;][0][&quot;embedding&quot;]</code>）。對 demo、寫法簡單較重要。</li>
</ul>
<h3 id="3-走訪--持久化">3. 走訪 + 持久化</h3>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="n">md_files</span> <span class="o">=</span> <span class="nb">sorted</span><span class="p">(</span><span class="n">args</span><span class="o">.</span><span class="n">content_root</span><span class="o">.</span><span class="n">rglob</span><span class="p">(</span><span class="s2">&#34;*.md&#34;</span><span class="p">))</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="n">records</span> <span class="o">=</span> <span class="p">[]</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl"><span class="k">for</span> <span class="n">md</span> <span class="ow">in</span> <span class="n">md_files</span><span class="p">:</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">    <span class="n">text</span> <span class="o">=</span> <span class="n">md</span><span class="o">.</span><span class="n">read_text</span><span class="p">(</span><span class="n">encoding</span><span class="o">=</span><span class="s2">&#34;utf-8&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">    <span class="n">text</span> <span class="o">=</span> <span class="n">re</span><span class="o">.</span><span class="n">sub</span><span class="p">(</span><span class="sa">r</span><span class="s2">&#34;^---\n.*?\n---\n&#34;</span><span class="p">,</span> <span class="s2">&#34;&#34;</span><span class="p">,</span> <span class="n">text</span><span class="p">,</span> <span class="n">count</span><span class="o">=</span><span class="mi">1</span><span class="p">,</span> <span class="n">flags</span><span class="o">=</span><span class="n">re</span><span class="o">.</span><span class="n">DOTALL</span><span class="p">)</span>  <span class="c1"># 去掉 frontmatter</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">    <span class="n">chunks</span> <span class="o">=</span> <span class="n">slice_markdown</span><span class="p">(</span><span class="n">text</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">    <span class="k">for</span> <span class="n">j</span><span class="p">,</span> <span class="n">chunk</span> <span class="ow">in</span> <span class="nb">enumerate</span><span class="p">(</span><span class="n">chunks</span><span class="p">):</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">        <span class="n">vec</span> <span class="o">=</span> <span class="n">embed</span><span class="p">(</span><span class="n">chunk</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">        <span class="n">records</span><span class="o">.</span><span class="n">append</span><span class="p">({</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">            <span class="s2">&#34;source&#34;</span><span class="p">:</span> <span class="nb">str</span><span class="p">(</span><span class="n">md</span><span class="o">.</span><span class="n">relative_to</span><span class="p">(</span><span class="n">args</span><span class="o">.</span><span class="n">content_root</span><span class="o">.</span><span class="n">parent</span><span class="p">)),</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">            <span class="s2">&#34;chunk_index&#34;</span><span class="p">:</span> <span class="n">j</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl">            <span class="s2">&#34;text&#34;</span><span class="p">:</span> <span class="n">chunk</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl">            <span class="s2">&#34;embedding&#34;</span><span class="p">:</span> <span class="n">vec</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl">        <span class="p">})</span>
</span></span><span class="line"><span class="ln">15</span><span class="cl"><span class="k">with</span> <span class="nb">open</span><span class="p">(</span><span class="s2">&#34;scripts/rag-demo/index.pkl&#34;</span><span class="p">,</span> <span class="s2">&#34;wb&#34;</span><span class="p">)</span> <span class="k">as</span> <span class="n">f</span><span class="p">:</span>
</span></span><span class="line"><span class="ln">16</span><span class="cl">    <span class="n">pickle</span><span class="o">.</span><span class="n">dump</span><span class="p">(</span><span class="n">records</span><span class="p">,</span> <span class="n">f</span><span class="p">)</span></span></span></code></pre></div><p><strong>每段做什麼</strong>：</p>
<ol>
<li><strong><code>args.content_root.rglob(&quot;*.md&quot;)</code></strong>：recursive glob、回 <code>Path</code> iterator、找出 <code>content_root</code> 下所有 <code>.md</code> 檔（含子目錄）。</li>
<li><strong><code>sorted(...)</code></strong>：排序、讓每次 ingest 順序穩定（git diff 比較友善、retrieval 結果可重現）。</li>
<li><strong><code>text.read_text(encoding=&quot;utf-8&quot;)</code></strong>：讀檔、明確指定 UTF-8（中文 markdown 必要、否則 macOS / Linux 預設可能不一致）。</li>
<li><strong><code>re.sub(r&quot;^---\n.*?\n---\n&quot;, &quot;&quot;, text, count=1, flags=re.DOTALL)</code></strong>：去掉 Hugo frontmatter。
<ul>
<li><code>^---\n</code>：開頭 <code>---\n</code>。</li>
<li><code>.*?</code>：non-greedy match、配到下一個 <code>---</code> 就停。</li>
<li><code>\n---\n</code>：closing fence。</li>
<li><code>count=1</code>：只 strip 第一個（檔案中可能有其他 <code>---</code> 是水平分隔線、不要誤殺）。</li>
<li><code>flags=re.DOTALL</code>：讓 <code>.</code> 也匹配換行符（預設 <code>.</code> 不匹配 <code>\n</code>、規 frontmatter 跨行就吃不到）。</li>
</ul>
</li>
<li><strong><code>records.append({...})</code></strong>：每個 chunk 一個 record、含 source path、chunk index、原文、embedding。</li>
<li><strong><code>md.relative_to(args.content_root.parent)</code></strong>：把絕對 path 變成 <code>llm/00-foundations/xxx.md</code> 形式、retrieval 顯示時短、跨機器可移植。</li>
<li><strong><code>pickle.dump(records, f)</code></strong>：把整個 records list 序列化到 binary 檔。</li>
</ol>
<p><strong>為什麼這樣設計</strong>：</p>
<ul>
<li><strong>為什麼要 strip frontmatter</strong>：Frontmatter 是 <code>title</code>、<code>date</code>、<code>tags</code> 等 metadata、不是文章正文。embed 進去會稀釋向量語意（讓「date」「2026-05-11」等 keyword 影響相似度計算）。Strip 後 embedding 只 capture 內容語意。</li>
<li><strong>為什麼 records 是 list of dict 而不是 numpy array</strong>：兩個原因。(1) 每個 record 含 source / chunk_index / text / embedding 四種異質欄位、numpy 處理不直接。(2) 463 chunks 規模、純 Python list 跑 cosine 也只是毫秒級、不需要 vectorize。十萬 chunk 以上才考慮 numpy array + batched dot product。</li>
<li><strong>為什麼 pickle 而不是 JSON</strong>：embedding 是 768-float list、JSON 序列化會把每個 float 變成 ASCII 字串（每個 ~20 bytes）、檔案大很多、parse 也慢。Pickle 是 binary format、保留原本資料結構、檔案小、loader 快。代價：pickle 有 Python 版本相依、跨語言不能讀——但本 demo 索引只給自家 query.py / mcp_server.py 用、可接受。</li>
<li><strong>為什麼存 <code>text</code> 跟 <code>embedding</code>、不只 embedding</strong>：retrieval 要回 chunk 原文給 LLM 看、不能只有 source path（不然每次 query 還要再讀檔）。這裡的 corpus 檔案就是 <a href="/blog/llm/knowledge-cards/retrieval-source/" data-link-title="Retrieval Source" data-link-desc="RAG 從哪個 corpus、index、tool 或外部系統取回內容，決定來源可信度、freshness、權限與引用責任">retrieval source</a>；Pickle 多存原文成本低（~100 byte / chunk）、查詢時方便很多。</li>
</ul>
<h3 id="跑-ingest">跑 ingest</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="nb">cd</span> ~/Projects/blog
</span></span><span class="line"><span class="ln">2</span><span class="cl">python3 scripts/rag-demo/ingest.py</span></span></code></pre></div><ul>
<li><code>cd ~/Projects/blog</code>：切到 repo 根、讓相對路徑 <code>content/llm</code> 對得到 corpus、<code>scripts/rag-demo/index.pkl</code> 對得到 output 位置。</li>
<li><code>python3 scripts/rag-demo/ingest.py</code>：跑 ingest script、預設讀 <code>content/llm/</code>、寫 <code>scripts/rag-demo/index.pkl</code>。</li>
</ul>
<p>實測輸出：</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">Found 71 markdown files under content/llm
</span></span><span class="line"><span class="ln">2</span><span class="cl">  [10/71] 86 chunks in 4.5s
</span></span><span class="line"><span class="ln">3</span><span class="cl">  [20/71] 181 chunks in 8.6s
</span></span><span class="line"><span class="ln">4</span><span class="cl">  ...
</span></span><span class="line"><span class="ln">5</span><span class="cl">  [70/71] 461 chunks in 22.2s
</span></span><span class="line"><span class="ln">6</span><span class="cl">Wrote 463 records to scripts/rag-demo/index.pkl (22.3s)</span></span></code></pre></div><p>463 chunks、22 秒、平均 ~21 chunks/sec。瓶頸是 sequential API call、用 async / batch 能快 5-10 倍、但這個量級不值得。</p>
<h2 id="queryretrieval--augmentation--generation">Query：retrieval + augmentation + generation</h2>
<p>完整檔案：<code>scripts/rag-demo/query.py</code>。三段。</p>
<h3 id="1-cosine-similarity--top-k-retrieval">1. Cosine similarity + top-K retrieval</h3>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="k">def</span> <span class="nf">cosine</span><span class="p">(</span><span class="n">a</span><span class="p">,</span> <span class="n">b</span><span class="p">):</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">    <span class="n">dot</span> <span class="o">=</span> <span class="nb">sum</span><span class="p">(</span><span class="n">x</span> <span class="o">*</span> <span class="n">y</span> <span class="k">for</span> <span class="n">x</span><span class="p">,</span> <span class="n">y</span> <span class="ow">in</span> <span class="nb">zip</span><span class="p">(</span><span class="n">a</span><span class="p">,</span> <span class="n">b</span><span class="p">))</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">    <span class="n">na</span> <span class="o">=</span> <span class="n">math</span><span class="o">.</span><span class="n">sqrt</span><span class="p">(</span><span class="nb">sum</span><span class="p">(</span><span class="n">x</span> <span class="o">*</span> <span class="n">x</span> <span class="k">for</span> <span class="n">x</span> <span class="ow">in</span> <span class="n">a</span><span class="p">))</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">    <span class="n">nb</span> <span class="o">=</span> <span class="n">math</span><span class="o">.</span><span class="n">sqrt</span><span class="p">(</span><span class="nb">sum</span><span class="p">(</span><span class="n">y</span> <span class="o">*</span> <span class="n">y</span> <span class="k">for</span> <span class="n">y</span> <span class="ow">in</span> <span class="n">b</span><span class="p">))</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">    <span class="k">return</span> <span class="n">dot</span> <span class="o">/</span> <span class="p">(</span><span class="n">na</span> <span class="o">*</span> <span class="n">nb</span><span class="p">)</span> <span class="k">if</span> <span class="n">na</span> <span class="ow">and</span> <span class="n">nb</span> <span class="k">else</span> <span class="mf">0.0</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="k">def</span> <span class="nf">retrieve</span><span class="p">(</span><span class="n">records</span><span class="p">,</span> <span class="n">query_vec</span><span class="p">,</span> <span class="n">top_k</span><span class="p">):</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">    <span class="n">scored</span> <span class="o">=</span> <span class="p">[(</span><span class="n">cosine</span><span class="p">(</span><span class="n">query_vec</span><span class="p">,</span> <span class="n">r</span><span class="p">[</span><span class="s2">&#34;embedding&#34;</span><span class="p">]),</span> <span class="n">r</span><span class="p">)</span> <span class="k">for</span> <span class="n">r</span> <span class="ow">in</span> <span class="n">records</span><span class="p">]</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">    <span class="n">scored</span><span class="o">.</span><span class="n">sort</span><span class="p">(</span><span class="n">key</span><span class="o">=</span><span class="k">lambda</span> <span class="n">x</span><span class="p">:</span> <span class="n">x</span><span class="p">[</span><span class="mi">0</span><span class="p">],</span> <span class="n">reverse</span><span class="o">=</span><span class="kc">True</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">    <span class="k">return</span> <span class="n">scored</span><span class="p">[:</span><span class="n">top_k</span><span class="p">]</span></span></span></code></pre></div><p><strong>每行做什麼</strong>：</p>
<ol>
<li><strong><code>dot = sum(x * y for x, y in zip(a, b))</code></strong>：兩個向量的內積（dot product）。<code>zip(a, b)</code> 把兩個 list 對位配對、generator expression 算每對相乘、sum 加起來。</li>
<li><strong><code>na = math.sqrt(sum(x * x for x in a))</code></strong>：a 的 L2 norm（歐氏範數）—— <code>sqrt(x1² + x2² + ... + xn²)</code>。</li>
<li><strong><code>nb = math.sqrt(sum(y * y for y in b))</code></strong>：b 的 L2 norm。</li>
<li><strong><code>return dot / (na * nb) if na and nb else 0.0</code></strong>：cosine = dot / (||a|| × ||b||)。三元運算子防 zero division——若任一向量是零向量、na 或 nb 為 0、回 0.0 而不是 crash。</li>
<li><strong><code>scored = [(cosine(query_vec, r[&quot;embedding&quot;]), r) for r in records]</code></strong>：對每個 record 算相似度、組成 (score, record) tuple 的 list。</li>
<li><strong><code>scored.sort(key=lambda x: x[0], reverse=True)</code></strong>：按 score 從大到小排序。<code>key=lambda x: x[0]</code> 取 tuple 第一個元素（score）當排序 key。</li>
<li><strong><code>return scored[:top_k]</code></strong>：取前 K 個。</li>
</ol>
<p><strong>為什麼這樣設計</strong>：</p>
<ul>
<li><strong>為什麼 cosine 而不是純 dot product</strong>：純 dot product 受向量長度影響——長向量自動拿高分、跟「相似度」無關。Cosine 把向量正規化到單位長度、純看方向、是「語意相似」的標準衡量。語意相似 embedding 應該方向相近、長度差異不重要。</li>
<li><strong>為什麼用 <code>math.sqrt</code> 而不是 <code>**0.5</code></strong>：兩者數學等價、但 <code>math.sqrt</code> 用 C-level 實作、CPython 中比 Python 級 <code>**0.5</code> 快幾倍。對 463 chunks 影響不大、但 production scale 會放大差異——習慣寫 <code>math.sqrt</code> 的好。</li>
<li><strong>為什麼 <code>if na and nb else 0.0</code></strong>：防禦性程式設計。理論上 embedding 不會是零向量（模型架構保證有非零權重）、但邊界情況（空輸入、API 出錯回 placeholder）可能出現、避免 ZeroDivisionError 整個 query 失敗。回 0.0 表示「無法判斷相似度」、retrieval 排序時自然排到最後。</li>
<li><strong>為什麼 sort 全部、不用 heap</strong>：463 records、Python sort 是 O(n log n)、毫秒級。<code>heapq.nlargest(top_k, ...)</code> 是 O(n log k)、在 k=4、n=463 上實測幾乎沒差。十萬 record 以上才看到顯著差別。</li>
<li><strong>為什麼用 list of tuple、不用 numpy</strong>：跟 ingest 同樣的理由——小規模不需要 vectorize、純 Python 清楚。</li>
</ul>
<h3 id="2-建-augmented-prompt">2. 建 augmented prompt</h3>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="n">context_blocks</span> <span class="o">=</span> <span class="p">[]</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="k">for</span> <span class="n">score</span><span class="p">,</span> <span class="n">r</span> <span class="ow">in</span> <span class="n">retrieved</span><span class="p">:</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">    <span class="n">context_blocks</span><span class="o">.</span><span class="n">append</span><span class="p">(</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">        <span class="sa">f</span><span class="s2">&#34;[來源：</span><span class="si">{</span><span class="n">r</span><span class="p">[</span><span class="s1">&#39;source&#39;</span><span class="p">]</span><span class="si">}</span><span class="s2">#chunk</span><span class="si">{</span><span class="n">r</span><span class="p">[</span><span class="s1">&#39;chunk_index&#39;</span><span class="p">]</span><span class="si">}</span><span class="s2"> 相似度：</span><span class="si">{</span><span class="n">score</span><span class="si">:</span><span class="s2">.3f</span><span class="si">}</span><span class="s2">]</span><span class="se">\n</span><span class="si">{</span><span class="n">r</span><span class="p">[</span><span class="s1">&#39;text&#39;</span><span class="p">]</span><span class="si">}</span><span class="s2">&#34;</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">    <span class="p">)</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="n">system</span> <span class="o">=</span> <span class="p">(</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">    <span class="s2">&#34;你是一個技術文件問答助手。&#34;</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">    <span class="s2">&#34;依下方 context 內容回答問題、不要編造 context 外的事實。&#34;</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">    <span class="s2">&#34;若 context 不足以回答、明確說『資料不足』。&#34;</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">    <span class="s2">&#34;回答末尾列出引用的來源 path。&#34;</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl"><span class="p">)</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl"><span class="n">user</span> <span class="o">=</span> <span class="s2">&#34;## Context</span><span class="se">\n\n</span><span class="s2">&#34;</span> <span class="o">+</span> <span class="s2">&#34;</span><span class="se">\n\n</span><span class="s2">---</span><span class="se">\n\n</span><span class="s2">&#34;</span><span class="o">.</span><span class="n">join</span><span class="p">(</span><span class="n">context_blocks</span><span class="p">)</span> <span class="o">+</span> <span class="sa">f</span><span class="s2">&#34;</span><span class="se">\n\n</span><span class="s2">## Question</span><span class="se">\n\n</span><span class="si">{</span><span class="n">question</span><span class="si">}</span><span class="s2">&#34;</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl">
</span></span><span class="line"><span class="ln">15</span><span class="cl"><span class="n">messages</span> <span class="o">=</span> <span class="p">[</span>
</span></span><span class="line"><span class="ln">16</span><span class="cl">    <span class="p">{</span><span class="s2">&#34;role&#34;</span><span class="p">:</span> <span class="s2">&#34;system&#34;</span><span class="p">,</span> <span class="s2">&#34;content&#34;</span><span class="p">:</span> <span class="n">system</span><span class="p">},</span>
</span></span><span class="line"><span class="ln">17</span><span class="cl">    <span class="p">{</span><span class="s2">&#34;role&#34;</span><span class="p">:</span> <span class="s2">&#34;user&#34;</span><span class="p">,</span> <span class="s2">&#34;content&#34;</span><span class="p">:</span> <span class="n">user</span><span class="p">},</span>
</span></span><span class="line"><span class="ln">18</span><span class="cl"><span class="p">]</span></span></span></code></pre></div><p><strong>每行做什麼</strong>：</p>
<ol>
<li><strong><code>f&quot;[來源：{...} 相似度：{score:.3f}]\n{r['text']}&quot;</code></strong>：每個 retrieved chunk 加 header 標明出處跟相似度、再接原文。<code>:.3f</code> 是 score 格式化到三位小數。</li>
<li><strong><code>&quot;\n\n---\n\n&quot;.join(context_blocks)</code></strong>：用 <code>---</code> 水平分隔線分隔各 chunk、視覺上清楚。</li>
<li><strong><code>{&quot;role&quot;: &quot;system&quot;, &quot;content&quot;: system}</code></strong>：system message 給 LLM 設定角色 + 約束。</li>
<li><strong><code>{&quot;role&quot;: &quot;user&quot;, &quot;content&quot;: user}</code></strong>：user message 含 context 跟 question、是 LLM 實際讀的內容。</li>
</ol>
<p><strong>為什麼這樣設計</strong>：</p>
<ul>
<li><strong>為什麼 <a href="/blog/llm/knowledge-cards/system-prompt/" data-link-title="System Prompt" data-link-desc="LLM application 中由開發者預設、不直接顯示給使用者的指令層、定義模型的角色、行為規範、輸出格式">system prompt</a> 約束四件事</strong>（角色、忠於 context、資料不足時明說、引用來源）：
<ul>
<li><strong>角色</strong>：「技術文件問答助手」框定模型行為、減少 off-topic 回應。</li>
<li><strong>忠於 context</strong>：對抗 RAG 最常見的失敗模式——LLM 看到 context 但用自己訓練的 knowledge 補完、結果跟 corpus 不一致。明確要求 follow context 能降低（雖然不能完全消除、見實測 1）。</li>
<li><strong>資料不足時明說</strong>：避免 LLM「硬要回答」造成 hallucination。對 weak model 這條 follow 度差、但對 large model 有效。</li>
<li><strong>引用來源</strong>：traceability。讀者能回查 corpus、驗證模型答案。</li>
</ul>
</li>
<li><strong>為什麼 <code>## Context</code> / <code>## Question</code> 結構</strong>：用 markdown heading 結構幫助 LLM 區分「我要讀什麼」「我要回答什麼」。比平鋪文字穩定（即使對小模型）。</li>
<li><strong>為什麼把 retrieved chunks 全塞 user message、不分開</strong>：MCP / function calling 的更現代做法是把 retrieved 結果做成 tool response、模型主動 call retrieval tool。本 demo 不引入 tool use、直接塞 prompt 較單純——能說明 RAG 核心（augmentation）不必牽扯 tool use。</li>
</ul>
<h3 id="3-呼叫-chat-completions">3. 呼叫 chat completions</h3>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="ln">1</span><span class="cl"><span class="k">def</span> <span class="nf">chat</span><span class="p">(</span><span class="n">messages</span><span class="p">,</span> <span class="n">model</span><span class="p">):</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">    <span class="n">payload</span> <span class="o">=</span> <span class="n">json</span><span class="o">.</span><span class="n">dumps</span><span class="p">({</span><span class="s2">&#34;model&#34;</span><span class="p">:</span> <span class="n">model</span><span class="p">,</span> <span class="s2">&#34;messages&#34;</span><span class="p">:</span> <span class="n">messages</span><span class="p">,</span> <span class="s2">&#34;stream&#34;</span><span class="p">:</span> <span class="kc">False</span><span class="p">})</span><span class="o">.</span><span class="n">encode</span><span class="p">()</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">    <span class="n">req</span> <span class="o">=</span> <span class="n">urllib</span><span class="o">.</span><span class="n">request</span><span class="o">.</span><span class="n">Request</span><span class="p">(</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">        <span class="s2">&#34;http://localhost:11434/v1/chat/completions&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">        <span class="n">data</span><span class="o">=</span><span class="n">payload</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl">        <span class="n">headers</span><span class="o">=</span><span class="p">{</span><span class="s2">&#34;Content-Type&#34;</span><span class="p">:</span> <span class="s2">&#34;application/json&#34;</span><span class="p">},</span>
</span></span><span class="line"><span class="ln">7</span><span class="cl">    <span class="p">)</span>
</span></span><span class="line"><span class="ln">8</span><span class="cl">    <span class="k">with</span> <span class="n">urllib</span><span class="o">.</span><span class="n">request</span><span class="o">.</span><span class="n">urlopen</span><span class="p">(</span><span class="n">req</span><span class="p">,</span> <span class="n">timeout</span><span class="o">=</span><span class="mi">180</span><span class="p">)</span> <span class="k">as</span> <span class="n">resp</span><span class="p">:</span>
</span></span><span class="line"><span class="ln">9</span><span class="cl">        <span class="k">return</span> <span class="n">json</span><span class="o">.</span><span class="n">loads</span><span class="p">(</span><span class="n">resp</span><span class="o">.</span><span class="n">read</span><span class="p">())[</span><span class="s2">&#34;choices&#34;</span><span class="p">][</span><span class="mi">0</span><span class="p">][</span><span class="s2">&#34;message&#34;</span><span class="p">][</span><span class="s2">&#34;content&#34;</span><span class="p">]</span></span></span></code></pre></div><p><strong>每行做什麼</strong>：</p>
<ol>
<li><strong><code>json.dumps({&quot;model&quot;: ..., &quot;messages&quot;: ..., &quot;stream&quot;: False}).encode()</code></strong>：構造 OpenAI 相容 chat completions request body。<code>stream: False</code> 讓 server 等生成完再一次回、不要 SSE 串流。</li>
<li><strong><code>/v1/chat/completions</code></strong>：OpenAI 相容 endpoint、跟雲端 OpenAI 完全同樣 schema。</li>
<li><strong><code>timeout=180</code></strong>：3 分鐘、給長 context + 慢模型空間。</li>
<li><strong><code>[&quot;choices&quot;][0][&quot;message&quot;][&quot;content&quot;]</code></strong>：parse OpenAI 標準 response 結構、取第一個 choice 的 content。</li>
</ol>
<p><strong>為什麼這樣設計</strong>：</p>
<ul>
<li><strong>為什麼 <code>stream: False</code></strong>：demo 要把完整 answer 印出、不需要 incremental display。<code>stream: True</code> 要寫 SSE parser、複雜。Production 互動式 UI 才需要 streaming。</li>
<li><strong>為什麼 timeout=180、不是 60</strong>：1B 模型 + 4 個 retrieved chunks 的 context、prefill 可能要 5-30 秒、生成 100-500 token 又要 5-20 秒、保守設 3 分鐘。embed function 用 60 是因為 embedding 是純 forward pass、單一 token 量級操作、不需要這麼長。</li>
<li><strong>為什麼 <code>/v1/...</code> 而不是 <code>/api/...</code></strong>：chat completions 走 OpenAI 相容 endpoint、生態都用這個格式（Continue.dev、Cursor、各家 SDK）。embedding 用 <code>/api/...</code> 是因為原生 schema 簡單；chat 用 <code>/v1/...</code> 是因為 message-based 結構是 OpenAI 標準、跨工具互通。</li>
</ul>
<h2 id="實測結果retrieval-對generation-弱">實測結果：retrieval 對、generation 弱</h2>
<h3 id="測試-1什麼是-mtp為什麼對寫-code-場景特別有效">測試 1：「什麼是 MTP？為什麼對寫 code 場景特別有效？」</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">python3 scripts/rag-demo/query.py --show-retrieved <span class="s2">&#34;什麼是 MTP？為什麼對寫 code 場景特別有效？&#34;</span></span></span></code></pre></div><p><code>--show-retrieved</code> 是個 flag、開啟後在 stderr 印 retrieved chunks 跟 score、答案還是進 stdout。是 debug 跟教學用、不會影響 LLM 看到的 prompt。</p>
<p>Retrieval：</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">0.870  llm/knowledge-cards/transformer.md#chunk2
</span></span><span class="line"><span class="ln">2</span><span class="cl">0.825  llm/03-theoretical-foundations/sampling-and-decoding.md#chunk8
</span></span><span class="line"><span class="ln">3</span><span class="cl">0.782  llm/knowledge-cards/ttft.md#chunk1
</span></span><span class="line"><span class="ln">4</span><span class="cl">0.771  llm/knowledge-cards/mtp.md#chunk2</span></span></code></pre></div><p>四個 chunk 都跟問題相關、相似度合理。MTP 卡確實被命中（雖然不是 top-1、是因為 transformer.md 該段提到 MTP）。</p>
<p>Generation（1B 模型）：</p>
<blockquote>
<p>MTP 僅指使用 Ollama 進行 Coding 模型訓練與部署、它是一種系統性的方式&hellip;
來源：<a href="https://llm.dev/mti/">llm.dev</a></p></blockquote>
<p><strong>錯</strong>：1B 模型編造了「MTP 僅指使用 Ollama」這個事實（不對、MTP 是 Google 為 Gemma 釋出的、跟 Ollama 沒直接關係）、來源 URL 也是 hallucination。</p>
<h3 id="測試-2mcp-跟-function-calling-有什麼差別">測試 2：「MCP 跟 function calling 有什麼差別？」</h3>
<p>Retrieval：</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">0.721  llm/04-applications/application-protocols.md#chunk2
</span></span><span class="line"><span class="ln">2</span><span class="cl">0.704  llm/04-applications/application-protocols.md#chunk1
</span></span><span class="line"><span class="ln">3</span><span class="cl">0.702  llm/04-applications/application-protocols.md#chunk0
</span></span><span class="line"><span class="ln">4</span><span class="cl">0.693  llm/knowledge-cards/function-calling.md#chunk1</span></span></code></pre></div><p>完美命中——4.3 應用層協議章節三個 chunk + function-calling 卡。</p>
<p>Generation：模型把幾段重複拼接、framing 跟原文有出入、但比測試 1 好（因為 context 涵蓋直接答案）。</p>
<h2 id="觀察跟原理對應">觀察跟原理對應</h2>
<p>這個 demo 剛好示範 <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> 提的兩段式失敗模式：</p>
<table>
  <thead>
      <tr>
          <th>階段</th>
          <th>表現</th>
          <th>原因</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Retrieval</td>
          <td>命中率好、找到對的 chunks</td>
          <td><code>nomic-embed-text</code> 對技術文件覆蓋好、cosine 對短 query 也 OK</td>
      </tr>
      <tr>
          <td>Generation</td>
          <td>內容有時編造、不忠於 context、來源亂寫</td>
          <td><code>gemma3:1b</code> 模型容量不足以可靠 follow system prompt</td>
      </tr>
  </tbody>
</table>
<p>換 31B+ 模型 generation 會改善很多——這也是 4.0 章節提到「retrieval 跟下游 LLM 訓練分佈不一致」會放大失敗的具體例子。寫 RAG 系統時、generation 失敗不一定是「retrieval 沒給對 context」、可能是「模型不夠強」。</p>
<h2 id="何時這份-demo-會過時">何時這份 demo 會過時</h2>
<ul>
<li><strong>Ollama API 形狀</strong>：短期內不會變（生態都依賴）。</li>
<li><strong><code>nomic-embed-text</code> / <code>gemma3:1b</code> 具體 tag</strong>：預期會被新模型取代、但 retrieval + augmentation 結構不變。</li>
<li><strong>Chunking heuristic</strong>：簡單 char-count / 2 很粗、半年後若有便宜的 token counter 直接接會更準。</li>
<li><strong>Pickle 儲存</strong>：production 場景建議換 vector DB、本 demo 是教學用。</li>
</ul>
<p>實作換代時、保留 ingest / retrieve / augment / generate 四段、各段內部換工具即可——這四段是 RAG 的骨架、跨工具世代不變。</p>
<h2 id="跑這個-demo-的指令總結">跑這個 demo 的指令總結</h2>





<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"># 一次性建索引（每次 corpus 變動才需要重建）</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="nb">cd</span> ~/Projects/blog
</span></span><span class="line"><span class="ln">3</span><span class="cl">python3 scripts/rag-demo/ingest.py</span></span></code></pre></div><ul>
<li><code>cd</code>：切到 repo 根、relative path 對得到。</li>
<li><code>python3 ingest.py</code>：跑索引、預設讀 <code>content/llm/</code>、寫 <code>scripts/rag-demo/index.pkl</code>。每次 corpus 變動才需要重跑、不變的話 index 就一直用。</li>
</ul>





<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">python3 scripts/rag-demo/query.py --show-retrieved <span class="s2">&#34;你的問題&#34;</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">python3 scripts/rag-demo/query.py --top-k <span class="m">5</span> --model gemma3:1b <span class="s2">&#34;問題&#34;</span></span></span></code></pre></div><ul>
<li><code>--show-retrieved</code>：教學 / debug 用、列 retrieved chunks 跟 score 到 stderr。</li>
<li><code>--top-k 5</code>：取 top 5 instead of 預設 4。chunks 越多 context 越長、TTFT 越久、但訊息越完整。</li>
<li><code>--model gemma3:1b</code>：指定 chat model。換 <code>gemma3:4b</code>、<code>gemma4:31b-coding-mtp-bf16</code> 等 generation 品質會大幅改善。</li>
</ul>
<p>完整 source 在 <code>scripts/rag-demo/</code> 下、200 行 Python、無外部 dependency。</p>
<p>跟其他 hands-on 章節的關係：完整 hands-on 系列見 <a href="/blog/llm/01-local-llm-services/hands-on/" data-link-title="Hands-on：本地 AI 工具實作筆記" data-link-desc="Ollama / ComfyUI / Whisper / Piper TTS：實際安裝、驗證、跑通的紀錄。隨工具版本演化、跟 1.x 原理章節互補。">Hands-on 章節索引</a>、把 retrieval 包成 MCP server 暴露給 LLM application 見 <a href="/blog/llm/01-local-llm-services/hands-on/mcp-demo/" data-link-title="Hands-on：用 blog content 寫一個最小 MCP server" data-link-desc="stdio JSON-RPC、stdlib-only Python、暴露 blog content 給 LLM 用、validating 4.3 應用層協議">MCP demo</a>、RAG + MCP 同跑的記憶體 / 程序預算見 <a href="/blog/llm/01-local-llm-services/hands-on/rag-mcp-resources/" data-link-title="Hands-on：RAG / MCP 的資源 footprint" data-link-desc="RAG ingest / query / MCP server 三階段的 RAM / 磁碟 / process 實測、多模型並存的 RAM 衝突、本地 LLM 跑 RAG 跟單純 chat 的差異">RAG + MCP resource footprint</a>、術語見 <a href="/blog/llm/knowledge-cards/rag/" data-link-title="RAG" data-link-desc="Retrieval-Augmented Generation：動態外掛知識給 LLM、繞開模型參數記憶的靜態限制">RAG</a> 跟 <a href="/blog/llm/knowledge-cards/embedding-model/" data-link-title="Embedding Model" data-link-desc="把文字轉成向量的模型：用於 codebase 索引與語意搜尋">embedding model</a>。</p>
]]></content:encoded></item><item><title>4.12 Embedding model 內部：訓練、選型、in-domain fine-tune</title><link>https://tarrragon.github.io/blog/llm/04-applications/embedding-model-internals/</link><pubDate>Tue, 12 May 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/llm/04-applications/embedding-model-internals/</guid><description>&lt;p>&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 失敗的根本原因">RAG&lt;/a> 章節定義了 retrieval + augmentation 的二段式結構、但 retrieval 階段背後的 &lt;a href="https://tarrragon.github.io/blog/llm/knowledge-cards/embedding-model/" data-link-title="Embedding Model" data-link-desc="把文字轉成向量的模型：用於 codebase 索引與語意搜尋">embedding model&lt;/a> 怎麼運作、怎麼選、什麼時候該換、什麼時候該自己 fine-tune、這些決策直接影響 RAG 品質。本章把 embedding model 的訓練機制、評估方法、實務選型展開。&lt;/p>
&lt;h2 id="本章目標">本章目標&lt;/h2>
&lt;p>讀完本章後、你應該能：&lt;/p>
&lt;ol>
&lt;li>解釋 embedding model 跟 base LLM 的訓練差異。&lt;/li>
&lt;li>看到 MTEB / BEIR 分數時、知道對自己場景的意義。&lt;/li>
&lt;li>對自己 domain 選對 embedding model（通用 vs code vs multilingual）。&lt;/li>
&lt;li>判斷「需要 fine-tune 自己的 embedding model」的時機跟方法。&lt;/li>
&lt;/ol>
&lt;h2 id="embedding-model-vs-llm-的訓練差異">Embedding model vs LLM 的訓練差異&lt;/h2>
&lt;p>兩者底層架構可能類似（都用 Transformer）、但訓練 objective 完全不同：&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>維度&lt;/th>
 &lt;th>LLM（如 Llama / Gemma instruct）&lt;/th>
 &lt;th>Embedding model（如 bge-large、jina-v3）&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>訓練 objective&lt;/td>
 &lt;td>Next-token prediction + RLHF&lt;/td>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/llm/knowledge-cards/contrastive-learning/" data-link-title="Contrastive Learning" data-link-desc="用「相關 vs 不相關」成對 / 三元組樣本訓練 embedding 的方法、現代 embedding model 的核心訓練 paradigm">Contrastive learning&lt;/a>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>輸出形式&lt;/td>
 &lt;td>一連串 token&lt;/td>
 &lt;td>一個固定維度的向量（如 768、1024）&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>訓練資料&lt;/td>
 &lt;td>Trillion-token 通用文字&lt;/td>
 &lt;td>億級的 (query, doc) 正向對&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>用法&lt;/td>
 &lt;td>Prompt → response&lt;/td>
 &lt;td>Text → vector&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Pretrained 起點&lt;/td>
 &lt;td>從 scratch 或繼承 base&lt;/td>
 &lt;td>通常從 base LLM 抽 hidden state 開始&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>關鍵理解：&lt;strong>不能拿任意 LLM 的最後 hidden state 當 embedding&lt;/strong> — LLM hidden state 是為「預測下一個 token」優化、不為「相似度比較」優化。要再經過 contrastive learning fine-tune 才能當 embedding model 用。&lt;/p>
&lt;p>Embedding model 的典型訓練 pipeline：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-text" data-lang="text">&lt;span class="line">&lt;span class="ln"> 1&lt;/span>&lt;span class="cl">Stage 1: 從 base model 開始（如 BERT、RoBERTa、Mistral、Llama）
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 2&lt;/span>&lt;span class="cl"> ↓
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 3&lt;/span>&lt;span class="cl">Stage 2: Contrastive pre-training
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 4&lt;/span>&lt;span class="cl"> 用大量 weak supervised pair（如 Reddit title-body、StackExchange QA）
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 5&lt;/span>&lt;span class="cl"> InfoNCE loss、batch size 大、hard negative mining
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 6&lt;/span>&lt;span class="cl"> ↓
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 7&lt;/span>&lt;span class="cl">Stage 3: Supervised fine-tune
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 8&lt;/span>&lt;span class="cl"> 用標註好的 (query, relevant_doc) pair
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 9&lt;/span>&lt;span class="cl"> 來源如 MSMARCO、Natural Questions
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">10&lt;/span>&lt;span class="cl"> ↓
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">11&lt;/span>&lt;span class="cl">Stage 4（可選）: Task-specific instruction tuning
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">12&lt;/span>&lt;span class="cl"> 讓模型懂「task description」、可針對不同 retrieval 任務切換
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">13&lt;/span>&lt;span class="cl"> 代表：bge-large、e5-mistral-7b-instruct&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Stage 4 的「instruction-tuned embedding」是 2024 後流行的設計：query 前加「Represent this sentence for retrieving relevant passages:」這類前綴、embedding model 學會依任務調整向量。&lt;/p></description><content:encoded><![CDATA[<p><a href="/blog/llm/04-applications/rag-principles/" data-link-title="4.1 RAG 原理：retrieval &#43; augmentation 模式" data-link-desc="為什麼模型需要外掛知識、語意相似 vs 字面相似、chunking 的本質取捨、retrieval 失敗的根本原因">RAG</a> 章節定義了 retrieval + augmentation 的二段式結構、但 retrieval 階段背後的 <a href="/blog/llm/knowledge-cards/embedding-model/" data-link-title="Embedding Model" data-link-desc="把文字轉成向量的模型：用於 codebase 索引與語意搜尋">embedding model</a> 怎麼運作、怎麼選、什麼時候該換、什麼時候該自己 fine-tune、這些決策直接影響 RAG 品質。本章把 embedding model 的訓練機制、評估方法、實務選型展開。</p>
<h2 id="本章目標">本章目標</h2>
<p>讀完本章後、你應該能：</p>
<ol>
<li>解釋 embedding model 跟 base LLM 的訓練差異。</li>
<li>看到 MTEB / BEIR 分數時、知道對自己場景的意義。</li>
<li>對自己 domain 選對 embedding model（通用 vs code vs multilingual）。</li>
<li>判斷「需要 fine-tune 自己的 embedding model」的時機跟方法。</li>
</ol>
<h2 id="embedding-model-vs-llm-的訓練差異">Embedding model vs LLM 的訓練差異</h2>
<p>兩者底層架構可能類似（都用 Transformer）、但訓練 objective 完全不同：</p>
<table>
  <thead>
      <tr>
          <th>維度</th>
          <th>LLM（如 Llama / Gemma instruct）</th>
          <th>Embedding model（如 bge-large、jina-v3）</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>訓練 objective</td>
          <td>Next-token prediction + RLHF</td>
          <td><a href="/blog/llm/knowledge-cards/contrastive-learning/" data-link-title="Contrastive Learning" data-link-desc="用「相關 vs 不相關」成對 / 三元組樣本訓練 embedding 的方法、現代 embedding model 的核心訓練 paradigm">Contrastive learning</a></td>
      </tr>
      <tr>
          <td>輸出形式</td>
          <td>一連串 token</td>
          <td>一個固定維度的向量（如 768、1024）</td>
      </tr>
      <tr>
          <td>訓練資料</td>
          <td>Trillion-token 通用文字</td>
          <td>億級的 (query, doc) 正向對</td>
      </tr>
      <tr>
          <td>用法</td>
          <td>Prompt → response</td>
          <td>Text → vector</td>
      </tr>
      <tr>
          <td>Pretrained 起點</td>
          <td>從 scratch 或繼承 base</td>
          <td>通常從 base LLM 抽 hidden state 開始</td>
      </tr>
  </tbody>
</table>
<p>關鍵理解：<strong>不能拿任意 LLM 的最後 hidden state 當 embedding</strong> — LLM hidden state 是為「預測下一個 token」優化、不為「相似度比較」優化。要再經過 contrastive learning fine-tune 才能當 embedding model 用。</p>
<p>Embedding model 的典型訓練 pipeline：</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">Stage 1: 從 base model 開始（如 BERT、RoBERTa、Mistral、Llama）
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">   ↓
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">Stage 2: Contrastive pre-training
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">   用大量 weak supervised pair（如 Reddit title-body、StackExchange QA）
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">   InfoNCE loss、batch size 大、hard negative mining
</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">Stage 3: Supervised fine-tune
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">   用標註好的 (query, relevant_doc) pair
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">   來源如 MSMARCO、Natural Questions
</span></span><span class="line"><span class="ln">10</span><span class="cl">   ↓
</span></span><span class="line"><span class="ln">11</span><span class="cl">Stage 4（可選）: Task-specific instruction tuning
</span></span><span class="line"><span class="ln">12</span><span class="cl">   讓模型懂「task description」、可針對不同 retrieval 任務切換
</span></span><span class="line"><span class="ln">13</span><span class="cl">   代表：bge-large、e5-mistral-7b-instruct</span></span></code></pre></div><p>Stage 4 的「instruction-tuned embedding」是 2024 後流行的設計：query 前加「Represent this sentence for retrieving relevant passages:」這類前綴、embedding model 學會依任務調整向量。</p>
<h2 id="選型維度">選型維度</h2>
<p>主流 embedding model 的選型維度：</p>
<h3 id="1-domain-相符">1. Domain 相符</h3>
<table>
  <thead>
      <tr>
          <th>Domain</th>
          <th>推薦模型</th>
          <th>為什麼</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>通用英文</td>
          <td>bge-large-en-v1.5、mxbai-embed-large-v1</td>
          <td>通用 corpus、MTEB Retrieval 高分</td>
      </tr>
      <tr>
          <td>通用多語</td>
          <td>jina-embeddings-v3、bge-m3、multilingual-e5</td>
          <td>多語 pretrain、中日韓阿等支援</td>
      </tr>
      <tr>
          <td>Code（讀 / 寫 code）</td>
          <td>jina-embeddings-v2-base-code、voyage-code-3</td>
          <td>code corpus 訓練、語意（函式名、註解）+ syntax 結合</td>
      </tr>
      <tr>
          <td>中文</td>
          <td>bge-large-zh、Conan-embedding</td>
          <td>中文 corpus 為主</td>
      </tr>
      <tr>
          <td>跨語言（中英混合）</td>
          <td>jina-embeddings-v3、multilingual-e5</td>
          <td>跨語言對齊訓練、中英 query 找對方語言 doc</td>
      </tr>
  </tbody>
</table>
<h3 id="2-大小模型大小--向量維度">2. 大小（模型大小 / 向量維度）</h3>
<table>
  <thead>
      <tr>
          <th>Tier</th>
          <th>模型大小</th>
          <th>向量維度</th>
          <th>Latency / 記憶體</th>
          <th>適合場景</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>小（&lt; 200M）</td>
          <td>nomic-embed (137M)、all-MiniLM (23M)</td>
          <td>384-768</td>
          <td>快、本機 CPU 可跑</td>
          <td>本地 RAG、簡單 retrieval</td>
      </tr>
      <tr>
          <td>中（200-500M）</td>
          <td>bge-large (335M)、mxbai-embed-large</td>
          <td>1024</td>
          <td>中、需要 GPU 或 fast CPU</td>
          <td>主力 RAG、品質敏感場景</td>
      </tr>
      <tr>
          <td>大（500M-7B）</td>
          <td>e5-mistral-7b、Linq-Embed-Mistral</td>
          <td>4096</td>
          <td>慢、需要 GPU</td>
          <td>高品質、雲端、Reranking 場景</td>
      </tr>
      <tr>
          <td>雲端 API</td>
          <td>OpenAI text-embedding-3、voyage-3</td>
          <td>1024-3072</td>
          <td>網路 latency + API 成本</td>
          <td>雲端 RAG、高 QPS</td>
      </tr>
  </tbody>
</table>
<h3 id="3-context-window-上限">3. Context window 上限</h3>
<p>不同 embedding model 對單次 embed 的 token 上限不同：</p>
<table>
  <thead>
      <tr>
          <th>模型</th>
          <th>Context limit</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>早期 sentence-transformers</td>
          <td>256-512 tokens</td>
      </tr>
      <tr>
          <td>bge-large / mxbai-embed</td>
          <td>512 tokens</td>
      </tr>
      <tr>
          <td>nomic-embed-text-v1.5</td>
          <td>8192 tokens</td>
      </tr>
      <tr>
          <td>jina-embeddings-v3</td>
          <td>8192 tokens</td>
      </tr>
      <tr>
          <td>voyage-3</td>
          <td>32K tokens</td>
      </tr>
  </tbody>
</table>
<blockquote>
<p><strong>事實查核註</strong>：本節所列具體型號（bge-large-en-v1.5、jina-embeddings-v3、nomic-embed-text-v1.5、voyage-3 等）、向量維度、context limit、訓練資料 domain、MTEB / BEIR 排名 — 都是 2026/5 主流版本的估計、各模型升級節奏快、引用前以 <a href="https://huggingface.co/spaces/mteb/leaderboard">MTEB Leaderboard</a> 跟對應 model card 當前狀態為準。</p></blockquote>
<p>選擇影響 chunking 策略（見 <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> 的 chunking 段）：短 context embedding 要切細、長 context embedding 可保留更完整段落、但內部 attention 對長段中段仍可能 lost-in-the-middle。</p>
<h3 id="4-cosine-similarity-設計">4. Cosine similarity 設計</h3>
<p>部分 embedding model 訓練時就 L2-normalized、用 cosine = dot product；部分沒 normalize、要自己處理：</p>
<table>
  <thead>
      <tr>
          <th>Model</th>
          <th>Normalize 預設</th>
          <th>推薦 distance metric</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>bge-large、mxbai-embed</td>
          <td>已 L2-normalize</td>
          <td>Dot product（高效、結果同 cosine）</td>
      </tr>
      <tr>
          <td>nomic-embed-text</td>
          <td>已 L2-normalize</td>
          <td>Dot product</td>
      </tr>
      <tr>
          <td>OpenAI ada-002 / 3</td>
          <td>已 L2-normalize</td>
          <td>Dot product</td>
      </tr>
      <tr>
          <td>自訓練 / 早期模型</td>
          <td>未 normalize</td>
          <td>Cosine similarity</td>
      </tr>
  </tbody>
</table>
<blockquote>
<p>詳細見 <a href="/blog/llm/knowledge-cards/vector-norm/" data-link-title="Vector Norm" data-link-desc="衡量向量大小的純量值、L1 / L2 / L∞ 各有用途、cosine similarity 的基礎">vector-norm</a> 跟 <a href="/blog/llm/knowledge-cards/dot-product/" data-link-title="Dot Product" data-link-desc="兩個向量對應位置相乘再加總、attention score 跟相似度判讀的基礎">dot-product</a> 卡片。</p></blockquote>
<h2 id="評估mteb-跟自己-domain-的對齊">評估：MTEB 跟自己 domain 的對齊</h2>
<p><a href="/blog/llm/knowledge-cards/mteb-benchmark/" data-link-title="MTEB" data-link-desc="Massive Text Embedding Benchmark：8 大類 56 任務、評估 embedding model 跨任務通用能力的標準">MTEB</a> 是現在挑選 embedding model 最常用的 leaderboard、但要正確讀：</p>
<ol>
<li><strong>看 Retrieval 子分數、不是 Overall</strong>：MTEB 含 8 大類、跟 RAG 最直接相關的是 Retrieval 跟 Reranking</li>
<li><strong>跟自己 domain 對齊</strong>：MTEB 通用 corpus、自己 domain 可能跟 MTEB 落差大</li>
<li><strong>In-domain benchmark 才是 final test</strong>：用自己工作流的真實 query 跟 expected doc、自建小型評估集（如 100-200 對）、看候選 embedding model 的 hit rate / nDCG</li>
</ol>
<p>In-domain 評估的最小可行流程：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><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"><span class="mf">1.</span> <span class="n">蒐集</span> <span class="mi">50</span><span class="o">-</span><span class="mi">100</span> <span class="n">個</span> <span class="n">query</span> <span class="o">+</span> <span class="n">expected_doc</span><span class="err">（</span><span class="n">已知答案的對</span><span class="err">）</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="mf">2.</span> <span class="n">對</span> <span class="n">candidate</span> <span class="n">embedding</span> <span class="n">models</span> <span class="n">各跑</span><span class="err">：</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">   <span class="o">-</span> <span class="n">embed</span> <span class="n">所有</span> <span class="n">doc</span><span class="err">（</span><span class="n">含</span> <span class="n">expected</span> <span class="n">跟</span> <span class="n">distractor</span><span class="err">、</span><span class="o">~</span><span class="mi">1000</span> <span class="n">個</span> <span class="n">distractor</span><span class="err">）</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">   <span class="o">-</span> <span class="n">embed</span> <span class="n">每個</span> <span class="n">query</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl">   <span class="o">-</span> <span class="n">算</span> <span class="n">query</span><span class="o">-</span><span class="n">doc</span> <span class="n">similarity</span><span class="err">、</span><span class="n">看</span> <span class="n">expected</span> <span class="n">是否在</span> <span class="n">top</span><span class="o">-</span><span class="mi">5</span> <span class="o">/</span> <span class="n">top</span><span class="o">-</span><span class="mi">10</span>
</span></span><span class="line"><span class="ln">7</span><span class="cl"><span class="mf">3.</span> <span class="n">比較</span> <span class="n">candidate</span> <span class="n">的</span> <span class="n">hit_rate</span><span class="o">@</span><span class="mi">5</span> <span class="o">/</span> <span class="n">hit_rate</span><span class="o">@</span><span class="mi">10</span></span></span></code></pre></div><p>跑完這個再決定用哪個 embedding model、比看 MTEB leaderboard 可靠很多。</p>
<h2 id="實務選型的-constraint-優先序">實務選型的 constraint 優先序</h2>
<p>上面四個維度（domain / 大小 / context / cosine 設計）跟 MTEB 評估是「品質軸」— 哪個 embedding model 最能解你的 retrieval 問題。但實際選型時，品質軸之前通常有一組<strong>工程 constraint 先砍掉大量選項</strong>，剩下的候選才進品質比較。</p>
<p>常見的工程 constraint 依砍選項力度排序：</p>
<ol>
<li><strong>Runtime 可用性</strong>：推論伺服器支援哪些模型？Ollama 目前原生支援 <code>nomic-embed-text</code>、<code>mxbai-embed-large</code>、<code>snowflake-arctic-embed</code> 等，但不支援所有 Hugging Face 模型。用 cloud API（OpenAI / Cohere / Voyage）則受 vendor 綁定跟成本約束。這一條通常砍掉最多選項。</li>
<li><strong>體積 / 記憶體預算</strong>：個人機器常駐 embedding model 跟 chat model 共用記憶體。137M 的 <code>nomic-embed-text</code> 跟 7B 的 <code>e5-mistral</code> 在記憶體佔用上差一個數量級。</li>
<li><strong>已有驗證基線</strong>：團隊或前期 demo 已用某個模型跑過、retrieval 品質已確認可用。換模型要重建 index + 重新驗證，成本不只是 MTEB 分數比較。</li>
<li><strong>向量維度的 storage 成本</strong>：維度影響 index 大小（n × d × 4 bytes）跟 brute-force search 延遲。768 維 vs 1024 維在小規模無感，但 100K+ chunks 時差異開始有意義。詳見 <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>。</li>
</ol>
<p>實務流程是：先用 constraint 1-3 收窄到 2-3 個候選，再跑 in-domain benchmark（上段的 hit rate 流程）做最終決定。直接從 MTEB leaderboard 挑最高分的模型、到實際場景才發現 runtime 不支援或體積太大，是常見的繞路。</p>
<h2 id="何時該-fine-tune-自己的-embedding-model">何時該 fine-tune 自己的 embedding model</h2>
<p>通常<strong>不該</strong> fine-tune embedding model — 用現成的 bge-large、jina-v3 已經很好。但下列情境值得評估：</p>
<ol>
<li>
<p><strong>Domain 跟通用 corpus 差距大</strong>：</p>
<ul>
<li>醫療 / 法律 / 金融的專業術語、通用 embedding model 對「同義詞」「同概念不同表述」recall 差</li>
<li>In-domain term frequency 跟通用 corpus 差距大（如「IRA」在金融 vs 政治語境）</li>
</ul>
</li>
<li>
<p><strong>In-domain benchmark hit rate 顯著低於通用 benchmark</strong>：</p>
<ul>
<li>用 MTEB 高分模型、in-domain hit rate@5 仍 &lt; 60%</li>
<li>換多個候選 embedding model、所有都類似低分</li>
</ul>
</li>
<li>
<p><strong>有足夠 in-domain (query, doc) 對</strong>：</p>
<ul>
<li>Fine-tune 需要至少數千對、最好 1-10 萬對</li>
<li>對少於 1000 對的場景、fine-tune 收益通常低於數據增強 / 提升 retrieval pipeline</li>
</ul>
</li>
</ol>
<p>Fine-tune 流程（詳細）：</p>
<h3 id="step-1蒐集-in-domain-training-data">Step 1：蒐集 in-domain training data</h3>
<p>三種主流形態：</p>
<table>
  <thead>
      <tr>
          <th>Format</th>
          <th>結構</th>
          <th>蒐集難度</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Positive pair</td>
          <td>(query, relevant_doc)</td>
          <td>容易（從 click log、QA pair）</td>
      </tr>
      <tr>
          <td>Triplet</td>
          <td>(anchor, positive, negative)</td>
          <td>中（要明確 negative）</td>
      </tr>
      <tr>
          <td>Score / label</td>
          <td>(query, doc, relevance_score)</td>
          <td>難（要人工標）</td>
      </tr>
  </tbody>
</table>
<p>實務多從 positive pair 開始（InfoNCE loss 在 batch 內自動取其他樣本當 negative）、品質提升再進 triplet（hard negative mining）。</p>
<h3 id="step-2選-base-model">Step 2：選 base model</h3>
<p>選擇看資料量跟硬體：</p>
<table>
  <thead>
      <tr>
          <th>起始 base model</th>
          <th>適合資料量</th>
          <th>適合硬體</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>sentence-transformers MiniLM</td>
          <td>1K - 50K 對</td>
          <td>一般 CPU / 小 GPU</td>
      </tr>
      <tr>
          <td>BGE-base / bge-small</td>
          <td>10K - 100K 對</td>
          <td>16GB+ GPU</td>
      </tr>
      <tr>
          <td>BGE-large / jina-v3 / mxbai</td>
          <td>50K+ 對</td>
          <td>24GB+ GPU</td>
      </tr>
      <tr>
          <td>E5-Mistral-7B-instruct</td>
          <td>100K+ 對</td>
          <td>多卡 / A100</td>
      </tr>
  </tbody>
</table>
<p>選擇原則：base model 在 generic benchmark 越強、fine-tune 後上限越高、但訓練成本越高。</p>
<h3 id="step-3loss-選擇">Step 3：Loss 選擇</h3>
<table>
  <thead>
      <tr>
          <th>Loss</th>
          <th>機制</th>
          <th>適合</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>MultipleNegativesRankingLoss</td>
          <td>InfoNCE 變體、batch 內其他樣本當 negative</td>
          <td>Positive pair only、大 batch</td>
      </tr>
      <tr>
          <td>Triplet loss</td>
          <td>直接比 (anchor, positive, negative) 距離</td>
          <td>有明確 triplet、傳統選擇</td>
      </tr>
      <tr>
          <td>Cosine similarity loss</td>
          <td>預測相似度標籤</td>
          <td>Score / label data</td>
      </tr>
      <tr>
          <td>Contrastive tension loss</td>
          <td>對比學習變體、效果好</td>
          <td>大規模 fine-tune</td>
      </tr>
  </tbody>
</table>
<p>實務 default：MultipleNegativesRankingLoss + batch size 64-128（越大 negatives 越多、品質越高）。</p>
<h3 id="step-4hard-negative-mining">Step 4：Hard negative mining</h3>
<p>純隨機 negative（batch 內其他樣本）容易、但 hard negative（看似相關但實際無關）才能 push 模型品質：</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">1. 用初版 fine-tuned model 對每個 query 跑 retrieve top-50
</span></span><span class="line"><span class="ln">2</span><span class="cl">2. 對每個 query 的 top-50：
</span></span><span class="line"><span class="ln">3</span><span class="cl">   - 真正 relevant doc（known positive）→ skip
</span></span><span class="line"><span class="ln">4</span><span class="cl">   - 其他 → 候選 hard negative
</span></span><span class="line"><span class="ln">5</span><span class="cl">3. 篩 hard negatives（LLM-as-judge 或人工確認真的「看似相關但不對」）
</span></span><span class="line"><span class="ln">6</span><span class="cl">4. 用 (query, positive, hard_negative) 重訓
</span></span><span class="line"><span class="ln">7</span><span class="cl">5. Iterate 2-3 輪</span></span></code></pre></div><p>Hard negative 是 embedding fine-tune 品質的關鍵差距 — 沒做的 fine-tune 通常 plateau 早、做了的可超越通用 model。</p>
<h3 id="step-5lora-fine-tune-而非-full-fine-tune">Step 5：LoRA fine-tune 而非 full fine-tune</h3>
<p>跟 LLM fine-tune 一樣、embedding model fine-tune 也用 <a href="/blog/llm/knowledge-cards/lora/" data-link-title="LoRA" data-link-desc="Low-Rank Adaptation：凍住原模型權重、只訓兩個小矩陣的 parameter-efficient fine-tuning">LoRA</a>：</p>
<table>
  <thead>
      <tr>
          <th>方式</th>
          <th>訓練成本</th>
          <th>通用能力保留</th>
          <th>推論方式</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Full fine-tune</td>
          <td>高</td>
          <td>易 <a href="/blog/llm/knowledge-cards/catastrophic-forgetting/" data-link-title="Catastrophic Forgetting" data-link-desc="Fine-tune 模型時、新訓練資料覆蓋掉原本學到的能力的現象、LoRA / 資料 mixing 是主要緩解">catastrophic forgetting</a></td>
          <td>部署新權重</td>
      </tr>
      <tr>
          <td>LoRA fine-tune</td>
          <td>低</td>
          <td>保留好</td>
          <td>載入 base + adapter</td>
      </tr>
  </tbody>
</table>
<p>主流 framework：sentence-transformers + PEFT、Hugging Face Transformers + LoRA library。</p>
<h3 id="step-6evaluate">Step 6：Evaluate</h3>
<p>不只看 training loss、要實測：</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">1. Build in-domain test set（held-out、跟 training 完全分開）
</span></span><span class="line"><span class="ln">2</span><span class="cl">2. 算 [hit_rate@K](/llm/knowledge-cards/retrieval-recall/)（query 的 expected doc 是否在 top-K retrieval result）
</span></span><span class="line"><span class="ln">3</span><span class="cl">3. 跟「base model 未 fine-tune」對比：
</span></span><span class="line"><span class="ln">4</span><span class="cl">   - Fine-tune 後 hit_rate@5 提升 ≥ 10 percentage point → 成功
</span></span><span class="line"><span class="ln">5</span><span class="cl">   - 提升 &lt; 5pp → fine-tune 沒效益、不如優化 retrieval pipeline
</span></span><span class="line"><span class="ln">6</span><span class="cl">4. 確認沒崩通用能力：在 MTEB 跑、看主流 retrieval 任務沒大降</span></span></code></pre></div><h3 id="失敗模式">失敗模式</h3>
<table>
  <thead>
      <tr>
          <th>失敗</th>
          <th>緩解</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>資料太少（&lt; 1000 對）、模型沒學到</td>
          <td>數據增強（用 LLM 生 synthetic pair）、改用 prompt + RAG</td>
      </tr>
      <tr>
          <td>訓練 loss 降但 hit_rate 沒升</td>
          <td>Hard negative 不夠、要重 mine</td>
      </tr>
      <tr>
          <td>In-domain 提升但通用能力崩</td>
          <td>加 mixed dataset（80% domain + 20% MTEB）</td>
      </tr>
      <tr>
          <td>Embedding dim 不能改</td>
          <td>Base model 已固定 dim、自己訓 from scratch 才能改</td>
      </tr>
      <tr>
          <td>部署時跟 base model 衝突</td>
          <td>LoRA adapter merge 進 base 後部署、或同時 serve 兩版</td>
      </tr>
  </tbody>
</table>
<h2 id="跟-llm-的整合retrieval-pipeline">跟 LLM 的整合：retrieval pipeline</h2>
<p>完整 RAG pipeline 裡 embedding model 的位置：</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">[Ingestion 階段（離線）]
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">  Documents
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">    ↓ chunking
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">  Chunks
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">    ↓ embedding model
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">  Chunk vectors → 存進 vector DB
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">[Query 階段（線上）]
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">  User query
</span></span><span class="line"><span class="ln">10</span><span class="cl">    ↓ embedding model
</span></span><span class="line"><span class="ln">11</span><span class="cl">  Query vector
</span></span><span class="line"><span class="ln">12</span><span class="cl">    ↓ vector DB ANN search
</span></span><span class="line"><span class="ln">13</span><span class="cl">  Top-K chunks
</span></span><span class="line"><span class="ln">14</span><span class="cl">    ↓ (optional) reranking
</span></span><span class="line"><span class="ln">15</span><span class="cl">  Top-N chunks
</span></span><span class="line"><span class="ln">16</span><span class="cl">    ↓ augment LLM prompt
</span></span><span class="line"><span class="ln">17</span><span class="cl">  LLM response</span></span></code></pre></div><p>關鍵設計決策：</p>
<ol>
<li><strong>Embedding model 一致性</strong>：ingestion 跟 query 必須用同個 model（換 model = 整批 re-embed）；chunk vectors 存進 vector DB 之後的 index 結構、維度成本與生命週期見 <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></li>
<li><strong>Chunking 策略對齊 embedding context</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 chunking</a></li>
<li><strong>Reranking model 通常用 cross-encoder</strong>：embedding model 是 bi-encoder（query 跟 doc 分開 embed）、reranker 是 cross-encoder（query + doc 一起算）、品質更高但慢、適合在 top-50 → top-5 之間做 reranking</li>
<li><strong>Hybrid retrieval</strong>：BM25（字面）+ embedding（語意）混用、用 RRF（Reciprocal Rank Fusion）合併、是 production 常見配置</li>
</ol>
<h2 id="本地-vs-雲端-embedding">本地 vs 雲端 embedding</h2>
<table>
  <thead>
      <tr>
          <th>維度</th>
          <th>本地（如 nomic-embed）</th>
          <th>雲端（如 OpenAI text-embedding-3）</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>隱私</td>
          <td>完全本地、no exfil</td>
          <td>API 送 doc、依政策 log</td>
      </tr>
      <tr>
          <td>成本</td>
          <td>一次硬體 + 電費</td>
          <td>按 token 計費、長期可累積</td>
      </tr>
      <tr>
          <td>品質</td>
          <td>bge-large / jina-v3 已接近雲端旗艦</td>
          <td>略高（旗艦如 voyage-3 仍領先）</td>
      </tr>
      <tr>
          <td>Latency</td>
          <td>視硬體、本地 SSD 快</td>
          <td>網路 latency</td>
      </tr>
      <tr>
          <td>多語 / domain</td>
          <td>開源選擇多、可挑 domain-specific</td>
          <td>API 是通用、不一定最佳 domain match</td>
      </tr>
  </tbody>
</table>
<p>寫 code 場景的判讀：</p>
<ul>
<li><strong>codebase 內部 RAG（NDA / 機密 code）</strong>：本地 embedding 必選</li>
<li><strong>個人開源專案 RAG</strong>：本地 embedding 是合理 default、簡單、free</li>
<li><strong>公司內部 RAG（需高品質、量大）</strong>：評估 voyage-3 / OpenAI v3 vs 本地 bge-large</li>
<li><strong>產品級 production RAG</strong>：通常雲端 API + 自己 fine-tune 的 embedding（最佳品質）</li>
</ul>
<h2 id="何時過時--何時不過時">何時過時 / 何時不過時</h2>
<p><strong>不會過時的部分</strong>：</p>
<ul>
<li>Contrastive learning 是 embedding model 的核心訓練 paradigm</li>
<li>MTEB 作為通用 embedding 評估的角色</li>
<li>「跟自己 domain 對齊」的 in-domain benchmark 必要性</li>
<li>Bi-encoder vs cross-encoder 的分工（retrieval vs reranking）</li>
<li>Hybrid retrieval（BM25 + embedding）的設計</li>
</ul>
<p><strong>會變的部分</strong>：</p>
<ul>
<li>具體 embedding model（bge → bge-v2 → &hellip;、jina-v3 → v4 → &hellip;）</li>
<li>MTEB leaderboard 排名（每月變）</li>
<li>Instruction-tuned embedding 的 prompt format（標準化中）</li>
<li>Embedding model 的 context window 上限（推升中）</li>
<li>Long-context embedding 的研究（如 ColBERT-style late interaction）</li>
</ul>
<h2 id="下一章">下一章</h2>
<p>沒 backend 的靜態場景（個人 blog / docs site）做 embedding 搜尋的 deployment 選擇見 <a href="/blog/llm/04-applications/static-and-serverless-rag-deployment/" data-link-title="4.16 靜態 / serverless RAG deployment：架構選擇與資安取捨" data-link-desc="沒 backend 的場景怎麼做 RAG：四種 deployment 方案、API key 暴露問題、CORS / abuse / 第三方信任、跟模組六的 routing">4.16 靜態 / serverless RAG deployment</a>。</p>
<p>下一章：<a href="/blog/llm/04-applications/eval-design-framework/" data-link-title="4.13 Eval 設計座標系：三軸、八象限、何時測什麼" data-link-desc="Eval 設計三軸（objective↔subjective / component↔end-to-end / quantitative↔qualitative）、八象限的對應 eval 工具、軸選錯的訊號、跟 benchmarking / LLM-as-judge / tracing 的關係">4.13 Eval 設計座標系</a>、看 eval 三軸八象限 meta 框架（先選軸再選工具）、再進 <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> 看具體 benchmark 設計。</p>
]]></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><item><title>pgvector Deep Dive：HNSW / IVFFlat 取捨跟跟專業 Vector DB 對比</title><link>https://tarrragon.github.io/blog/backend/01-database/vendors/postgresql/pgvector-deep-dive/</link><pubDate>Tue, 19 May 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/backend/01-database/vendors/postgresql/pgvector-deep-dive/</guid><description>&lt;blockquote>
&lt;p>本文是 &lt;a href="https://tarrragon.github.io/blog/backend/01-database/vendors/postgresql/" data-link-title="PostgreSQL" data-link-desc="多用途 OLTP 主流關聯式資料庫、MVCC、豐富 SQL 特性、是 Aurora / Cosmos DB / Spanner / CockroachDB / Aurora DSQL 的相容目標">PostgreSQL&lt;/a> overview 的 implementation-layer deep article。Overview 已說明 PG 在 OLTP 譜系的定位、本文聚焦 &lt;em>pgvector extension&lt;/em> — 用 PG 解 vector search workload 的路徑、是 &lt;a href="https://tarrragon.github.io/blog/backend/01-database/vendors/postgresql/extension-ecosystem/" data-link-title="PostgreSQL Extension Ecosystem：把 PG 變成 vector DB / time-series / sharded 的 plugin 生態" data-link-desc="PG 的 extension 機制不只是 plugin、是 *結構性產品線擴張* — pgvector 讓 PG 變 vector DB、TimescaleDB 變 time-series、Citus 變 sharded、PostGIS 變 GIS。本文走 PG extension lifecycle、6 個 production-critical extension（pg_stat_statements / pg_partman / pg_repack / pgvector / TimescaleDB / PostGIS）、5 production 踩雷（extension version 跟 PG version 對齊 / managed PG 限制 / upgrade order / shared_preload_libraries 衝突 / extension 跟 logical replication 互動）、cloud vendor 對 extension 的限制">extension-ecosystem&lt;/a> 內最受關注的 extension。&lt;/p>&lt;/blockquote>
&lt;hr>
&lt;h2 id="pgvector-是-pg-變-vector-db-的最短路徑">pgvector 是 PG 變 Vector DB 的最短路徑&lt;/h2>
&lt;p>pgvector 加兩件事：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-sql" data-lang="sql">&lt;span class="line">&lt;span class="ln"> 1&lt;/span>&lt;span class="cl">&lt;span class="k">CREATE&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">EXTENSION&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">vector&lt;/span>&lt;span class="p">;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 2&lt;/span>&lt;span class="cl">&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 3&lt;/span>&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="c1">-- 加 vector column（dimension 必須事先決定）
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 4&lt;/span>&lt;span class="cl">&lt;span class="c1">&lt;/span>&lt;span class="k">CREATE&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">TABLE&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">documents&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 5&lt;/span>&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="n">id&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="nb">SERIAL&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">PRIMARY&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">KEY&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 6&lt;/span>&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="n">content&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="nb">TEXT&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 7&lt;/span>&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="n">embedding&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">vector&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="mi">1536&lt;/span>&lt;span class="p">)&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="c1">-- OpenAI ada-002 維度
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 8&lt;/span>&lt;span class="cl">&lt;span class="c1">&lt;/span>&lt;span class="p">);&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 9&lt;/span>&lt;span class="cl">&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">10&lt;/span>&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="c1">-- 三種 distance operator
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">11&lt;/span>&lt;span class="cl">&lt;span class="c1">&lt;/span>&lt;span class="k">SELECT&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">*&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">FROM&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">documents&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">ORDER&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">BY&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">embedding&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">&amp;lt;-&amp;gt;&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s1">&amp;#39;[0.1, 0.2, ...]&amp;#39;&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">LIMIT&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="mi">10&lt;/span>&lt;span class="p">;&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="c1">-- L2
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">12&lt;/span>&lt;span class="cl">&lt;span class="c1">&lt;/span>&lt;span class="k">SELECT&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">*&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">FROM&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">documents&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">ORDER&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">BY&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">embedding&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">&amp;lt;#&amp;gt;&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s1">&amp;#39;[0.1, 0.2, ...]&amp;#39;&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">LIMIT&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="mi">10&lt;/span>&lt;span class="p">;&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="c1">-- inner product
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">13&lt;/span>&lt;span class="cl">&lt;span class="c1">&lt;/span>&lt;span class="k">SELECT&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">*&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">FROM&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">documents&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">ORDER&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">BY&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">embedding&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">&amp;lt;=&amp;gt;&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s1">&amp;#39;[0.1, 0.2, ...]&amp;#39;&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">LIMIT&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="mi">10&lt;/span>&lt;span class="p">;&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="c1">-- cosine&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Operator 對應：&lt;/p></description><content:encoded><![CDATA[<blockquote>
<p>本文是 <a href="/blog/backend/01-database/vendors/postgresql/" data-link-title="PostgreSQL" data-link-desc="多用途 OLTP 主流關聯式資料庫、MVCC、豐富 SQL 特性、是 Aurora / Cosmos DB / Spanner / CockroachDB / Aurora DSQL 的相容目標">PostgreSQL</a> overview 的 implementation-layer deep article。Overview 已說明 PG 在 OLTP 譜系的定位、本文聚焦 <em>pgvector extension</em> — 用 PG 解 vector search workload 的路徑、是 <a href="/blog/backend/01-database/vendors/postgresql/extension-ecosystem/" data-link-title="PostgreSQL Extension Ecosystem：把 PG 變成 vector DB / time-series / sharded 的 plugin 生態" data-link-desc="PG 的 extension 機制不只是 plugin、是 *結構性產品線擴張* — pgvector 讓 PG 變 vector DB、TimescaleDB 變 time-series、Citus 變 sharded、PostGIS 變 GIS。本文走 PG extension lifecycle、6 個 production-critical extension（pg_stat_statements / pg_partman / pg_repack / pgvector / TimescaleDB / PostGIS）、5 production 踩雷（extension version 跟 PG version 對齊 / managed PG 限制 / upgrade order / shared_preload_libraries 衝突 / extension 跟 logical replication 互動）、cloud vendor 對 extension 的限制">extension-ecosystem</a> 內最受關注的 extension。</p></blockquote>
<hr>
<h2 id="pgvector-是-pg-變-vector-db-的最短路徑">pgvector 是 PG 變 Vector DB 的最短路徑</h2>
<p>pgvector 加兩件事：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-sql" data-lang="sql"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="k">CREATE</span><span class="w"> </span><span class="n">EXTENSION</span><span class="w"> </span><span class="n">vector</span><span class="p">;</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="ln"> 3</span><span class="cl"><span class="w"></span><span class="c1">-- 加 vector column（dimension 必須事先決定）
</span></span></span><span class="line"><span class="ln"> 4</span><span class="cl"><span class="c1"></span><span class="k">CREATE</span><span class="w"> </span><span class="k">TABLE</span><span class="w"> </span><span class="n">documents</span><span class="w"> </span><span class="p">(</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 5</span><span class="cl"><span class="w">    </span><span class="n">id</span><span class="w"> </span><span class="nb">SERIAL</span><span class="w"> </span><span class="k">PRIMARY</span><span class="w"> </span><span class="k">KEY</span><span class="p">,</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 6</span><span class="cl"><span class="w">    </span><span class="n">content</span><span class="w"> </span><span class="nb">TEXT</span><span class="p">,</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 7</span><span class="cl"><span class="w">    </span><span class="n">embedding</span><span class="w"> </span><span class="n">vector</span><span class="p">(</span><span class="mi">1536</span><span class="p">)</span><span class="w">  </span><span class="c1">-- OpenAI ada-002 維度
</span></span></span><span class="line"><span class="ln"> 8</span><span class="cl"><span class="c1"></span><span class="p">);</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 9</span><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="ln">10</span><span class="cl"><span class="w"></span><span class="c1">-- 三種 distance operator
</span></span></span><span class="line"><span class="ln">11</span><span class="cl"><span class="c1"></span><span class="k">SELECT</span><span class="w"> </span><span class="o">*</span><span class="w"> </span><span class="k">FROM</span><span class="w"> </span><span class="n">documents</span><span class="w"> </span><span class="k">ORDER</span><span class="w"> </span><span class="k">BY</span><span class="w"> </span><span class="n">embedding</span><span class="w"> </span><span class="o">&lt;-&gt;</span><span class="w"> </span><span class="s1">&#39;[0.1, 0.2, ...]&#39;</span><span class="w"> </span><span class="k">LIMIT</span><span class="w"> </span><span class="mi">10</span><span class="p">;</span><span class="w">  </span><span class="c1">-- L2
</span></span></span><span class="line"><span class="ln">12</span><span class="cl"><span class="c1"></span><span class="k">SELECT</span><span class="w"> </span><span class="o">*</span><span class="w"> </span><span class="k">FROM</span><span class="w"> </span><span class="n">documents</span><span class="w"> </span><span class="k">ORDER</span><span class="w"> </span><span class="k">BY</span><span class="w"> </span><span class="n">embedding</span><span class="w"> </span><span class="o">&lt;#&gt;</span><span class="w"> </span><span class="s1">&#39;[0.1, 0.2, ...]&#39;</span><span class="w"> </span><span class="k">LIMIT</span><span class="w"> </span><span class="mi">10</span><span class="p">;</span><span class="w">  </span><span class="c1">-- inner product
</span></span></span><span class="line"><span class="ln">13</span><span class="cl"><span class="c1"></span><span class="k">SELECT</span><span class="w"> </span><span class="o">*</span><span class="w"> </span><span class="k">FROM</span><span class="w"> </span><span class="n">documents</span><span class="w"> </span><span class="k">ORDER</span><span class="w"> </span><span class="k">BY</span><span class="w"> </span><span class="n">embedding</span><span class="w"> </span><span class="o">&lt;=&gt;</span><span class="w"> </span><span class="s1">&#39;[0.1, 0.2, ...]&#39;</span><span class="w"> </span><span class="k">LIMIT</span><span class="w"> </span><span class="mi">10</span><span class="p">;</span><span class="w">  </span><span class="c1">-- cosine</span></span></span></code></pre></div><p>Operator 對應：</p>
<table>
  <thead>
      <tr>
          <th>Operator</th>
          <th>意義</th>
          <th>適用</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><code>&lt;-&gt;</code></td>
          <td>L2 distance</td>
          <td>通用、空間距離</td>
      </tr>
      <tr>
          <td><code>&lt;#&gt;</code></td>
          <td>Negative inner product</td>
          <td>normalized vector、cosine 等價</td>
      </tr>
      <tr>
          <td><code>&lt;=&gt;</code></td>
          <td>Cosine distance</td>
          <td>embedding 比較最常用</td>
      </tr>
  </tbody>
</table>
<p>對 OpenAI / Cohere / sentence-transformers embedding、通常用 <code>&lt;=&gt;</code>（cosine）— embedding model 訓練時是 cosine objective。</p>
<h2 id="ann-index-是-vector-search-的核心">ANN Index 是 Vector Search 的核心</h2>
<p>不加 index 的 <code>ORDER BY embedding &lt;=&gt; ?</code> 是 <em>full scan</em>：</p>
<ul>
<li>100K row、1536 dim、每 query ~2-5s（不可用）</li>
<li>1M row 直接超時</li>
</ul>
<p>pgvector 提供兩種 <em>Approximate Nearest Neighbor</em>（ANN）index：</p>
<table>
  <thead>
      <tr>
          <th>Index</th>
          <th>Build 時間</th>
          <th>Query 時間</th>
          <th>Recall@10</th>
          <th>Memory cost</th>
          <th>Update 行為</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>IVFFlat</td>
          <td>快（分鐘級）</td>
          <td>中（10-100ms）</td>
          <td>90-95%</td>
          <td>中（lists 數量）</td>
          <td>Insert OK、需重建保持 recall</td>
      </tr>
      <tr>
          <td>HNSW</td>
          <td>慢（小時級）</td>
          <td>快（1-10ms）</td>
          <td>95-99%</td>
          <td>高（2-4x 資料）</td>
          <td>Insert OK、graph 漸進維護</td>
      </tr>
  </tbody>
</table>
<p><strong>選 IVFFlat 的場景</strong>：</p>
<ul>
<li>Embedding 量 &lt; 1M</li>
<li>Build 時間敏感（CI / batch 環境）</li>
<li>Memory 緊</li>
<li>接受重建 cost（每月 / 每季）</li>
</ul>
<p><strong>選 HNSW 的場景</strong>：</p>
<ul>
<li>Embedding 量 1M-100M</li>
<li>Query latency &lt; 50ms 要求</li>
<li>Memory 充足</li>
<li>Insert 量穩定（不會爆炸性增長）</li>
</ul>
<h2 id="ivfflat分-cluster-找鄰居">IVFFlat：分 Cluster 找鄰居</h2>
<p>IVFFlat 機制：</p>
<ol>
<li><strong>Build</strong>：跑 k-means 把所有 vector 分 <code>lists</code> 個 cluster</li>
<li><strong>Query</strong>：先找最近的 <code>probes</code> 個 cluster、再在這些 cluster 內找 nearest neighbor</li>
</ol>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-sql" data-lang="sql"><span class="line"><span class="ln">1</span><span class="cl"><span class="c1">-- Build（lists 數量重要）
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="c1"></span><span class="k">CREATE</span><span class="w"> </span><span class="k">INDEX</span><span class="w"> </span><span class="k">ON</span><span class="w"> </span><span class="n">documents</span><span class="w"> </span><span class="k">USING</span><span class="w"> </span><span class="n">ivfflat</span><span class="w"> </span><span class="p">(</span><span class="n">embedding</span><span class="w"> </span><span class="n">vector_cosine_ops</span><span class="p">)</span><span class="w"> </span><span class="k">WITH</span><span class="w"> </span><span class="p">(</span><span class="n">lists</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="mi">100</span><span class="p">);</span><span class="w">
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="w"></span><span class="c1">-- Query 時調 probes 換 recall vs latency
</span></span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="c1"></span><span class="k">SET</span><span class="w"> </span><span class="n">ivfflat</span><span class="p">.</span><span class="n">probes</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="mi">10</span><span class="p">;</span><span class="w">
</span></span></span><span class="line"><span class="ln">6</span><span class="cl"><span class="w"></span><span class="k">SELECT</span><span class="w"> </span><span class="o">*</span><span class="w"> </span><span class="k">FROM</span><span class="w"> </span><span class="n">documents</span><span class="w"> </span><span class="k">ORDER</span><span class="w"> </span><span class="k">BY</span><span class="w"> </span><span class="n">embedding</span><span class="w"> </span><span class="o">&lt;=&gt;</span><span class="w"> </span><span class="o">?</span><span class="w"> </span><span class="k">LIMIT</span><span class="w"> </span><span class="mi">10</span><span class="p">;</span></span></span></code></pre></div><p><strong>Lists 跟 probes sizing 規則</strong>（pgvector 官方建議）：</p>
<table>
  <thead>
      <tr>
          <th>Row count</th>
          <th>lists 建議</th>
          <th>probes 建議</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>&lt; 1M</td>
          <td><code>rows / 1000</code></td>
          <td><code>sqrt(lists)</code></td>
      </tr>
      <tr>
          <td>&gt; 1M</td>
          <td><code>sqrt(rows)</code></td>
          <td><code>sqrt(lists)</code></td>
      </tr>
  </tbody>
</table>
<p>實務：100K row → lists=100 / probes=10、1M row → lists=1000 / probes=32。</p>
<p><strong>IVFFlat 的 recall drift</strong>：cluster 是 build 時固定的、新 insert 的 vector 進入「最近 cluster」、但隨資料分布改變、cluster center 可能不再代表性、recall 隨時間下降。</p>
<p>修法：定期 <code>REINDEX INDEX CONCURRENTLY ...</code>（每月 / 每 100K 新 row）。</p>
<h2 id="hnswmulti-level-graph-找鄰居">HNSW：Multi-level Graph 找鄰居</h2>
<p>HNSW（Hierarchical Navigable Small World）機制：</p>
<ol>
<li>多層 graph、上層稀疏、下層密集</li>
<li>Query 從上層 entry point 開始、逐層找近鄰、最後在底層精細搜尋</li>
<li>Insert 漸進維護 graph、不必重建</li>
</ol>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-sql" data-lang="sql"><span class="line"><span class="ln">1</span><span class="cl"><span class="c1">-- Build（兩個關鍵參數）
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="c1"></span><span class="k">CREATE</span><span class="w"> </span><span class="k">INDEX</span><span class="w"> </span><span class="k">ON</span><span class="w"> </span><span class="n">documents</span><span class="w"> </span><span class="k">USING</span><span class="w"> </span><span class="n">hnsw</span><span class="w"> </span><span class="p">(</span><span class="n">embedding</span><span class="w"> </span><span class="n">vector_cosine_ops</span><span class="p">)</span><span class="w">
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="w"></span><span class="k">WITH</span><span class="w"> </span><span class="p">(</span><span class="n">m</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="mi">16</span><span class="p">,</span><span class="w"> </span><span class="n">ef_construction</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="mi">64</span><span class="p">);</span><span class="w">
</span></span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="w"></span><span class="c1">-- Query 時調 ef_search
</span></span></span><span class="line"><span class="ln">6</span><span class="cl"><span class="c1"></span><span class="k">SET</span><span class="w"> </span><span class="n">hnsw</span><span class="p">.</span><span class="n">ef_search</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="mi">100</span><span class="p">;</span><span class="w">
</span></span></span><span class="line"><span class="ln">7</span><span class="cl"><span class="w"></span><span class="k">SELECT</span><span class="w"> </span><span class="o">*</span><span class="w"> </span><span class="k">FROM</span><span class="w"> </span><span class="n">documents</span><span class="w"> </span><span class="k">ORDER</span><span class="w"> </span><span class="k">BY</span><span class="w"> </span><span class="n">embedding</span><span class="w"> </span><span class="o">&lt;=&gt;</span><span class="w"> </span><span class="o">?</span><span class="w"> </span><span class="k">LIMIT</span><span class="w"> </span><span class="mi">10</span><span class="p">;</span></span></span></code></pre></div><p><strong>參數含義</strong>：</p>
<table>
  <thead>
      <tr>
          <th>參數</th>
          <th>含義</th>
          <th>預設</th>
          <th>Trade-off</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><code>m</code></td>
          <td>每 node 最多鄰居數</td>
          <td>16</td>
          <td>大 → recall 高、memory 多</td>
      </tr>
      <tr>
          <td><code>ef_construction</code></td>
          <td>Build 時 graph 質量參數</td>
          <td>64</td>
          <td>大 → build 慢、graph 質量好</td>
      </tr>
      <tr>
          <td><code>ef_search</code></td>
          <td>Query 時搜尋範圍</td>
          <td>40</td>
          <td>大 → recall 高、latency 高</td>
      </tr>
  </tbody>
</table>
<p><strong>Build cost 真實量級</strong>（1M vector × 1536 dim）：</p>
<table>
  <thead>
      <tr>
          <th>配置</th>
          <th>Build 時間</th>
          <th>Memory</th>
          <th>Recall@10</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>m=8, ef_construction=32</td>
          <td>30 min</td>
          <td>4GB</td>
          <td>92%</td>
      </tr>
      <tr>
          <td>m=16, ef_construction=64</td>
          <td>2 hour</td>
          <td>8GB</td>
          <td>96%</td>
      </tr>
      <tr>
          <td>m=32, ef_construction=200</td>
          <td>8 hour</td>
          <td>16GB</td>
          <td>98%</td>
      </tr>
  </tbody>
</table>
<p>Production 多數選中間 <code>m=16, ef_construction=64</code>、recall / cost 平衡。</p>
<h2 id="hybrid-searchvector--filter-一起">Hybrid Search：Vector + Filter 一起</h2>
<p>Vector search 加 SQL filter 是 pgvector 比專業 vector DB 強的場景：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-sql" data-lang="sql"><span class="line"><span class="ln">1</span><span class="cl"><span class="c1">-- Vector + metadata filter
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="c1"></span><span class="k">SELECT</span><span class="w"> </span><span class="o">*</span><span class="w"> </span><span class="k">FROM</span><span class="w"> </span><span class="n">documents</span><span class="w">
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="w"></span><span class="k">WHERE</span><span class="w"> </span><span class="n">category</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="s1">&#39;tech&#39;</span><span class="w"> </span><span class="k">AND</span><span class="w"> </span><span class="n">created_at</span><span class="w"> </span><span class="o">&gt;</span><span class="w"> </span><span class="s1">&#39;2025-01-01&#39;</span><span class="w">
</span></span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="w"></span><span class="k">ORDER</span><span class="w"> </span><span class="k">BY</span><span class="w"> </span><span class="n">embedding</span><span class="w"> </span><span class="o">&lt;=&gt;</span><span class="w"> </span><span class="s1">&#39;[0.1, 0.2, ...]&#39;</span><span class="w">
</span></span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="w"></span><span class="k">LIMIT</span><span class="w"> </span><span class="mi">10</span><span class="p">;</span></span></span></code></pre></div><p>但這裡有個 <em>pgvector 的踩雷</em>：filter 跟 ANN index 互動有兩種模式：</p>
<ol>
<li><strong>Pre-filter</strong>（planner 選）：先 filter 出符合條件的 row、再對 subset 跑 vector ordering → 不用 ANN index、可能慢</li>
<li><strong>Post-filter</strong>：用 ANN index 找 top-N、再 filter、可能 N 不夠補</li>
</ol>
<p>pgvector 0.8+（2024-10 release）加入 <em>iterative index scan</em>：HNSW / IVFFlat 一邊掃 graph 一邊 filter、效能比 pre-filter 好 5-10x。0.7+（2024-07）加 halfvec / binary quantization / parallel HNSW build。</p>
<p>實務：filter selectivity 高（&lt; 10%）時、考慮對 filter column 加 index 走 pre-filter；selectivity 低（&gt; 50%）走 iterative scan。</p>
<h2 id="quantization-跟-dimension-reduction">Quantization 跟 Dimension Reduction</h2>
<p>1536 dim float32 vector 一筆 6KB、1M row 6GB、加 HNSW index 後 ~20GB。Memory 緊時的省法：</p>
<h3 id="half-precisionpgvector-07">Half-precision（pgvector 0.7+）</h3>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-sql" data-lang="sql"><span class="line"><span class="ln">1</span><span class="cl"><span class="k">CREATE</span><span class="w"> </span><span class="k">TABLE</span><span class="w"> </span><span class="n">documents</span><span class="w"> </span><span class="p">(</span><span class="w">
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="w">    </span><span class="n">embedding</span><span class="w"> </span><span class="n">halfvec</span><span class="p">(</span><span class="mi">1536</span><span class="p">)</span><span class="w">
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="w"></span><span class="p">);</span></span></span></code></pre></div><p><code>halfvec</code> 是 float16、storage 減半、recall 損失通常 &lt; 1%。</p>
<h3 id="binary-quantization">Binary quantization</h3>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-sql" data-lang="sql"><span class="line"><span class="ln">1</span><span class="cl"><span class="c1">-- 把每維壓成 1 bit
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="c1"></span><span class="k">CREATE</span><span class="w"> </span><span class="k">INDEX</span><span class="w"> </span><span class="k">ON</span><span class="w"> </span><span class="n">documents</span><span class="w"> </span><span class="k">USING</span><span class="w"> </span><span class="n">hnsw</span><span class="w"> </span><span class="p">(</span><span class="n">embedding</span><span class="w"> </span><span class="n">bit_hamming_ops</span><span class="p">);</span></span></span></code></pre></div><p>Recall 下降明顯（85-90%）、但 storage 1/32、適合「先粗篩再 rerank」hybrid pipeline。</p>
<h3 id="dimension-reduction">Dimension reduction</h3>
<p>訓練 PCA / Matryoshka model 把 1536 dim 降到 256-512 dim、recall 通常損失 &lt; 3%、storage 1/3-1/6。</p>
<h2 id="5-個-production-踩雷">5 個 Production 踩雷</h2>
<h3 id="case-1dimension-超-2000-限制">Case 1：Dimension 超 2000 限制</h3>
<p><strong>情境</strong>：要用 OpenAI text-embedding-3-large（3072 dim）、<code>CREATE TABLE ... embedding vector(3072)</code> 報錯。</p>
<p>pgvector <code>vector</code> type 上限 2000 dim（IVFFlat / HNSW index 限制）。</p>
<p>修法：</p>
<ul>
<li>改用 <code>halfvec</code>（pgvector 0.7+ 支援 4000 dim）</li>
<li>用 Matryoshka 截斷到 2000 dim 以下</li>
<li>換 embedding model（OpenAI text-embedding-3-small 1536 dim / 可截斷到 256-1024）</li>
</ul>
<h3 id="case-2hnsw-build-太慢">Case 2：HNSW build 太慢</h3>
<p><strong>情境</strong>：1M row build HNSW、跑 8 小時、blocking production。</p>
<p>修法：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-sql" data-lang="sql"><span class="line"><span class="ln">1</span><span class="cl"><span class="c1">-- 用 CONCURRENTLY 不 block
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="c1"></span><span class="k">CREATE</span><span class="w"> </span><span class="k">INDEX</span><span class="w"> </span><span class="n">CONCURRENTLY</span><span class="w"> </span><span class="k">ON</span><span class="w"> </span><span class="n">documents</span><span class="w"> </span><span class="k">USING</span><span class="w"> </span><span class="n">hnsw</span><span class="w"> </span><span class="p">(...);</span><span class="w">
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="w"></span><span class="c1">-- 開 maintenance_work_mem
</span></span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="c1"></span><span class="k">SET</span><span class="w"> </span><span class="n">maintenance_work_mem</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="s1">&#39;8GB&#39;</span><span class="p">;</span><span class="w">
</span></span></span><span class="line"><span class="ln">6</span><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="ln">7</span><span class="cl"><span class="w"></span><span class="c1">-- 開 parallel
</span></span></span><span class="line"><span class="ln">8</span><span class="cl"><span class="c1"></span><span class="k">SET</span><span class="w"> </span><span class="n">max_parallel_maintenance_workers</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="mi">7</span><span class="p">;</span></span></span></code></pre></div><p>仍慢的話、考慮：</p>
<ul>
<li>切分 batch insert + index（適合 read-heavy）</li>
<li>用 IVFFlat 短期上線、之後再切 HNSW</li>
<li>改用 cloud managed pgvector（提供更大 instance）</li>
</ul>
<h3 id="case-3ivfflat-不重建-recall-漂移">Case 3：IVFFlat 不重建 recall 漂移</h3>
<p><strong>情境</strong>：IVFFlat build 時資料 100K、現在 500K、新資料 recall 從 92% 降到 75%、user 抱怨「找不到相關文件」。</p>
<p>修法：</p>
<ul>
<li>Monitor recall：定期跑 ground-truth eval（brute-force 對比）</li>
<li>設定 reindex policy：每 100K 新 row 或每月 reindex</li>
<li>換 HNSW：insert 漸進維護、不需 reindex（trade-off：build 更慢）</li>
</ul>
<h3 id="case-4hybrid-search-filter-selectivity-沒設計">Case 4：Hybrid search filter selectivity 沒設計</h3>
<p><strong>情境</strong>：query <code>WHERE user_id = ? ORDER BY embedding &lt;=&gt; ?</code>、user_id 高選擇性（1/1M）、planner 選 vector index scan、掃到 top-K 全不符 user_id、補抓無止盡。</p>
<p>修法：</p>
<ul>
<li><code>EXPLAIN</code> 看 planner 選 pre-filter 還是 vector-first</li>
<li>對 <code>user_id</code> 加 B-tree index、強 planner pre-filter（hint 不容易、用 statistics）</li>
<li>pgvector 0.8+ 用 iterative scan、自動處理</li>
<li>設計 schema：高選擇性 filter（user_id）建議走 pre-filter；低選擇性（category）走 iterative</li>
</ul>
<h3 id="case-5memory-budget-沒抓">Case 5：Memory budget 沒抓</h3>
<p><strong>情境</strong>：1M vector × 1536 dim × HNSW（m=16）= ~12GB index、shared_buffers 8GB、index 不在 cache、每 query disk IO、latency 100ms+。</p>
<p>修法：</p>
<ul>
<li>算 vector + index memory：<code>row × dim × 4 bytes × (1 + index_overhead)</code></li>
<li><code>shared_buffers</code> 至少能放 hot index portion</li>
<li>不行就降 dim（halfvec）/ 升 instance / 拆 sharded</li>
</ul>
<h2 id="跟專業-vector-db-對比">跟專業 Vector DB 對比</h2>
<table>
  <thead>
      <tr>
          <th>維度</th>
          <th>pgvector</th>
          <th>Pinecone</th>
          <th>Weaviate</th>
          <th>Milvus</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Query 介面</td>
          <td>SQL</td>
          <td>REST/gRPC API</td>
          <td>GraphQL / REST</td>
          <td>gRPC</td>
      </tr>
      <tr>
          <td>Recall</td>
          <td>95-99%（HNSW）</td>
          <td>95-99%</td>
          <td>95-99%</td>
          <td>95-99%</td>
      </tr>
      <tr>
          <td>Throughput</td>
          <td>中（PG 限制）</td>
          <td>高</td>
          <td>高</td>
          <td>高</td>
      </tr>
      <tr>
          <td>Hybrid search</td>
          <td>強（完整 SQL）</td>
          <td>中（metadata filter）</td>
          <td>中</td>
          <td>中</td>
      </tr>
      <tr>
          <td>跟既有 PG 整合</td>
          <td>完美（同 DB join）</td>
          <td>需 sync</td>
          <td>需 sync</td>
          <td>需 sync</td>
      </tr>
      <tr>
          <td>Multi-tenant</td>
          <td>row-level（PG 一致）</td>
          <td>內建</td>
          <td>內建</td>
          <td>partition</td>
      </tr>
      <tr>
          <td>Open source</td>
          <td>是</td>
          <td>否</td>
          <td>是</td>
          <td>是</td>
      </tr>
      <tr>
          <td>Operational cost</td>
          <td>跟 PG 一樣（管 PG 即可）</td>
          <td>Managed-only</td>
          <td>需自管或 cloud</td>
          <td>需自管或 cloud</td>
      </tr>
      <tr>
          <td>Scale 上限</td>
          <td>10M-100M vector</td>
          <td>10B+</td>
          <td>1B+</td>
          <td>10B+</td>
      </tr>
  </tbody>
</table>
<p><strong>選 pgvector 的場景</strong>：</p>
<ul>
<li>Application 已用 PG、不想多管系統</li>
<li>Vector 量 &lt; 100M</li>
<li>需要 join vector + relational</li>
<li>Team SQL 熟、不想學 API SDK</li>
<li>Cost 敏感（managed Pinecone 1M vector 月 ~$70+）</li>
</ul>
<p><strong>選專業 vector DB 的場景</strong>：</p>
<ul>
<li>Vector 量 &gt; 5-20M（依 dim / QPS / recall 要求、pgvector 在這個級別 + 高 QPS 已開始痛、不必撐到 100M 才換）</li>
<li>純 vector workload（沒 relational integration）</li>
<li>需要 multi-tenant SaaS</li>
<li>Throughput 要求極高（&gt; 10K QPS）</li>
<li>不想自管 HNSW build / memory budget / recall drift（managed Pinecone 把這層 ops 轉嫁、cost 換 ops 時間）</li>
<li>需要 dim &gt; 2000（pgvector vector type 限制、halfvec 可到 4000、再大需 dimension reduction）</li>
</ul>
<h2 id="相關連結">相關連結</h2>
<ul>
<li><a href="/blog/backend/01-database/vendors/postgresql/extension-ecosystem/" data-link-title="PostgreSQL Extension Ecosystem：把 PG 變成 vector DB / time-series / sharded 的 plugin 生態" data-link-desc="PG 的 extension 機制不只是 plugin、是 *結構性產品線擴張* — pgvector 讓 PG 變 vector DB、TimescaleDB 變 time-series、Citus 變 sharded、PostGIS 變 GIS。本文走 PG extension lifecycle、6 個 production-critical extension（pg_stat_statements / pg_partman / pg_repack / pgvector / TimescaleDB / PostGIS）、5 production 踩雷（extension version 跟 PG version 對齊 / managed PG 限制 / upgrade order / shared_preload_libraries 衝突 / extension 跟 logical replication 互動）、cloud vendor 對 extension 的限制">extension-ecosystem</a>：其他 PG extension</li>
<li><a href="/blog/backend/01-database/vendors/postgresql/jsonb-deep-dive/" data-link-title="PostgreSQL JSONB Deep Dive：Binary Storage &#43; GIN Index 為什麼是結構性優勢" data-link-desc="PG JSONB（9.4&#43;）是 *binary 儲存的 JSON*、可直接 GIN index、是 PG 在 JSON workload 的結構性優勢、跟 MongoDB / MySQL 8.0 JSON_TABLE 比仍領先。本文走 JSON vs JSONB 差異、GIN index 機制（jsonb_ops vs jsonb_path_ops）、operator &#43; path query、partial JSONB indexing、5 production 踩雷（大 JSONB 跟 TOAST / nested update / index 選錯 op class / jsonb_path_query 跟 jsonb_path_exists 行為差 / partial index 條件搞錯）、何時用 JSONB vs 拆 column">jsonb-deep-dive</a>：embedding 通常配 metadata JSONB</li>
<li><a href="/blog/backend/01-database/vendors/postgresql/index-selection/" data-link-title="PostgreSQL Index Selection：B-tree / GIN / GiST / BRIN / Hash 對應 workload 的決策樹" data-link-desc="PG 有 6 種 index method（B-tree / Hash / GIN / GiST / SP-GiST / BRIN）跟 partial / expression / covering 三種變體、不是「都用 B-tree 就好」。每種 index 有自己的 query pattern、儲存代價、write amplification 跟 maintenance 成本。本文走 6 種 index 的適用 workload 對照、決策樹、partial / expression / covering / multi-column 變體、5 production 踩雷（過度 index / partial 條件不對 / B-tree 對 JSON 無效 / BRIN 對非 correlated 資料無效 / multi-column 順序錯）、跟 query-optimization 的 EXPLAIN 互補">index-selection</a>：B-tree / GIN / HNSW 整體比較</li>
<li><a href="/blog/backend/01-database/vendors/postgresql/query-optimization/" data-link-title="PostgreSQL Query Optimization：EXPLAIN ANALYZE / pg_hint_plan / auto_explain 三層工具跟 4 個 case" data-link-desc="PG query 慢的根因常是 *planner 選錯 plan 或 statistics 過時*。本文從 4 個 production case 開場（seq scan vs index / hash vs nested loop / 多 column 統計缺 / parallel query 沒觸發）、走 EXPLAIN / EXPLAIN ANALYZE / auto_explain 三層工具、pg_hint_plan extension 跟 planner GUC 取捨、5 production 踩雷（ANALYZE 過時 / multi-column statistics / cost-base setting 不對齊硬體 / random_page_cost SSD 沒調 / parallel query 配置）、跟 MySQL query-optimization sibling 對比">query-optimization</a>：vector query 的 EXPLAIN</li>
</ul>
<h2 id="下一步">下一步</h2>
<ul>
<li>看 <a href="/blog/backend/01-database/vendors/postgresql/extension-ecosystem/" data-link-title="PostgreSQL Extension Ecosystem：把 PG 變成 vector DB / time-series / sharded 的 plugin 生態" data-link-desc="PG 的 extension 機制不只是 plugin、是 *結構性產品線擴張* — pgvector 讓 PG 變 vector DB、TimescaleDB 變 time-series、Citus 變 sharded、PostGIS 變 GIS。本文走 PG extension lifecycle、6 個 production-critical extension（pg_stat_statements / pg_partman / pg_repack / pgvector / TimescaleDB / PostGIS）、5 production 踩雷（extension version 跟 PG version 對齊 / managed PG 限制 / upgrade order / shared_preload_libraries 衝突 / extension 跟 logical replication 互動）、cloud vendor 對 extension 的限制">extension-ecosystem</a> 探索其他 PG 擴展可能</li>
<li>回 <a href="/blog/backend/01-database/vendors/postgresql/" data-link-title="PostgreSQL" data-link-desc="多用途 OLTP 主流關聯式資料庫、MVCC、豐富 SQL 特性、是 Aurora / Cosmos DB / Spanner / CockroachDB / Aurora DSQL 的相容目標">PostgreSQL overview</a> 看全圖</li>
</ul>
]]></content:encoded></item></channel></rss>