AG Day 20 RAG 基礎:embedding 與向量檢索
執行需求:CPU 可跑。在昨天 AG Day 19(原文連結)中,我們讓 research-agent 具備了即時串流輸出的能力,使用者終於能看到代理正在做什麼,而不是對著空白畫面乾等。但代理取得資訊的方式,一直只有「呼叫搜尋工具、拿回一段文字」這一種,每次研究同一個主題都要重新查詢,過去蒐集過的資料完全沒有被留下來、被再利用。今天我們要開始建立檢索增強生成(Retrieval-Augmented Generation,RAG)的地基:把文字轉換成向量(embedding),並學會用向量之間的相似度做語意檢索。今天全部範例都在本機 CPU 上執行,不需要任何 API 金鑰。
引言
RAG 這個詞近幾年被大量使用,但拆開來看,它其實只是兩件事的組合:先「檢索」出跟問題相關的資料,再把這些資料連同問題一起交給語言模型「生成」答案。它要解決的問題很實際:語言模型的知識來自訓練資料,訓練截止日期之後發生的事、或是只存在於你自己的私有文件裡的資訊,模型天生就不知道。與其奢望模型什麼都記得,不如在提問前,先幫模型把「可能用得上的相關資料」找出來、放進提示詞裡,模型只需要根據這些資料組織出通順的答案,而不需要自己「回想」。
要做到「找出相關資料」,傳統的關鍵字比對(例如 SQL 的 LIKE)有個明顯限制:使用者的問題用詞跟原始文件的用詞不一定一樣。使用者問「離岸風電的環境衝擊」,原始文件可能寫的是「海上風力發電對海洋生態的影響」,兩段文字幾乎沒有共同的關鍵字,但語意上高度相關。embedding(向量化)技術正是為了解決這個落差:它會用一個訓練好的模型,把一段文字轉換成一組固定長度的數字(向量),而且語意相近的文字,轉換出來的向量在數學空間裡的距離也會相近。今天我們要做的,就是讓 research-agent 具備「把文字轉成向量、把向量存起來、之後能用新的查詢向量去找出最相近的舊資料」這一整套能力。
原理與觀念
embedding:把文字映射成一個高維空間裡的座標
你可以把 embedding 想像成幫每一段文字在一個高維度的空間裡標記一個座標點。這個座標通常是由幾百到一千多個浮點數所構成的向量,具體維度依模型而定。訓練過程中,模型看過大量文字後,學會把語意相近的句子放在座標空間裡相近的位置、語意無關的句子放得遠遠的。今天我們使用 sentence-transformers 這個開源套件提供的預訓練模型,它可以直接把一段中文或英文句子轉成向量,不需要自己從頭訓練。
用餘弦相似度衡量兩個向量有多接近
有了向量之後,怎麼判斷「這兩段文字語意接近不接近」?最常用的指標是餘弦相似度(cosine similarity):把兩個向量的夾角餘弦值算出來,數值介於 -1 到 1 之間,越接近 1 代表兩個向量方向越一致、語意越相近;越接近 0 代表兩者幾乎無關;負值則代表語意方向相反(在文字語意的場景比較少見)。餘弦相似度只關心向量的「方向」,不太受向量長度影響,這也是為什麼很多向量檢索系統會先把向量正規化(normalize,讓長度統一變成 1),這樣算餘弦相似度時可以直接用內積,計算起來更快。
向量資料庫:Chroma 的基本角色
如果只有幾十筆資料,把所有向量存進一個 Python 串列、每次查詢都跟全部向量算一次相似度,效能完全沒問題。但當資料量成長到幾萬、幾十萬筆,逐一比對就會變得很慢。向量資料庫(vector database)的工作,就是用專門的索引結構(例如近似最近鄰搜尋演算法)加速這個「找出最相近的 K 筆」的查詢。我們在 research-agent 選用 Chroma,因為它可以完全在本機、以檔案形式運作,不需要另外架設伺服器,跟我們一直以來「先求能跑、再談規模」的原則一致。Chroma 裡的核心概念是 collection(集合),可以把它想成一張專門存放「原始文字+向量+中繼資料」的資料表,我們可以對它做新增、查詢、刪除等操作。
查詢與文件用同一個 embedding 模型的重要性
有個容易被忽略、但非常關鍵的原則:拿來查詢的問題,跟被拿來檢索的文件,必須用同一個 embedding 模型去轉換成向量,兩者的向量才會落在同一個有意義的座標空間裡,比較相似度才有意義。如果查詢用模型 A 轉成向量、文件用模型 B 轉成向量,即使兩個模型都號稱效果不錯,算出來的相似度數字也毫無意義,因為兩組向量根本不在同一個空間裡。今天我們會把這個模型名稱明確寫在設定裡,確保整個系統只用同一個模型做向量化。
完整實作
先安裝今天需要的套件:
uv pip install sentence-transformers chromadb
第一步:在 src/research_agent/retrieval.py 建立向量化的工具函式,選用一個支援中文、體積不會太大的多語言模型:
# research-agent/src/research_agent/retrieval.py
from sentence_transformers import SentenceTransformer
EMBEDDING_MODEL_NAME = "paraphrase-multilingual-MiniLM-L12-v2"
_model_cache: SentenceTransformer | None = None
def get_embedding_model() -> SentenceTransformer:
"""延遲載入 embedding 模型,並在行程內快取,避免每次呼叫都重新載入權重。"""
global _model_cache
if _model_cache is None:
_model_cache = SentenceTransformer(EMBEDDING_MODEL_NAME)
return _model_cache
def embed_texts(texts: list[str]) -> list[list[float]]:
"""把一批文字轉成向量,回傳的向量已經過正規化,方便直接用內積計算相似度。"""
model = get_embedding_model()
vectors = model.encode(texts, normalize_embeddings=True)
return vectors.tolist()
第二步:手刻一個最小可行的餘弦相似度比對,讓你在還沒接 Chroma 之前,先直觀感受向量檢索到底在做什麼:
# research-agent/demo_cosine.py
import numpy as np
from research_agent.retrieval import embed_texts
corpus = [
"離岸風電對海洋生態系統的長期影響",
"台灣半導體產業的全球供應鏈布局",
"海上風力發電廠的環境衝擊評估報告",
"咖啡豆烘焙程度對風味的影響",
]
query = "風電對海洋環境的衝擊"
corpus_vectors = np.array(embed_texts(corpus))
query_vector = np.array(embed_texts([query])[0])
similarities = corpus_vectors @ query_vector # 向量已正規化,內積即為餘弦相似度
ranked = sorted(zip(corpus, similarities), key=lambda pair: pair[1], reverse=True)
for text, score in ranked:
print(f"{score:.4f}|{text}")
示範輸出(實際數值會因模型版本略有不同,但排序方向應該一致):
0.7123|海上風力發電廠的環境衝擊評估報告
0.6544|離岸風電對海洋生態系統的長期影響
0.1892|台灣半導體產業的全球供應鏈布局
0.0731|咖啡豆烘焙程度對風味的影響
可以看到,即使查詢字串跟前兩筆文字幾乎沒有共同的關鍵字,語意相近的兩筆風電相關文字,相似度分數明顯高過完全無關的兩筆,這就是向量檢索相對於關鍵字比對的核心優勢。
第三步:把 Chroma 接上,建立一個持久化的 collection,讓向量資料存在磁碟上、行程重啟後依然能查詢:
# research-agent/src/research_agent/retrieval.py(新增 Chroma 整合)
import chromadb
from pathlib import Path
CHROMA_DIR = Path(__file__).resolve().parent.parent.parent / "data" / "chroma"
def get_collection(name: str = "research_notes"):
"""建立(或沿用)一個持久化的 Chroma collection。"""
client = chromadb.PersistentClient(path=str(CHROMA_DIR))
return client.get_or_create_collection(name=name)
def add_documents(ids: list[str], texts: list[str], metadatas: list[dict]) -> None:
"""把一批文字連同向量、中繼資料寫進 collection。"""
collection = get_collection()
vectors = embed_texts(texts)
collection.add(ids=ids, embeddings=vectors, documents=texts, metadatas=metadatas)
def search_similar(query: str, top_k: int = 3) -> list[dict]:
"""用查詢字串找出最相似的 top_k 筆結果。"""
collection = get_collection()
query_vector = embed_texts([query])[0]
result = collection.query(query_embeddings=[query_vector], n_results=top_k)
hits = []
for doc, meta, distance in zip(
result["documents"][0], result["metadatas"][0], result["distances"][0]
):
hits.append({"text": doc, "metadata": meta, "distance": distance})
return hits
第四步:寫一段驗證腳本,把幾筆示範資料寫進去,再用一句查詢測試檢索結果是否合理:
# research-agent/verify_retrieval.py
from research_agent.retrieval import add_documents, search_similar
add_documents(
ids=["doc-1", "doc-2", "doc-3"],
texts=[
"離岸風電開發需要考量對海洋生態系統的長期衝擊。",
"台灣半導體產業近年持續擴大先進製程投資。",
"海上風力發電廠選址需要評估對候鳥遷徙路線的影響。",
],
metadatas=[
{"source": "demo", "topic": "energy"},
{"source": "demo", "topic": "industry"},
{"source": "demo", "topic": "energy"},
],
)
for hit in search_similar("風力發電對生態的影響", top_k=2):
print(f"距離:{hit['distance']:.4f}|{hit['text']}|主題:{hit['metadata']['topic']}")
示範輸出:
距離:0.3120|離岸風電開發需要考量對海洋生態系統的長期衝擊。|主題:energy
距離:0.4356|海上風力發電廠選址需要評估對候鳥遷徙路線的影響。|主題:energy
注意 Chroma 預設回傳的是「距離」(distance),距離越小代表越相似,跟前面手刻範例用「相似度分數越高越相似」的方向相反,實際採用的距離定義(歐氏距離、餘弦距離等)依 Chroma 的設定與版本而定,串接前請查閱當前版本的官方文件確認公式,避免排序方向理解錯誤。
第五步:呼應前面提到「換模型就要重新向量化整個集合」的原則,我們寫一個小工具函式,在寫入新文件前先檢查目前 collection 裡既有向量的維度,與這次要寫入的向量維度是否一致,不一致就主動擋下來、提醒開發者不要把兩種模型的向量混在同一個集合裡:
# research-agent/src/research_agent/retrieval.py(新增維度檢查)
def assert_consistent_dimension(collection, new_vectors: list[list[float]]) -> None:
"""確保新寫入的向量維度,與集合裡既有向量的維度一致,避免混用不同 embedding 模型。"""
existing = collection.peek(limit=1)
if existing["embeddings"]:
expected_dim = len(existing["embeddings"][0])
actual_dim = len(new_vectors[0])
if expected_dim != actual_dim:
raise ValueError(
f"向量維度不一致:集合既有維度為 {expected_dim},"
f"這次要寫入的維度為 {actual_dim},請確認是否換了 embedding 模型。"
)
第六步:補上刪除與更新的操作,讓知識庫可以在原始資料變動時保持同步,而不是只能一路往裡面新增:
# research-agent/src/research_agent/retrieval.py(刪除與更新)
def delete_document(doc_id: str) -> None:
"""從向量庫中移除指定的文件,例如原始來源已經失效或內容已過期。"""
collection = get_collection()
collection.delete(ids=[doc_id])
def update_document(doc_id: str, new_text: str, metadata: dict) -> None:
"""更新既有文件的內容:先刪除舊向量,再用新內容重新寫入同一個 id。"""
delete_document(doc_id)
add_documents(ids=[doc_id], texts=[new_text], metadatas=[metadata])
常見錯誤與踩雷
第一個常見錯誤,就是前面原理段落強調的「查詢與文件用不同模型向量化」。這通常發生在專案演進過程中,例如一開始用某個模型建好向量庫,之後升級套件版本或換了模型,卻沒有把舊資料重新向量化,導致新查詢與舊文件的向量落在不相容的空間裡,檢索結果會變得莫名其妙。只要更換 embedding 模型,就必須把整個向量庫重新做一次,這是不能省略的步驟。
第二個是忘記正規化向量卻用內積當相似度指標。如果向量沒有正規化,內積的大小會受向量長度影響,長度較長的向量即使方向沒那麼接近,內積數值也可能比較大,導致排序結果失真。我們在 embed_texts 裡明確設了 normalize_embeddings=True,就是為了避免這個問題;如果改用其他函式庫或自己實作向量化,務必確認正規化這一步有沒有做。
第三個雷是把過長的文字整段丟去做 embedding。多數 embedding 模型有輸入長度上限(常見的是幾百個字元或 token),超過上限的部分可能被直接截斷,導致向量只反映了文字前段的語意,後段內容完全沒被考慮進去。這也是為什麼實務上很少直接對整篇文件做 embedding,而是要先把文件切成適當大小的分塊,這部分我們會在明天的篇章詳細處理。
第四個常見誤解,是以為向量檢索找到的結果一定「正確」或「完整」。向量檢索只是找出語意上相近的候選資料,相似不代表內容完全相關、更不代表沒有遺漏其他相關資料。把檢索結果直接當成最終答案交給使用者,而不經過模型進一步判讀與整合,是很多 RAG 系統踩過的坑,我們會在 AG Day 22 的檢索品質篇章討論如何用查詢改寫與重排序改善這個問題。
效能與實務提醒
在一般筆電的 CPU 上,sentence-transformers 對單句文字做 embedding 通常在幾十毫秒等級,但第一次載入模型權重(從硬碟讀進記憶體)可能需要幾秒鐘。我們在 get_embedding_model 裡用一個模組層級的快取變數,確保整個行程只載入一次模型,而不是每次呼叫 embed_texts 都重新載入,這對批次處理大量文字時的效能影響非常明顯。
批次處理也是值得留意的效能眉角:model.encode() 支援一次傳入一整批文字,內部會用向量化運算同時處理,效率遠高於逐筆呼叫。如果之後要把大量歷史文件一次性向量化,建議把文字分批(例如每批 32 或 64 筆)傳進去,而不是寫一個 for 迴圈逐句呼叫,能省下可觀的處理時間。
另外,Chroma 的 PersistentClient 會把資料寫在指定的磁碟路徑下,隨著文件數量增加,這個目錄的大小也會持續成長。建議定期關注 data/chroma/ 目錄的磁碟用量,並考慮替不重要或已過期的研究素材設計清理策略,避免向量庫無止盡膨脹,拖慢查詢效能。
小結
今天我們讓 research-agent 第一次擁有了語意檢索的能力:用 sentence-transformers 把文字轉成向量、用餘弦相似度衡量語意接近程度、再用 Chroma 把向量持久化存起來,之後就能用一句新的查詢,找出過去蒐集過的資料裡語意最相關的內容,而不必每次都重新呼叫外部搜尋服務。
今天新增的關鍵詞:向量化(embedding)——把文字轉換成能反映語意的高維度數字向量;餘弦相似度——衡量兩個向量方向接近程度的常見指標;向量資料庫——專門儲存向量並加速相似度查詢的資料庫,我們選用本機檔案形式的 Chroma。
結語
今天我們用手寫的示範句子驗證了向量檢索的基本原理,但這些句子都是我們自己手打的,離「真正從網頁上蒐集資料」還有一段距離。一份完整的研究助理,需要能自動把網頁內容抓下來、去除雜訊、切成適合檢索的分塊,再送進今天建立的向量庫。
明天,我們會進入「AG Day 21 文件擷取與分塊:從網頁到知識庫」,示範怎麼用 trafilatura 從原始網頁中擷取乾淨的正文、設計合理的分塊策略,並把整個流程串成一條完整的擷取管線,正式把 documents 與 chunks 資料表跟今天的向量庫接在一起。
延伸資源
- sentence-transformers 官方文件:
https://www.sbert.net/。模型清單、多語言模型選擇與encode()參數說明。 - Chroma 官方文件:
https://docs.trychroma.com/。PersistentClient、collection 操作與距離指標定義。 - 維基百科:Cosine similarity 條目,餘弦相似度的數學定義與常見應用場景。
留言
張貼留言