iT邦幫忙

2026 iThome 鐵人賽

DAY 15
0

昨天我們已經替每一筆 Memory Candidate 建立 Embedding,也寫好了 cosine_similarity(),可以比較兩段記憶在語意上有多接近。

例如:

Memory 1:
使用者想加強旅遊英文。

Memory 2:
使用者希望練習在機場和飯店會用到的英文。

即使兩句話沒有完全相同的關鍵字,它們的 Embedding 仍可能非常接近。不過昨天的做法還需要手動指定:

similarity 1 2

這只能回答:

第 1 筆和第 2 筆記憶像不像?

真正的 Memory System 需要回答的是另一個問題:

面對現在的 Query,所有記憶裡哪幾筆最相關?

今天我們先不用 Vector Database,直接使用目前 Python 記憶體裡的資料,自己完成一次最基本的 Semantic Search


一、從相似度比較走向搜尋

假設目前有四筆記憶:

1. 使用者想加強旅遊英文。
2. 使用者偏好簡短的文法解釋。
3. 使用者希望 TOEIC 達到 850 分。
4. 使用者喜歡透過例句學習。

使用者輸入:

我想練習在機場和飯店會用到的英文。

如果只做文字比對,第一筆記憶和 Query 之間沒有完全相同的句子。

但從語意來看:

旅遊英文

和:

機場、飯店會用到的英文

明顯是相關的。

Semantic Search 的工作,就是把這種「意思接近」變成可以計算與排序的結果。

整個流程可以整理成四個步驟:

  1. 把 Query 轉成 Embedding
  2. 與每一筆 Memory Embedding 計算 Cosine Similarity
  3. 按照分數由高到低排序
  4. 取回前幾筆結果

OpenAI 的 Embeddings 文件也以相同方式說明搜尋流程:使用同一個 Embedding Model 將 Query 向量化,再與已保存的 Embedding 計算 Cosine Similarity,分數越高代表越相關。OpenAI Embeddings Guide


二、Query 和 Memory 都要使用同一個 Embedding Model

目前 Memora 的 Memory Embedding 使用:

EMBEDDING_MODEL = "text-embedding-3-small"

搜尋時,Query 也必須使用同一個 Model:

query_embedding = embed(
    "我想練習在機場和飯店會用到的英文。"
)

接著才能和原本保存的 Memory Embedding 比較:

score = cosine_similarity(
    query_embedding,
    memory.embedding
)

不能讓 Memory 使用一個 Embedding Model,Query 卻使用另一個。不同 Model 產生的向量空間並不保證可以直接比較,向量維度也可能不同。

所以今天不會新增另一套 Embedding 設定,而是繼續使用 Day 14 的:

EMBEDDING_MODEL

三、先整理昨天的 Embedding Function

Day 14 的 embed_memory_candidates() 只能處理:

list[MemoryCandidate]

但今天除了 Memory Candidate,還要替一般的搜尋 Query 建立 Embedding。

因此先把呼叫 Embeddings API 的共同部分抽出來。

新增:

def create_embeddings(texts: list[str]):
    if not texts:
        return [], 0

    response = client.embeddings.create(
        model=EMBEDDING_MODEL,
        input=texts,
        encoding_format="float"
    )

    embedding_data = sorted(
        response.data,
        key=lambda item: item.index
    )

    vectors = [
        item.embedding
        for item in embedding_data
    ]

    return vectors, response.usage.total_tokens

這個 Function 接收一組文字:

[
    "使用者想加強旅遊英文。",
    "使用者偏好簡短的文法解釋。"
]

並回傳:

對應的 Embedding List
Embedding 使用的 Token 數量

保留 Token 數量,是因為前面建立的 ShortTermMemory 已經會統計 API Token Usage。這次搜尋所使用的 Embedding Token,也應該繼續加入原本的統計。


四、讓原本的 Memory Embedding 繼續工作

接著修改 Day 14 的 embed_memory_candidates(),讓它改用剛才建立的 create_embeddings()

def embed_memory_candidates(
    candidates: list[MemoryCandidate]
):
    if not candidates:
        return [], 0

    texts = [
        candidate.content
        for candidate in candidates
    ]

    vectors, embedding_tokens = create_embeddings(texts)

    embedded_candidates = [
        EmbeddedMemoryCandidate(
            content=candidate.content,
            embedding=vector
        )
        for candidate, vector in zip(candidates, vectors)
    ]

    return embedded_candidates, embedding_tokens

功能和昨天一樣:

MemoryCandidate
       ↓
建立 Embedding
       ↓
EmbeddedMemoryCandidate

差別只是把實際呼叫 API 的部分抽成共用 Function。

因此 Day 14 的這些功能都不需要重寫:

remember <text>
自動抽取 Memory Candidate
memories
vector <number>
similarity <a> <b>

它們仍然可以繼續使用。


五、定義搜尋結果的資料結構

每一筆 Semantic Search Result 至少需要三個資訊:

原本是第幾筆 Memory
Memory 內容
Similarity Score

所以在原本的 Pydantic Model 下方新增:

class MemorySearchResult(BaseModel):
    memory_number: int
    content: str
    score: float

例如搜尋後可能得到:

MemorySearchResult(
    memory_number=1,
    content="使用者想加強旅遊英文。",
    score=0.82
)

這裡的 score 不是 Memory 的重要程度,也不是回答正確率。

它只表示:

這筆 Memory 的 Embedding 和目前 Query Embedding 有多接近。

Day 22 才會討論 Memory 本身的 Importance Score。兩種分數代表不同概念,不要混在一起。


六、自己實作 semantic_search()

現在可以完成今天的核心 Function:

SEARCH_TOP_K = 3
def semantic_search(
    query: str,
    memories: list[EmbeddedMemoryCandidate],
    top_k: int = SEARCH_TOP_K
):
    query = query.strip()

    if not query:
        raise ValueError("Search query cannot be empty.")

    if not memories:
        return [], 0

    query_vectors, embedding_tokens = create_embeddings(
        [query]
    )

    query_embedding = query_vectors[0]

    results = []

    for index, memory in enumerate(memories, start=1):
        score = cosine_similarity(
            query_embedding,
            memory.embedding
        )

        results.append(
            MemorySearchResult(
                memory_number=index,
                content=memory.content,
                score=score
            )
        )

    results.sort(
        key=lambda result: result.score,
        reverse=True
    )

    return results[:top_k], embedding_tokens

這段程式主要做三件事情。

第一步,替 Query 建立 Embedding:

query_vectors, embedding_tokens = create_embeddings(
    [query]
)

query_embedding = query_vectors[0]

因為 create_embeddings() 接收的是 List,所以即使只有一個 Query,也要寫成:

[query]

第二步,逐一比較全部 Memory:

for index, memory in enumerate(memories, start=1):
    score = cosine_similarity(
        query_embedding,
        memory.embedding
    )

第三步,按照分數由高到低排序:

results.sort(
    key=lambda result: result.score,
    reverse=True
)

最後只回傳前 top_k 筆:

return results[:top_k], embedding_tokens

這就是最基本的 Semantic Search。


七、為什麼只建立一次 Memory Embedding?

搜尋時,我們只替 Query 建立新的 Embedding。

原本的 Memory 已經在加入 memory_candidates 時完成向量化:

EmbeddedMemoryCandidate(
    content="使用者想加強旅遊英文。",
    embedding=[...]
)

因此搜尋時不需要重新執行:

Memory 文字
↓
Embeddings API
↓
Memory Embedding

只需要:

Query
↓
Query Embedding
↓
與已存在的 Memory Embedding 比較

這也是為什麼昨天(Day 14)要在 Memory Candidate 建立時,就把 Embedding 一起保存下來。

如果每次搜尋都重新替全部 Memory 建立 Embedding,API 成本與等待時間都會隨著 Memory 數量增加,而且同一份資料會被重複計算。


八、加入 search 指令

現在回到Day 14原本的 while Loop。

在處理一般對話之前,新增一個指令:

if command == "search":
    print("Usage: search <query>")
    continue

接著加入真正的搜尋:

if command.startswith("search "):
    query = user_input[len("search "):].strip()

    try:
        search_results, search_tokens = semantic_search(
            query=query,
            memories=memory_candidates,
            top_k=SEARCH_TOP_K
        )

        memory.add_token_usage(search_tokens)

    except Exception as error:
        print("Search failed:", error)
        continue

    print(f"\n--- Semantic Search: {query} ---")

    if not search_results:
        print("(no memories)")

    for rank, result in enumerate(search_results, start=1):
        print(
            f"{rank}. [{result.score:.4f}] "
            f"{result.content}"
        )
        print(f"   Memory #{result.memory_number}")

    print("--------------------------------")
    continue

注意,這段指令要放在:

memory.add_user_message(user_input)

之前。

否則:

search 我想練習旅遊英文

可能會被當成一般對話,加入 Conversation History,甚至觸發 Memory Extraction。

search 是我們目前的 Debug Command,不是使用者真正要和 Memora 說的話。


九、更新 Memora 的指令提示

將版本從 Day 14 的:

print("Memora v0.11")

改成:

print("Memora v0.12")

並在原本的 Commands 中加入:

print("  search <query>          Semantic search")

現在與 Memory 有關的指令包含:

remember <text>
memories
vector <number>
similarity <a> <b>
search <query>

其中:

similarity <a> <b>

是比較兩筆指定 Memory。

而:

search <query>

則是讓 Query 和全部 Memory 比較,再找出最相關的結果。


十、整合後的新增程式

以下是今天需要加入昨天的完整區段。

首先是新的資料結構與設定:

SEARCH_TOP_K = 3


class MemorySearchResult(BaseModel):
    memory_number: int
    content: str
    score: float

接著替換昨天的 embed_memory_candidates()

def create_embeddings(texts: list[str]):
    if not texts:
        return [], 0

    response = client.embeddings.create(
        model=EMBEDDING_MODEL,
        input=texts,
        encoding_format="float"
    )

    embedding_data = sorted(
        response.data,
        key=lambda item: item.index
    )

    vectors = [
        item.embedding
        for item in embedding_data
    ]

    return vectors, response.usage.total_tokens


def embed_memory_candidates(
    candidates: list[MemoryCandidate]
):
    if not candidates:
        return [], 0

    texts = [
        candidate.content
        for candidate in candidates
    ]

    vectors, embedding_tokens = create_embeddings(texts)

    embedded_candidates = [
        EmbeddedMemoryCandidate(
            content=candidate.content,
            embedding=vector
        )
        for candidate, vector in zip(candidates, vectors)
    ]

    return embedded_candidates, embedding_tokens

在昨天的 cosine_similarity() 下方新增:

def semantic_search(
    query: str,
    memories: list[EmbeddedMemoryCandidate],
    top_k: int = SEARCH_TOP_K
):
    query = query.strip()

    if not query:
        raise ValueError("Search query cannot be empty.")

    if not memories:
        return [], 0

    query_vectors, embedding_tokens = create_embeddings(
        [query]
    )

    query_embedding = query_vectors[0]

    results = []

    for index, memory in enumerate(memories, start=1):
        score = cosine_similarity(
            query_embedding,
            memory.embedding
        )

        results.append(
            MemorySearchResult(
                memory_number=index,
                content=memory.content,
                score=score
            )
        )

    results.sort(
        key=lambda result: result.score,
        reverse=True
    )

    return results[:top_k], embedding_tokens

最後在原本 while Loop 的指令區加入:

if command == "search":
    print("Usage: search <query>")
    continue

if command.startswith("search "):
    query = user_input[len("search "):].strip()

    try:
        search_results, search_tokens = semantic_search(
            query=query,
            memories=memory_candidates,
            top_k=SEARCH_TOP_K
        )

        memory.add_token_usage(search_tokens)

    except Exception as error:
        print("Search failed:", error)
        continue

    print(f"\n--- Semantic Search: {query} ---")

    if not search_results:
        print("(no memories)")

    for rank, result in enumerate(search_results, start=1):
        print(
            f"{rank}. [{result.score:.4f}] "
            f"{result.content}"
        )
        print(f"   Memory #{result.memory_number}")

    print("--------------------------------")
    continue

其他 Day 14 的程式都保留不動。


十一、建立幾筆測試記憶

啟動程式後,先手動加入幾筆不同類型的 Memory Candidate:

You:
remember 使用者想加強旅遊英文。

Memora:
Memory added.
You:
remember 使用者偏好簡短的文法解釋。

Memora:
Memory added.
You:
remember 使用者希望 TOEIC 達到 850 分。

Memora:
Memory added.
You:
remember 使用者喜歡透過例句學習。

Memora:
Memory added.

接著輸入:

You:
memories

應該會看到:

1. 使用者想加強旅遊英文。
2. 使用者偏好簡短的文法解釋。
3. 使用者希望 TOEIC 達到 850 分。
4. 使用者喜歡透過例句學習。

這些 Memory 在加入時,都已經透過 Day 14 的流程建立好 Embedding。


十二、第一次執行 Semantic Search

現在搜尋:

You:
search 我想練習在機場和飯店會用到的英文

可能得到:

--- Semantic Search: 我想練習在機場和飯店會用到的英文 ---

1. [0.xxxx] 使用者想加強旅遊英文。
   Memory #1

2. [0.xxxx] 使用者喜歡透過例句學習。
   Memory #4

3. [0.xxxx] 使用者希望 TOEIC 達到 850 分。
   Memory #3

--------------------------------

實際分數會依照 Model 和輸入內容而不同,因此這裡不應該預先假設某個固定數值。

我們真正關心的是排序:

使用者想加強旅遊英文

應該比:

使用者希望 TOEIC 達到 850 分

更接近目前的 Query。

再搜尋另一個意思:

You:
search 請不要把文法解釋得太複雜

這次最相關的結果可能變成:

使用者偏好簡短的文法解釋。

這表示 Memora 已經不需要依靠完全相同的關鍵字,也能從語意上找到相關記憶。


十三、Top-K 是什麼?

假設目前有 100 筆 Memory,我們通常不需要把 100 筆全部交給後續流程。

所以設定:

SEARCH_TOP_K = 3

意思是:

搜尋後只保留最相關的三筆結果。

如果想取五筆,可以改成:

SEARCH_TOP_K = 5

不過 Top-K 只代表:

在現有資料裡排名最高的 K 筆

不代表這些結果一定真的足夠相關。例如目前所有 Memory 都是在談英文學習,但 Query 卻是:

我明天要煮什麼?

程式仍然可以排出前三名,只是這三筆可能全部都不適合使用。因此更完整的搜尋系統通常還會考慮:

Similarity Threshold
Metadata Filter
Recency
Importance
Memory Type

不過目前先不隨意設定:

if score > 0.7:

因為相似度分數沒有一個適合所有 Model、語言與資料集的通用門檻。實際 Threshold 應該透過自己的資料與測試結果決定。今天先把核心搜尋流程做正確!


十四、這個搜尋方法的限制

我們目前使用:

for memory in memories:

逐一比較每一筆記憶。

假設:

N = Memory 數量
D = Embedding 維度

一次搜尋大致需要進行:

N 次向量比較

每次比較又需要處理向量中的 D 個數值,所以計算量可以理解成:

O(N × D)

當 Memory 只有幾十筆或幾百筆時,這種 Linear Search 很適合學習,也足以完成小型實驗。

但如果之後有:

10 萬筆 Memory
100 萬筆 Memory

每次 Query 都逐一掃描全部向量,就會越來越慢。

而且目前的:

memory_candidates = []

只存在 Python Process 裡。

程式關閉後:

Memory 內容
Embedding
搜尋資料

都會一起消失。

這兩個問題正好會帶到下一篇再做討論:

如何更有效率地管理大量向量?

如何讓資料離開 Python List 後仍然存在?

十五、今天還沒有把結果交給 Chatbot

現在輸入:

search 我想練習旅遊英文

Memora 可以找到相關 Memory,但只會把結果印在 Terminal。

我們還沒有做:

Current User Input
        ↓
Semantic Search
        ↓
Relevant Memories
        ↓
加入 LLM Context
        ↓
產生回答

也就是說,今天完成的是:

從 Memory Candidates 中搜尋語意相關的資料。

Day 18才會把搜尋結果真正接回 Memora 的回答流程,讓模型在回答之前,自動找回可能需要的記憶。

這個界線很重要。

Semantic Search 解決的是:

哪幾筆 Memory 和 Query 最相關?

Memory Retrieval Pipeline 則還需要決定:

什麼時候搜尋?
搜尋幾筆?
哪些結果可以放進 Context?
要用什麼格式交給 LLM?
搜尋不到時怎麼辦?

今天先把最底層的搜尋能力完成。


Day 15 小結

今天我們沒有使用 Vector Database,而是直接使用昨天保存的 Embedding,自己完成一次 Semantic Search。

原本的 Day 14 只能:

指定兩筆 Memory
       ↓
計算一個 Similarity Score

今天則變成:

Search Query
      ↓
Query Embedding
      ↓
與全部 Memory Embedding 比較
      ↓
依照 Similarity Score 排序
      ↓
取得 Top-K Results

程式上新增了三個主要部分:

create_embeddings()

讓 Memory 和 Query 共用同一套 Embedding 流程。

semantic_search()

計算 Query 與全部 Memory 的相似度並排序。

search <query>

讓我們可以直接從 Terminal 測試搜尋結果。

Memora 的演進來到:

Day 13
Structured Memory Candidate

Day 14
Memory Embedding

Day 15
Semantic Search

現在 Memora 不只把記憶轉成向量,還能根據一個新的問題,找出語意最接近的 Memory。

不過目前使用的仍然是:

memory_candidates = []

搜尋方式也是逐一掃描全部向量。

當 Memory 數量越來越大,我們就會需要一個更適合保存、搜尋與管理向量的系統。

Day 16|Vector Database 到底在做什麼?

下一篇會從今天親手寫出的 Semantic Search 出發,拆解 Vector Database 幫我們處理了哪些工作:

  • Vector、Document 與 Metadata 怎麼一起保存?
  • 為什麼不能永遠用 Python List?
  • Exact Search 和 Approximate Nearest Neighbor 有什麼差別?
  • Vector Index 為什麼可以加快搜尋?
  • Vector Database 是 Memory 本身,還是 Memory 的儲存與搜尋工具?

今天我們已經自己走完一次:

Embed Query
Compare Vectors
Sort Results
Return Top-K

明天再來看 Vector Database 如何把這套流程變成一個真正可以持續擴充的系統!


上一篇
Day 14|Embedding:AI 怎麼知道兩段記憶「很像」?
下一篇
Day 16|Vector Database 到底在做什麼?
系列文
從 Stateless LLM 到 Agentic Memory:30 天打造會記憶的 AI Agent23
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言