iT邦幫忙

2026 iThome 鐵人賽

DAY 16
0

昨天我們沒有使用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,看看它究竟替我們處理了哪些事情。


一、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

這裡要先分清楚兩個元件的工作。

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

這個系列接下來會使用 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。


四、建立第一個 Collection

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 時指定 cosinel2ip 等距離空間。Chroma Collection Configuration


五、從 Python List 改成 Vector Collection

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。


六、替每一筆 Memory 建立 ID

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 也會很重要。


七、把 Memory 加入 Collection

新增一個 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


八、修改手動新增 Memory 的流程

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(...)

九、修改自動抽取 Memory 的流程

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 換算出的相似度。


十二、讓 Vector Database 負責 Semantic Search

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


十三、Distance 和 Similarity 不要搞反

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 的 Debug 指令怎麼辦?

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 指令都繼續保留。


十七、測試 Vector Database Search

先加入幾筆 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

十八、Exact Search 和 Approximate Search

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 換一個寫法。


十九、Vector Database 不等於 AI Memory

目前我們已經擁有:

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 裡負責儲存與搜尋的元件

二十、今天還不是 Long-term Memory

雖然我們已經使用 Vector Database,但今天建立的是:

chromadb.EphemeralClient()

它不會把資料寫進硬碟。

只要關閉程式:

Memory Records
Embeddings
Metadata
Vector Index

仍然會消失。因此目前 Memora 做到的是:

同一次程式執行
↓
Vector Database 可以管理並搜尋 Memory

還沒有做到:

關閉程式
↓
重新啟動
↓
Memory 仍然存在

是否擁有 Long-term Memory,關鍵不只是有沒有 Vector Database,而是資料能不能跨越目前這次 Process 繼續存在,並在未來的 Conversation 被找回來。


Day 16 小結

今天我們把 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。

Day 17|打造第一個 Long-term Memory Store

下一篇會從今天的程式繼續修改,將:

chromadb.EphemeralClient()

換成:

chromadb.PersistentClient(
    path="./memora_db"
)

並把目前散落在程式裡的:

add
get
query
count

整理成一個真正的:

LongTermMemoryStore

到時候我們會重新啟動程式,確認 Memora 是否仍然找得到之前保存的 Memory。

今天我們讓Memora擁有了管理向量的Database;明天再讓這些資料第一次真正跨越程式生命週期,成為可以留下來的Long-term Memory!


上一篇
Day 15|不用 Vector Database,自己實作一次 Semantic Search
下一篇
Day 17|打造第一個 Long-term Memory Store
系列文
從 Stateless LLM 到 Agentic Memory:30 天打造會記憶的 AI Agent23
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言