昨天我們沒有使用Vector Database,而是自己完成了一次Semantic Search:
Query
↓
Query Embedding
↓
逐一比較所有 Memory Embedding
↓
依照 Similarity Score 排序
↓
取出 Top-K
這個做法很適合理解 Semantic Search 的底層流程,但目前所有資料都放在:
memory_candidates = []
搜尋時則使用:
for memory in memories:
逐一掃描所有記憶。
當資料只有幾十筆時,這樣沒有太大問題;但隨著 Memory 數量增加,我們會開始需要更完整的資料管理與搜尋機制。今天要把 Day15 的 Python List 換成一個真正的 Vector Database,看看它究竟替我們處理了哪些事情。
一般 Database 會保存:
字串
數字
日期
Boolean
Vector Database 除了保存一般資料,也能管理 Embedding 這種高維度向量,並提供 Vector Search。
一筆 Vector Database Record 通常包含:
| 欄位 | 用途 |
|---|---|
| ID | 唯一識別一筆資料 |
| Document | 原始文字內容 |
| Embedding | Document 對應的向量 |
| Metadata | 類型、來源、時間等附加資訊 |
例如 Memora 的一筆記憶可以表示成:
{
"id": "73b3d4c8-...",
"document": "使用者想加強旅遊英文。",
"embedding": [0.012, -0.021, ...],
"metadata": {
"source": "automatic"
}
}
所以 Vector Database 的責任不只是保存一串浮點數,而是將:
原始資料
向量
識別碼
附加資訊
組成可以新增、搜尋、更新與刪除的 Record。
這裡要先分清楚兩個元件的工作。
Embedding Model 負責:
把文字轉成向量。
Vector Database 負責:
保存向量及其相關資料,並找出與 Query Vector 最接近的 Records。
也就是說,目前的流程仍然是:
Memory Text
↓
OpenAI Embeddings API
↓
Memory Embedding
↓
Vector Database
搜尋時則是:
Search Query
↓
OpenAI Embeddings API
↓
Query Embedding
↓
Vector Database Query
↓
Top-K Memories
今天不會改掉 Day 14 建立的:
create_embeddings()
我們只會把 Day 15 自己寫的向量比較與排序,交給 Vector Database 處理。
這個系列接下來會使用 Chroma 作為 Vector Database。
先安裝:
pip install chromadb
然後在原本程式上方新增:
import chromadb
from uuid import uuid4
原本的 OpenAI Client 繼續保留:
client = OpenAI()
接著另外建立 Chroma Client:
chroma_client = chromadb.EphemeralClient()
我刻意將兩個變數分開命名:
client
是 OpenAI Client。
chroma_client
是 Chroma Client。
EphemeralClient 的資料只存在目前的程式執行期間,不會寫進硬碟,適合用來測試和理解 Vector Database。Chroma Clients
因此今天即使換成 Vector Database,關閉程式後,Memory 還是會消失。
這是刻意保留的限制。Day 17 才會把它改成真正能跨程式保存的 Long-term Memory Store。
Database 裡通常會有 Table,而 Chroma 使用的概念是:
Collection
我們可以建立一個專門保存 Memora Memory 的 Collection:
memory_collection = chroma_client.get_or_create_collection(
name="memora_memories",
embedding_function=None,
configuration={
"hnsw": {
"space": "cosine"
}
}
)
Collection 名稱是:
memora_memories
我們設定:
embedding_function=None
是因為 Embedding 仍然由 Day 14 寫好的 OpenAI API 流程產生,不需要 Chroma 再替同一段文字重新建立一次 Embedding。
另外指定:
"space": "cosine"
代表這個 Collection 使用 Cosine Distance 判斷向量距離,延續 Day 14、Day 15 使用 Cosine Similarity 的設計。
Chroma 的 Single-Node Collection 會使用 HNSW Index 進行 Approximate Nearest Neighbor Search,也可以在建立 Collection 時指定 cosine、l2 或 ip 等距離空間。Chroma Collection Configuration
Day 15 使用:
memory_candidates = []
保存所有 EmbeddedMemoryCandidate。
今天可以刪除這個 List,改由:
memory_collection
管理資料。
也就是從:
memory_candidates
├── Memory 1
├── Memory 2
└── Memory 3
改成:
memora_memories Collection
├── Record 1
├── Record 2
└── Record 3
每個 Record 都會有自己的 ID、Document、Embedding 和 Metadata。
Python List 可以使用:
第 1 筆
第 2 筆
第 3 筆
表示資料位置,但 Database 通常需要一個穩定而且唯一的 ID。
因此我們使用:
uuid4()
產生 ID:
memory_id = str(uuid4())
結果可能長這樣:
73b3d4c8-6b38-44b5-a987-900d04e438af
為什麼不直接使用:
1
2
3
因為 List 的位置可能隨著刪除或排序改變,而 Database ID 應該持續指向同一筆資料。
之後 Day 24 要更新或處理矛盾記憶時,這個 ID 也會很重要。
新增一個 Function:
def add_memories_to_collection(
memories: list[EmbeddedMemoryCandidate],
source: str
):
if not memories:
return []
memory_ids = [
str(uuid4())
for _ in memories
]
memory_collection.add(
ids=memory_ids,
documents=[
memory.content
for memory in memories
],
embeddings=[
memory.embedding
for memory in memories
],
metadatas=[
{
"source": source
}
for _ in memories
]
)
return memory_ids
這裡一次交給 Chroma 四組資料:
ids=memory_ids
每一筆 Memory 的唯一 ID。
documents=[...]
Memory 原本的文字內容。
embeddings=[...]
Day 14 建立的 Embedding。
metadatas=[...]
這筆 Memory 的來源。
Chroma 的 collection.add() 可以同時保存 ID、Document、Embedding 與 Metadata;如果 Embedding 已經由 Application 產生,也可以直接傳入,Chroma 不會再重新 Embed Document。Adding Data to Chroma Collections
Day 15 的 remember 指令最後會執行:
memory_candidates.extend(embedded_memories)
今天把這一行改成:
memory_ids = add_memories_to_collection(
memories=embedded_memories,
source="manual"
)
完整的 remember 區段變成:
if command.startswith("remember "):
memory_text = user_input[len("remember "):].strip()
if not memory_text:
print("Usage: remember <text>")
continue
manual_candidate = MemoryCandidate(
content=memory_text
)
try:
embedded_memories, embedding_tokens = (
embed_memory_candidates([manual_candidate])
)
memory.add_token_usage(embedding_tokens)
memory_ids = add_memories_to_collection(
memories=embedded_memories,
source="manual"
)
except Exception as error:
print("Memory creation failed:", error)
continue
print("Memory added:", memory_ids[0])
continue
原本的流程沒有被推翻,仍然是:
MemoryCandidate
↓
EmbeddedMemoryCandidate
↓
保存
只是最後一步從:
memory_candidates.append(...)
改成:
memory_collection.add(...)
Day 13 建立的 Structured Output 和 Day 14 的 Embedding 流程同樣保留。
原本自動抽取後會執行:
memory_candidates.extend(embedded_memories)
現在改成:
add_memories_to_collection(
memories=embedded_memories,
source="automatic"
)
完整的修改部分如下:
if extraction_result.should_remember:
embedded_memories, embedding_tokens = (
embed_memory_candidates(
extraction_result.memories
)
)
memory.add_token_usage(embedding_tokens)
add_memories_to_collection(
memories=embedded_memories,
source="automatic"
)
如此一來,手動和自動建立的 Memory 都會進入同一個 Collection,只是 Metadata 不同:
{
"source": "manual"
}
或:
{
"source": "automatic"
}
這就是 Metadata 的其中一個用途。
未來可以利用 Metadata 篩選:
只搜尋某位使用者的 Memory
只搜尋特定 Memory Type
只搜尋最近建立的 Memory
只搜尋自動抽取的 Memory
目前先保存 source,之後再逐步增加其他欄位。
memories 指令原本的 memories 指令會讀取:
memory_candidates
今天改成使用:
memory_collection.get()
if command == "memories":
result = memory_collection.get(
include=[
"documents",
"metadatas"
]
)
print("\n--- Stored Memories ---")
if not result["ids"]:
print("(no memories)")
else:
for index, (
memory_id,
document,
metadata
) in enumerate(
zip(
result["ids"],
result["documents"],
result["metadatas"]
),
start=1
):
source = metadata.get(
"source",
"unknown"
) if metadata else "unknown"
print(f"{index}. {document}")
print(f" ID: {memory_id}")
print(f" Source: {source}")
print("-----------------------")
continue
現在輸入:
memories
可能看到:
--- Stored Memories ---
1. 使用者想加強旅遊英文。
ID: 73b3d4c8-6b38-44b5-a987-900d04e438af
Source: manual
2. 使用者偏好簡短的文法解釋。
ID: e33ef718-e21b-4f24-b74a-2e5d052137a7
Source: automatic
-----------------------
這些資料已經不是從 Python List 讀取,而是從 Vector Collection 取得。
Day 15 的搜尋結果使用:
class MemorySearchResult(BaseModel):
memory_number: int
content: str
score: float
今天不再依賴 List 中的編號,因此將它修改成:
class MemorySearchResult(BaseModel):
memory_id: str
content: str
source: str
distance: float
score: float
新增的欄位包括:
memory_id
Record 的唯一 ID。
source
Memory 的來源。
distance
Vector Database 回傳的距離。
score
為了方便閱讀,由 Distance 換算出的相似度。
Day 15 的 semantic_search() 會自己執行:
for memory in memories:
score = cosine_similarity(
query_embedding,
memory.embedding
)
接著自己排序:
results.sort(
key=lambda result: result.score,
reverse=True
)
今天把這些工作交給 Chroma:
def semantic_search(
query: str,
top_k: int = SEARCH_TOP_K
):
query = query.strip()
if not query:
raise ValueError(
"Search query cannot be empty."
)
total_memories = memory_collection.count()
if total_memories == 0:
return [], 0
query_vectors, embedding_tokens = create_embeddings(
[query]
)
query_embedding = query_vectors[0]
result = memory_collection.query(
query_embeddings=[query_embedding],
n_results=min(top_k, total_memories),
include=[
"documents",
"metadatas",
"distances"
]
)
search_results = []
for (
memory_id,
document,
metadata,
distance
) in zip(
result["ids"][0],
result["documents"][0],
result["metadatas"][0],
result["distances"][0]
):
source = metadata.get(
"source",
"unknown"
) if metadata else "unknown"
search_results.append(
MemorySearchResult(
memory_id=memory_id,
content=document,
source=source,
distance=distance,
score=1 - distance
)
)
return search_results, embedding_tokens
程式仍然要先替 Query 建立 Embedding:
query_vectors, embedding_tokens = create_embeddings(
[query]
)
但後面的比較、搜尋與排序改成:
memory_collection.query(...)
其中:
n_results=min(top_k, total_memories)
代表最多取回 top_k 筆。如果 Collection 裡只有兩筆資料,就不會要求它回傳三筆。
Chroma 的 query() 會尋找距離 Query 最近的 Records,並可回傳 Documents、Metadata 和 Distances。Chroma Collection API
Day 15 的 cosine_similarity() 是:
越大越相似
但 Chroma 在 Cosine Space 回傳的是:
Cosine Distance
計算方式可以理解成:
distance = 1 - cosine_similarity
所以:
Distance 越小
代表越相似
例如:
| Cosine Distance | Cosine Similarity |
|---|---|
| 0.05 | 0.95 |
| 0.20 | 0.80 |
| 0.70 | 0.30 |
為了讓輸出延續 Day 15 的呈現方式,我們將它換算回 Similarity Score:
score = 1 - distance
但要注意,只有在 Collection 使用:
"space": "cosine"
時,這個換算才成立。
如果改用:
L2 Distance
Inner Product
就不能直接照搬這個公式。
search 指令Day 15 呼叫 semantic_search() 時會傳入:
memories=memory_candidates
現在 Collection 已經在 Function 裡,因此改成:
if command.startswith("search "):
query = user_input[len("search "):].strip()
try:
search_results, search_tokens = semantic_search(
query=query,
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" ID: {result.memory_id}")
print(f" Source: {result.source}")
print("--------------------------------")
continue
現在的搜尋流程變成:
Query
↓
create_embeddings()
↓
Query Embedding
↓
memory_collection.query()
↓
Top-K Records
Day 15 自己寫的:
cosine_similarity()
和 Python 排序已經不再出現在主要搜尋流程中。
Day 14 加入的:
vector <number>
similarity <a> <b>
是為了觀察 Embedding 和 Cosine Similarity。
現在資料來源已經從:
memory_candidates
移到:
memory_collection
因此原本依賴 List 的兩個 Command 不能直接繼續使用。
這一版可以先移除:
vector <number>
similarity <a> <b>
以及它們對應的指令區段。
這不代表 Vector Database 不能取得 Embedding。Chroma 的 get() 也能透過:
include=["embeddings"]
讀取向量。
只是這兩個指令已經完成它們在 Day 14 的教學任務,現在主程式只保留:
remember <text>
memories
search <query>
讓 Memory Flow 更集中。
將版本改成:
print("Memora v0.13")
Commands 更新為:
print("Commands:")
print(" history Show conversation history")
print(" context Show current context")
print(" summary Show conversation summary")
print(" status Show memory status")
print(" remember <text> Add a memory manually")
print(" memories Show stored memories")
print(" extraction Toggle memory extraction")
print(" search <query> Semantic search")
print(" exit Exit")
其他 Short-term Memory 與 Memory Extraction 指令都繼續保留。
先加入幾筆 Memory:
You:
remember 使用者想加強旅遊英文。
You:
remember 使用者偏好簡短的文法解釋。
You:
remember 使用者希望 TOEIC 達到 850 分。
接著輸入:
You:
memories
應該會看到 Collection 裡的三筆 Record,包括各自的 ID 和 Source。
然後搜尋:
You:
search 我想學習在機場和飯店會使用的英文
可能得到:
--- Semantic Search: 我想學習在機場和飯店會使用的英文 ---
1. [0.xxxx] 使用者想加強旅遊英文。
ID: 73b3d4c8-...
Source: manual
2. [0.xxxx] 使用者希望 TOEIC 達到 850 分。
ID: 1f95a4d9-...
Source: manual
3. [0.xxxx] 使用者偏好簡短的文法解釋。
ID: e33ef718-...
Source: manual
--------------------------------
從使用者操作來看,它和 Day 15 的:
search <query>
幾乎相同。真正改變的是內部實作。
昨天:
Python List
+
自己逐筆比較
+
自己排序
今天:
Vector Collection
+
Vector Index
+
Database Query
Day 15 的搜尋方式是 Exact Search。
假設有 10,000 筆 Memory,就逐一比較 10,000 次:
for memory in memories:
這種方法可以完整計算 Query 和每一筆 Memory 的距離,但資料越多,搜尋時間通常也會跟著增加。
Vector Database 通常會建立 Vector Index,例如 Chroma Single-Node 使用的:
HNSW
Hierarchical Navigable Small World
HNSW 會把相近的向量建立成圖形關係。搜尋時不必從頭掃描所有向量,而是沿著圖中的鄰近節點尋找可能的結果。
這屬於:
Approximate Nearest Neighbor Search
也就是用可能非常接近、但不保證百分之百完整掃描的結果,換取更快的搜尋速度。
因此通常存在一個取捨:
| 方法 | 優點 | 限制 |
|---|---|---|
| Exact Search | 結果精確、容易理解 | 資料量大時速度較慢 |
| Approximate Search | 大量向量下搜尋較快 | 可能漏掉真正的最近鄰 |
目前 Memora 的資料量還很小,兩種方法通常不會產生明顯差異。但理解這個概念,可以幫助我們知道 Vector Database 為什麼不只是替 Python for Loop 換一個寫法。
目前我們已經擁有:
Memory Document
Memory Embedding
Memory Metadata
Vector Search
但 Vector Database 本身並不知道:
哪些事情值得記住?
哪一筆記憶是錯的?
哪一筆記憶已經過時?
什麼時候應該搜尋?
哪些結果應該交給 LLM?
它只負責:
保存資料,並根據 Query 找出距離較近的 Records。
是否要記住,仍然是 Memory Extraction 或之後的 Memory Policy 決定。
搜尋到資料之後要如何使用,也仍然是 Application 的責任。
所以更精確地說:
Vector Database
≠
Memory System
而是:
Vector Database
=
Memory System 裡負責儲存與搜尋的元件
雖然我們已經使用 Vector Database,但今天建立的是:
chromadb.EphemeralClient()
它不會把資料寫進硬碟。
只要關閉程式:
Memory Records
Embeddings
Metadata
Vector Index
仍然會消失。因此目前 Memora 做到的是:
同一次程式執行
↓
Vector Database 可以管理並搜尋 Memory
還沒有做到:
關閉程式
↓
重新啟動
↓
Memory 仍然存在
是否擁有 Long-term Memory,關鍵不只是有沒有 Vector Database,而是資料能不能跨越目前這次 Process 繼續存在,並在未來的 Conversation 被找回來。
今天我們把 Day 15 的 Semantic Search 從:
memory_candidates = []
和:
for memory in memories:
移到 Chroma Vector Database。
主要修改包括:
chroma_client = chromadb.EphemeralClient()
建立暫時性的 Chroma Client。
memory_collection = (
chroma_client.get_or_create_collection(...)
)
建立專門保存 Memory 的 Collection。
memory_collection.add(...)
保存 Memory 的 ID、Document、Embedding 與 Metadata。
memory_collection.query(...)
根據 Query Embedding 搜尋 Top-K Records。
Memora 的演進來到:
Day 14
把 Memory 轉成 Embedding
Day 15
自己實作 Semantic Search
Day 16
交給 Vector Database 管理與搜尋
Vector Database 幫我們處理了:
Record Management
Vector Storage
Metadata
Vector Index
Nearest Neighbor Search
但目前使用的 EphemeralClient 不會保存資料,所以它仍然不是完整的 Long-term Memory。
下一篇會從今天的程式繼續修改,將:
chromadb.EphemeralClient()
換成:
chromadb.PersistentClient(
path="./memora_db"
)
並把目前散落在程式裡的:
add
get
query
count
整理成一個真正的:
LongTermMemoryStore
到時候我們會重新啟動程式,確認 Memora 是否仍然找得到之前保存的 Memory。
今天我們讓Memora擁有了管理向量的Database;明天再讓這些資料第一次真正跨越程式生命週期,成為可以留下來的Long-term Memory!