讓幾個 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)〉:
Acceptheader 與 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 實測。