跳到主要內容

AG Day 20 RAG 基礎:embedding 與向量檢索

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 條目,餘弦相似度的數學定義與常見應用場景。

留言

這個網誌中的熱門文章

Day 2 變數與資料型別

Day 2 變數與資料型別 引言 寫程式的過程中,變數與資料型別是處理資料的基礎。變數是存放資料的容器,資料型別則決定這筆資料有哪些特性、可以進行哪些操作。學會定義變數、認識各種資料型別,是學好 Python 的關鍵一步。 這篇文章會帶你了解 Python 中變數的觀念、如何定義變數,以及常見的資料型別,包括整數、浮點數、字串、布林值,還有串列、元組、字典與集合等容器型別。我們也會介紹變數的命名規則與撰寫風格建議,以及如何用 type() 檢查資料型別。 什麼是變數?如何在 Python 中定義變數 變數是在程式執行時用來存放資料的名稱。透過定義變數,我們可以給一筆資料一個名字,並在程式的其他地方用這個名字取用該筆資料。在 Python 中,變數不需要事先宣告型別,因為 Python 是動態型別語言,變數的型別由指定給它的值決定。 定義變數的基本語法 在 Python 中定義變數非常簡單,只要用賦值符號 = 把值指定給變數即可。例如: x = 5 # 定義變數 x,並把整數 5 賦值給它 name = "Alice" # 定義變數 name,並把字串 "Alice" 賦值給它 在這裡,x 是一個變數,被賦予整數 5;name 是另一個變數,被賦予字串 "Alice"。 變數的更新與覆寫 變數的值可以修改,也就是說,我們可以在程式的不同地方給同一個變數新的值。例如: x = 10 # x 最初被賦予 10 x = 15 # x 的值現在被更新為 15 這樣就能依照需求,在程式執行過程中靈活調整變數的值。 Python 的動態型別系統 Python 和某些靜態型別語言不同,定義變數時不需要宣告型別。賦值時,Python 會根據值自動判斷變數的型別。例如: x = 5 # x 是整數 x = 3.14 # x 變成浮點數 x = "Hi" # x 變成字串 同一個變數在程式執行過程中可以存放不同型別的值,這是 Python 的彈性之一。 常見資料型別 在 Python 中,資料型別決定我們可以對變數進行哪些操作...

Day 1 Python 簡介與環境設定

Day 1 Python 簡介與環境設定 引言 在現在的科技環境裡,程式設計已經是一項重要技能。無論你是對資料科學有興趣、想成為開發者,或是想踏入人工智慧(AI)領域,學會寫程式都能明顯提升你的競爭力。在眾多程式語言中,Python 因為語法簡單、功能強大、應用範圍廣泛,成為許多人進入程式世界的第一選擇。這篇文章會帶你認識 Python 的背景與優勢,並一步步教你在不同系統上安裝與設定 Python 開發環境,最後寫出第一支 Python 程式。 為什麼選擇 Python? Python 是一種高階程式語言,由 Guido van Rossum 在 1991 年發布。Python 的設計哲學強調程式碼的可讀性,並用縮排來定義程式區塊,這點和許多使用大括號的語言不同。簡潔的語法讓它成為初學者的理想選擇;就算是經驗豐富的開發者,也能用它完成複雜的專案。 Python 的優勢如下: 簡單易學 :Python 的語法清楚、結構簡潔,初學者很快就能上手。和其他語言相比,學習曲線相對平緩,不需要先弄懂一堆複雜觀念,就能開始寫程式。 應用範圍廣泛 :從資料科學、網頁開發、人工智慧、機器學習、自動化測試到網路爬蟲,Python 都有大量開源函式庫與工具支援,而且在這些領域都扮演關鍵角色。 豐富的函式庫與框架 :Python 的函式庫生態系非常龐大。做資料分析有 NumPy、Pandas;開發網站有 Django、Flask;做深度學習有 TensorFlow、PyTorch。各種需求幾乎都能找到對應的套件,讓開發更有效率。 跨平台支援 :Python 支援 Windows、macOS、Linux 等作業系統,程式通常不需要太多修改就能跨平台執行,讓開發與部署更有彈性。 活躍的社群 :Python 擁有龐大的開發者社群。學習或開發上遇到問題,幾乎都能在社群與論壇(例如 Stack Overflow)找到答案,對初學者來說是很強的後盾,也能減少卡關時的挫折感。 Python 的應用領域 Python 的流行與強大功能,讓許多領域都開始大量使用它。以下是幾個常見的應用方向: 資料科學 :隨著大數據與人工智慧興起,資料科學大量使用 Python。NumPy、Pandas 與 Matplotlib 等工具能處理和分析龐...

Python 從入門到 PyTorch 深度學習:開啟 AI 世界的大門

Python 從入門到 PyTorch 深度學習:開啟 AI 世界的大門 隨著人工智慧(AI)與深度學習(Deep Learning)快速發展,越來越多人對這些技術產生興趣。不論你是想踏入 AI 領域的初學者,還是已經有程式基礎的開發者,學好 Python 與深度學習框架(例如 PyTorch),都能為你打開更多可能。 為什麼選擇 Python? Python 已經是資料科學與人工智慧領域的首選語言。它的語法簡潔、容易上手,而且擁有龐大的生態系與大量開源函式庫。無論是資料處理、資料視覺化,還是建立機器學習與深度學習模型,Python 都能勝任。對想進入 AI 或資料科學領域的人來說,它幾乎是必備工具。 PyTorch 是什麼? PyTorch 是由 Meta(原 Facebook)AI 研究團隊開發的開源深度學習框架,以易用、靈活和動態計算圖著稱,是許多 AI 研究人員與開發者的首選。相較於其他框架,PyTorch 的寫法更貼近原生 Python,對初學者相對友善。無論是簡單的實驗,還是複雜的深度學習模型,PyTorch 都能提供強大的支援。 這個系列能帶給你什麼? 這個系列會從 Python 的基礎開始,帶你一步一步學習,最後能自己用 PyTorch 建立深度學習模型。即使你完全沒有寫過程式,也能跟著文章的節奏累積技能,理解 AI 與深度學習的核心觀念。 本系列涵蓋的主題 Python 基礎:從變數、條件判斷到函式與模組。 資料處理工具:用 NumPy 與 Pandas 有效率地操作資料。 資料視覺化:用 Matplotlib 與 Seaborn 把資料畫成圖表。 深度學習的數學基礎:線性代數、微積分與機率。 PyTorch 入門:理解張量、模型建構與 GPU 加速。 基礎深度學習模型:CNN 與 RNN 的實作應用。 深度學習專案實戰:從資料前處理到模型部署的端到端流程。 誰適合這個系列? 程式初學者 :如果你對 AI 充滿好奇,卻還沒寫過程式,系列的第一部分會帶你快速上手 Python,並幫助你理解深度學習的基本觀念。 資料科學愛好者 :如果你已經熟悉一些資料處理方法,進階部分會教你如何用 PyTorch 建構深度學習模型。 開發者與研究人員 :想更深入了...