昨天我們已經替每一筆 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 的工作,就是把這種「意思接近」變成可以計算與排序的結果。
整個流程可以整理成四個步驟:
OpenAI 的 Embeddings 文件也以相同方式說明搜尋流程:使用同一個 Embedding Model 將 Query 向量化,再與已保存的 Embedding 計算 Cosine Similarity,分數越高代表越相關。OpenAI Embeddings Guide
目前 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
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,也應該繼續加入原本的統計。
接著修改 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。
搜尋時,我們只替 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 說的話。
將版本從 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。
現在搜尋:
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 已經不需要依靠完全相同的關鍵字,也能從語意上找到相關記憶。
假設目前有 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 後仍然存在?
現在輸入:
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?
搜尋不到時怎麼辦?
今天先把最底層的搜尋能力完成。
今天我們沒有使用 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 數量越來越大,我們就會需要一個更適合保存、搜尋與管理向量的系統。
下一篇會從今天親手寫出的 Semantic Search 出發,拆解 Vector Database 幫我們處理了哪些工作:
今天我們已經自己走完一次:
Embed Query
Compare Vectors
Sort Results
Return Top-K
明天再來看 Vector Database 如何把這套流程變成一個真正可以持續擴充的系統!