讓幾個 AI 工具共用記憶:資料以 SQLite 為準,Qdrant 只負責檢索

我自己的助理 agent、Claude Desktop 和另一個 IDE 裡的 agent,三個工具透過 MCP 共用同一個 Qdrant collection 當記憶。真的用起來之後,向量資料庫是整套裡最不需要擔心的部分。麻煩在於哪一份資料才算數、誰可以寫進去,兩邊怎麼同步,還有舊的東西什麼時候淘汰。

我用 SQLite 當 canonical store,放事件記憶和 audit log,資料以這裡為準。Qdrant 負責檢索,透過 MCP 操作,另一個舊的 collection 留作唯讀參考。同步任務後來改用 cursor 從 SQLite 增量讀取,過程中還遇到兩個問題,下面連同分層的做法一起記下來。範例在 Python 3.13、SQLite 3.45 和 Qdrant 1.19.2 上跑過。

一、先決定哪一份算數

目前的配置大致如下:

寫入:工具 ──► SQLite(canonical)
                 memory_events:事件記憶
                 audit_log:誰在什麼時候寫了什麼
                 sync_status:同步到哪一筆
                     │
                     └── 增量同步,經 MCP 寫入 ──► Qdrant collection(檢索層,L3)
                                                        ▲
查詢:助理 agent、Claude Desktop、IDE agent ── MCP qdrant-find ──┘

舊 collection(384 維):唯讀參考

現在的 collection 用 Gemini Embedding,3072 維、cosine,另一個較舊的 384 維 collection 保留下來,設成唯讀參考。

官方 mcp-server-qdrant 只支援 FastEmbed,接不了 Gemini,所以 MCP server 是我自己 patch 過的版本,放在 docker-compose-templates 的 qdrant_mcp_hybrid(整個範本集的介紹在〈我的 Docker Compose 範本集〉)。patch 加了一個 Gemini Embedding 2 的 provider(3072 維),設 EMBEDDING_PROVIDER=gemini 才會切過去,預設的 dense 向量仍是 fastembed 的 BAAI/bge-small-en-v1.5(384 維)。檢索改成 dense 加 sparse 的 hybrid search,sparse 用 fastembed 的 BM25(Qdrant/bm25),兩邊結果用 RRF 合併;qdrant-find 另外多了一個選填的 metadata filter。三個工具共用同一個 collection,用 metadata.agent 欄位分開各自的記憶。

速度不是問題,我在本機(local Qdrant 加這個 MCP server)量過,一次寫入約 0.05 秒、一次查詢約 0.002 秒。這只是當時隨手量的數字,不是 benchmark,但足以說明時間都花在別的地方。

寫入路徑只有一條:工具先把記憶和稽核紀錄寫進 SQLite,再由同步任務寫進 Qdrant。不過,官方 mcp-server-qdrant 的 qdrant-store 會直接寫進 Qdrant。經過 MCP 不代表經過 SQLite,工具如果直接呼叫它,一樣是繞過 canonical store。

每個工具都得遵守寫入規則,這套流程才跑得穩,規則也得落在工具設定裡,只寫在設計文件裡不夠。我最常看到的兩種失誤,一種是把檢索庫當 source of truth,直接採信 Qdrant 查到的內容,過期或互相矛盾的記憶就這樣一直被撈出來。

另一種是工具為了方便,繞過 MCP 直接往向量庫寫資料。這和直接呼叫 qdrant-store 一樣,會讓 Qdrant 裡出現 SQLite 沒有的資料。下次從 canonical store 重建檢索層,這些資料就不見了,也查不到是誰寫的。

換 embedding model、調整切段方式,或 collection 壞掉時,檢索層都應該能整個砍掉,從 SQLite 重建一次。把 canonical store 和檢索層分開,最實際的好處就在這裡,只要有一筆資料只存在 Qdrant 裡,這個保證就沒了。

舊 collection 可以在 MCP 這一層設成唯讀。官方 mcp-server-qdrant 的 QDRANT_READ_ONLY 環境變數打開後,就不提供 qdrant-store。每個 collection 各跑一個 server 實例,唯讀的那個工具清單裡根本沒有寫入的選項。

二、同步任務讀不到檔案:改成 cursor 增量同步

一開始的同步腳本讀每天產生的 Markdown 檔,把內容寫進 Qdrant。後來記憶搬進 SQLite,每日檔案不再產生,同步任務就開始報 Read failed,資料其實都在,只是腳本還在找舊的來源。

我把同步任務改成直接讀 SQLite,另加一張 sync_status 表記住最後同步到哪個 id,用它當 cursor。每次取 id 大於 cursor 的新記錄,一批最多 20 筆,寫進 Qdrant,成功之後才把 cursor 往前推。

下面是簡化版,embedding 用 deterministic hash 向量代替,因為容器連不到模型下載來源,同步邏輯本身也和向量品質無關。named vector 的名稱照 mcp-server-qdrant 的規則取:

import hashlib
import math
import sqlite3

from qdrant_client import QdrantClient, models

COLLECTION = "memory_demo"
VECTOR_NAME = "fast-all-minilm-l6-v2"  # 與 collection 建立時的 named vector 一致
DIM = 384
BATCH = 20


def toy_embed(text: str) -> list[float]:
    # 示範用的決定性向量,實際系統換成真正的 embedding model
    raw = hashlib.sha512(text.encode()).digest() * 6
    vec = [b - 127.5 for b in raw[:DIM]]
    norm = math.sqrt(sum(x * x for x in vec))
    return [x / norm for x in vec]


def init_db(conn: sqlite3.Connection) -> None:
    conn.executescript("""
    CREATE TABLE IF NOT EXISTS memory_events (
        id         INTEGER PRIMARY KEY,
        scope      TEXT NOT NULL,
        source     TEXT NOT NULL,
        content    TEXT NOT NULL,
        created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
    );
    CREATE TABLE IF NOT EXISTS sync_status (
        target  TEXT PRIMARY KEY,
        last_id INTEGER NOT NULL
    );
    INSERT OR IGNORE INTO sync_status (target, last_id) VALUES ('qdrant', 0);
    """)


def sync_once(conn: sqlite3.Connection, client: QdrantClient) -> int:
    (cursor,) = conn.execute(
        "SELECT last_id FROM sync_status WHERE target = 'qdrant'").fetchone()
    rows = conn.execute(
        "SELECT id, scope, source, content FROM memory_events "
        "WHERE id > ? ORDER BY id LIMIT ?", (cursor, BATCH)).fetchall()
    if not rows:
        return 0
    points = [
        models.PointStruct(
            id=row_id,  # SQLite id 當 point id,重送就是覆寫
            vector={VECTOR_NAME: toy_embed(content)},
            payload={"document": content,
                     "metadata": {"scope": scope, "source": source,
                                  "sqlite_id": row_id}},
        )
        for row_id, scope, source, content in rows
    ]
    client.upsert(COLLECTION, points=points, wait=True)  # 失敗就丟例外,cursor 不動
    with conn:
        conn.execute("UPDATE sync_status SET last_id = ? WHERE target = 'qdrant'",
                     (rows[-1][0],))
    return len(rows)

測試放了 45 筆記錄,先包裝 client,讓第二次 upsert 丟出連線錯誤,再用正常的 client 重跑。最後把 cursor 手動退回 0,整批重送一次,模擬 upsert 成功了、cursor 還沒寫回去程式就掛掉的情況:

class FlakyClient:
    """第 fail_on 次 upsert 時模擬網路中斷。"""

    def __init__(self, inner: QdrantClient, fail_on: int):
        self.inner, self.fail_on, self.calls = inner, fail_on, 0

    def upsert(self, *args, **kwargs):
        self.calls += 1
        if self.calls == self.fail_on:
            raise ConnectionError("simulated: qdrant unreachable")
        return self.inner.upsert(*args, **kwargs)
synced batch of 20
sync aborted: simulated: qdrant unreachable
after failed run:            cursor=20  points=20
synced batch of 20
synced batch of 5
after retry:                 cursor=45  points=45
after replaying everything:  cursor=45  points=45

第二批失敗時 cursor 停在 20,Qdrant 裡也只有前 20 筆,重跑從第 21 筆接著做。cursor 退回 0、45 筆全部重送一次,point 數量還是 45,因為 point id 就是 SQLite 的 id,同一筆資料再送一次只會覆寫,不會多一筆。

成功才推進 cursor,保證不漏資料,代價是 upsert 成功、cursor 還沒寫回的那一小段時間掛掉時,下次會重送。這是 at-least-once,寫入端得做到 idempotent,重送才不會多出重複資料。

用遞增 id 當 cursor 有幾個前提:id 由 SQLite 自動產生,記錄只新增、不修改也不刪除。INTEGER PRIMARY KEY 本身不保證這些,沒有加 AUTOINCREMENT 時,刪掉目前最大的那筆,下一筆會拿到同一個 id:

id INTEGER PRIMARY KEY                   new id after deleting 3: 3
id INTEGER PRIMARY KEY AUTOINCREMENT     new id after deleting 3: 4

如果 cursor 已經走過 3,重用的 id 就不會再被同步。記憶內容如果允許修改,也得另外記 updated_at 或改動序號,否則改過的內容永遠同步不過去。

經過 MCP 寫入,就得確認 idempotency 能不能做到,而我自己的 stack 剛好做不到。設定了預設 collection 時,官方 mcp-server-qdrant 的 qdrant-store 只接受 information 和 metadata 兩個參數(沒設定時還要指定 collection)。point id 在 server 端用 uuid.uuid4() 產生,呼叫端指定不了,同一批資料用隨機 id 送兩次,結果是這樣:

attempt 1: points=20
attempt 2: points=40

同步任務如果走官方的 qdrant-store,cursor 沒推進就重跑,Qdrant 裡就會有兩份一樣的記憶。我 patch 過的 server 只改了 embedding 和檢索,寫入時 point id 照樣是 uuid.uuid4().hex,所以這個問題在我的環境裡一樣存在。這也是增量同步一定要靠 SQLite 的 cursor、而且成功才推進的原因:server 端不會幫忙去重,能限制重送範圍的只有 cursor,它讓重送只限於 cursor 還沒推進的那一批,沒有它,每次重跑都會把整份記憶再寫一遍。可以把 SQLite id 放進 metadata,查詢後依 id 去重,但這只能清掉已經取回的結果,重複的 point 還在,還可能先占掉查詢的名額。

也可以改用能指定 id 的 MCP server,或讓同步任務用 qdrant-client 指定 id 直接寫入。後者等於開了一個繞過 MCP 的寫入口,必須明確寫進邊界規則裡,只有這一個任務能用。

三、Not existing vector name:設定值和 collection 對不上

有一次 MCP server 查詢時一直報 Not existing vector name。server 設定的 embedding model 是 sentence-transformers/all-MiniLM-L6-v2,既有 collection 的 named vector 卻是 fast-all-minilm-l6-v2,兩個名稱對不上。調整 server 的模型設定,讓查詢用的 vector 名稱和 collection 一致之後就好了。

Qdrant 一個 collection 可以有多個 named vector,查詢時用 using 指定要比對哪一個。mcp-server-qdrant 的 FastEmbed provider 取模型名稱 / 後面那段,轉成小寫,再加上 fast- 前綴當 vector 名稱,所以 sentence-transformers/all-MiniLM-L6-v2 會變成 fast-all-minilm-l6-v2。原始碼註解說,這是為了相容 FastEmbed 0.6.0 以前的命名。

照這個規則,官方 server 設定 sentence-transformers/all-MiniLM-L6-v2 時,查的本來就是 fast-all-minilm-l6-v2。會對不上,代表 server 或建立 collection 的程式用了另一套命名規則,或設定的模型和建立時用的不同。要調整模型設定,讓兩邊推導出同一個名稱,模型欄位仍然填模型名稱。直接填成 fast-... 這類 vector 名稱,換一個 server 實作就可能又對不上。

用 Qdrant 1.19.2 server 重現,collection 裡只有 fast-all-minilm-l6-v2,分別用兩個名稱查:

from qdrant_client import QdrantClient
from qdrant_client.http.exceptions import UnexpectedResponse

from sync_demo import COLLECTION, toy_embed

client = QdrantClient(url="http://localhost:6333")
info = client.get_collection(COLLECTION)
print("named vectors:", list(info.config.params.vectors))

query = toy_embed("memory event #7")
for name in ["sentence-transformers/all-MiniLM-L6-v2", "fast-all-minilm-l6-v2"]:
    try:
        hits = client.query_points(COLLECTION, query=query, using=name, limit=1).points
        print(f"{name}: top hit = {hits[0].payload['document']}")
    except UnexpectedResponse as err:
        print(f"{name}: HTTP {err.status_code} {err.content.decode()}")
named vectors: ['fast-all-minilm-l6-v2']
sentence-transformers/all-MiniLM-L6-v2: HTTP 400 {"status":{"error":"Wrong input: Not existing vector name error: sentence-transformers/all-MiniLM-L6-v2"},"time":0.002283536}
fast-all-minilm-l6-v2: top hit = memory event #7

錯誤訊息會印出找不到的名稱,拿它和 get_collection 列出的名稱比對,通常一眼就能看出差在哪。名稱對上了,還得確認維度和模型真的一致。名稱只是標籤,換了模型卻沿用同一個名稱,維度不同時 Qdrant 會報錯;維度剛好相同時,查詢可能照跑,結果卻已經失去可比性,這比報錯更難發現。

官方 mcp-server-qdrant 目前只支援 FastEmbed 一種 embedding provider,預設模型就是 sentence-transformers/all-MiniLM-L6-v2(384 維),這也是我得自己 patch 一個 Gemini provider 的原因。Gemini 的 gemini-embedding-001 和 gemini-embedding-2 預設輸出都是 3072 維,官方建議 768、1536 或 3072,collection 建立時的維度要和實際輸出一致。

四、curl 直接打 MCP 端點回 406

我想跳過 client,直接用 curl 確認 MCP server 有沒有活著,結果遇到 406。MCP 的 Streamable HTTP transport 規定,client POST 時 Accept header 必須同時列出 application/json 和 text/event-stream。2025-11-25 及更早的版本另有 GET 開 SSE stream,要列出 text/event-stream,新版已經拿掉 GET。server 端照規格檢查,不符合就拒絕。

用 MCP Python SDK 2.2.0 起一個 Streamable HTTP server,Accept 只寫 application/json 送 initialize:

HTTP/1.1 406 Not Acceptable
server: uvicorn
content-type: application/json
mcp-session-id: 5b40f4097d714f659abc508a89c6791e
content-length: 142

{"jsonrpc":"2.0","id":null,"error":{"code":-32600,"message":"Not Acceptable: Client must accept both application/json and text/event-stream"}}

改成 Accept: application/json, text/event-stream 就會回 200 和 text/event-stream。這個版本的 SDK 會把 */* 當成兩種都接受,curl 預設的 Accept: */* 可以通過,手動指定成 application/json 反而會被擋;其他語言的 SDK 或舊版行為不一定相同(需驗證),遇到 406 先看 Accept。

手動用 curl 除錯,還得看規格版本。2025-11-25 版要先送 initialize,server 可以選擇是否建立 session,有回傳 MCP-Session-Id 時,後續每個請求都要帶上。

目前的 2026-07-28 版拿掉了 initialize 握手和協定層的 session,連 Mcp-Session-Id header 都移除了。每個 POST 都要帶 MCP-Protocol-Version 和 Mcp-Method header,tools/call 還要帶 Mcp-Name,body 的 _meta 裡也要放協定版本和 client capabilities,header 和 body 對不上時 server 回 400。照網路上舊教學一步步打,打到只支援新版的 server 時會對不上。

transport 不只在 curl 除錯時會出事。IDE agent 接上 MCP 時,我遇過 SSE 和 HTTP 兩種 transport 衝突,最後把 MCP 服務切到 JSON-RPC over HTTP(也就是 server 用 --transport streamable-http 啟動,對外開 8800 port),IDE 那邊才接得上。

五、記憶首先是知識治理問題

我認為 AI 長期記憶得先訂好保留與採信的規則,再處理檢索。哪些對話內容可以升格成長期記憶?重複的怎麼合併,新舊說法矛盾時以哪一筆為準?不同工具或專案的記憶怎麼隔開,每一筆又從哪裡來,都得交代清楚。

這些答案決定資料表需要哪些欄位,上面範例的 scope 和 source,以及另外一張 audit_log 表,就是用來記錄這些資訊。檢索準確度可以之後再調。

本地記憶可以只存在 SQLite,需要語意搜尋時再接上 Qdrant。如果需求是可以更新、可以稽核的 operational memory,PostgreSQL 加 pgvector 可能比專門的向量資料庫自然。向量和其他欄位存在同一張表,更新和稽核都在同一個 transaction 裡完成,也就省掉 SQLite 和 Qdrant 之間的資料複寫。內容改了,embedding 還是得重新計算。這個想法是我整理 Hacker News 上一串討論時記下的。

六、有些東西該寫成文件

專案慣例、用語規範和禁止事項,agent 每次工作都得遵守,適合寫在 AGENTS.md、CLAUDE.md 這類文件裡,有版本控制,也能 review。哪個工具會讀哪個檔、在什麼範圍載入,各家做法不同,要自己確認。載入了也只代表模型看得到,不保證一定照做。放進向量庫還多一層不確定:規則得剛好被查到,才會出現在模型面前,查詢字串換個說法就可能漏掉。

向量庫適合放的是事件和經驗:某次決定了什麼、某個問題當時怎麼解、誰提過什麼需求,這些東西數量多、只有在相關時才需要,用語意檢索剛好。固定規則留在文件裡,就不必反覆寫進記憶庫,也少了新舊規則在記憶庫裡互相矛盾的機會。〈從通訊軟體到社群與短影片〉裡用 Qdrant 寫過一個最小的推薦範例,那裡的向量查詢就是功能本身,和這裡只當檢索層的定位不同。

補充筆記

  • 幾個 AI 工具共用記憶時,SQLite 當 canonical store,Qdrant 只當檢索層,檢索層要能整個砍掉從 SQLite 重建;工具先寫 SQLite、再由同步寫 Qdrant,直接呼叫官方 qdrant-store 也會繞過 SQLite。
  • 同步用 sync_status 記最後同步的 id 當 cursor,一批 20 筆,成功才推進。這是 at-least-once,重送不重複要靠 SQLite id 當 point id 的 idempotent upsert,id 也不能被重用。
  • 官方 mcp-server-qdrant 的 qdrant-store 用 uuid4 當 point id,經它重送同一批資料會變成兩份;我 patch 過、改用 Gemini 與 hybrid search 的版本也一樣,所以重送範圍只能靠 SQLite cursor 限制。
  • Not existing vector name 是 server 查詢用的 vector 名稱和 collection 的 named vector 對不上,官方 FastEmbed provider 會取模型名稱 / 後面那段,轉成 fast- 開頭的小寫名稱,修正時改模型設定,不要把 vector 名稱填進模型欄位。
  • curl 打 Streamable HTTP 端點回 406,先看 Accept 有沒有同時列出 application/json 和 text/event-stream;2026-07-28 版規格已經拿掉 session 與 initialize 握手。
  • 長期記憶要先訂好升格、去重、矛盾、範圍與來源的規則,每次都要遵守的規則寫進 AGENTS.md 這類文件,向量庫放事件與經驗。

延伸閱讀

  • qdrant_mcp_hybrid:我 patch 過的 mcp-server-qdrant 與 Qdrant 的 Docker Compose 範本,加了 Gemini Embedding provider、dense 加 BM25 sparse 的 RRF hybrid search 和 metadata filter。
  • qdrant/mcp-server-qdrant:官方 Qdrant MCP server,qdrant-store、qdrant-find 兩個工具、EMBEDDING_MODEL 預設值與 QDRANT_READ_ONLY 等環境變數。
  • mcp-server-qdrant 的 FastEmbed provider 原始碼:get_vector_name 把模型名稱轉成 fast- 開頭名稱的規則。
  • Qdrant 文件〈Vectors〉:named vector 的定義,以及查詢時用 using 指定 vector。
  • MCP 規格〈Transports(2025-11-25)〉、〈Streamable HTTP(2026-07-28)〉與〈Key Changes(2026-07-28)〉:Accept header 與 session 的要求、新版必要的 request header,以及移除 session 與 initialize 握手的說明。
  • Google〈Embeddings〉:Gemini embedding 模型的預設 3072 維與建議維度。
  • pgvector/pgvector:PostgreSQL 的向量搜尋擴充,向量和其他資料存在一起,沿用 PostgreSQL 的 transaction 與復原機制。

想法與技術判斷出自 Sheng,和 Claude 一起起草 · 範例在 Python 3.13.16、SQLite 3.45.1、Qdrant 1.19.2(qdrant-client 1.19.1)與 MCP Python SDK 2.2.0 實測。