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

> 幾個 AI 工具共用記憶，我用 SQLite 保存正式資料，Qdrant 經 MCP 負責檢索。本文記錄寫入權限、增量同步與舊記憶的淘汰問題，附 cursor 重送與 named vector 實測。

原文：https://sheng.page/posts/shared-ai-memory-sqlite-and-qdrant/ · 發布：2026-10-19 · 作者：Sheng

我自己的助理 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 上跑過。

## 一、先決定哪一份算數

目前的配置大致如下：

```text
寫入：工具 ──► 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](https://github.com/yshengliao/docker-compose-templates/tree/main/qdrant_mcp_hybrid)（整個範本集的介紹在〈[我的 Docker Compose 範本集](/posts/introducing-my-docker-compose-template-collection/)〉）。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` 的規則取：

```python
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 還沒寫回去程式就掛掉的情況：

```python
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)
```

```text
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：

```text
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 送兩次，結果是這樣：

```text
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`，分別用兩個名稱查：

```python
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()}")
```

```text
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`：

```text
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。哪個工具會讀哪個檔、在什麼範圍載入，各家做法不同，要自己確認。載入了也只代表模型看得到，不保證一定照做。放進向量庫還多一層不確定：規則得剛好被查到，才會出現在模型面前，查詢字串換個說法就可能漏掉。

向量庫適合放的是事件和經驗：某次決定了什麼、某個問題當時怎麼解、誰提過什麼需求，這些東西數量多、只有在相關時才需要，用語意檢索剛好。固定規則留在文件裡，就不必反覆寫進記憶庫，也少了新舊規則在記憶庫裡互相矛盾的機會。〈[從通訊軟體到社群與短影片](/posts/community-and-short-video-platforms/)〉裡用 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](https://github.com/yshengliao/docker-compose-templates/tree/main/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](https://github.com/qdrant/mcp-server-qdrant)：官方 Qdrant MCP server，`qdrant-store`、`qdrant-find` 兩個工具、`EMBEDDING_MODEL` 預設值與 `QDRANT_READ_ONLY` 等環境變數。
- [mcp-server-qdrant 的 FastEmbed provider 原始碼](https://github.com/qdrant/mcp-server-qdrant/blob/master/src/mcp_server_qdrant/embeddings/fastembed.py)：`get_vector_name` 把模型名稱轉成 `fast-` 開頭名稱的規則。
- Qdrant 文件〈[Vectors](https://qdrant.tech/documentation/concepts/vectors/)〉：named vector 的定義，以及查詢時用 `using` 指定 vector。
- MCP 規格〈[Transports（2025-11-25）](https://modelcontextprotocol.io/specification/2025-11-25/basic/transports)〉、〈[Streamable HTTP（2026-07-28）](https://modelcontextprotocol.io/specification/2026-07-28/basic/transports/streamable-http)〉與〈[Key Changes（2026-07-28）](https://modelcontextprotocol.io/specification/2026-07-28/changelog)〉：`Accept` header 與 session 的要求、新版必要的 request header，以及移除 session 與 initialize 握手的說明。
- Google〈[Embeddings](https://ai.google.dev/gemini-api/docs/embeddings)〉：Gemini embedding 模型的預設 3072 維與建議維度。
- [pgvector/pgvector](https://github.com/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 實測。