Qdrant:向量資料庫,
記憶體裡就能跑
一般資料庫回答「等於什麼」:WHERE name = '紅茶'。 向量資料庫回答「最像什麼」:給一個向量,找出最近的幾筆。RAG、推薦、語意搜尋的核心都是這個問題。 先用人看得懂的 3 維「口味向量」體會一下——拉桿組出你想吃的口味,看它推薦什麼:
notebook 裡這八道菜會真的存進 Qdrant——QdrantClient(":memory:") 一行起一個完整的向量資料庫, API 跟正式伺服器一模一樣;最後一節再把口味向量換成真的 embedding。
Collection、Point、Upsert
Collection 是一張表,建立時就要說定向量幾維、用什麼距離;Point 是一筆資料= id+vector+payload(任意 JSON,放原始資訊); Upsert 寫入、同 id 覆蓋。查詢用 query_points,每個命中帶 score (Cosine 模式 1.0=方向完全相同)。實測查「酸辣」[0.2, 0.7, 0.9]: 酸辣粉 0.995、泰式酸辣湯 0.982、麻辣鍋 0.898。 把 ":memory:" 換成 "http://localhost:6333" 就是連正式伺服器,其他一個字不用改。
到 notebook 的 1️⃣–3️⃣ 節:口味向量、建庫、拉桿查詢相似度+條件同時成立;三種「像」的算法
「我想喝酸酸辣辣的飲料」——向量負責酸辣,payload 過濾負責飲料: query_filter=Filter(must=[FieldCondition(key="kind", match=MatchValue(value="drink"))])。 Qdrant 是先篩再排,不是撈前 k 名再丟掉不合的(那樣可能一筆都不剩)。 實測結果:檸檬紅茶 0.60、蜂蜜檸檬 0.50、珍珠奶茶 0.24——沒有飲料真的辣,所以分數都不高, 但在「飲料」子集合裡檸檬紅茶確實最接近。分數是相對的,門檻要看資料。
| distance | 怎麼算 | 查「酸辣」的前 3(實測) |
|---|---|---|
| Cosine | 只看方向、不看長度(文字 embedding 幾乎都用它) | 酸辣粉 0.995 → 泰式酸辣湯 0.982 → 麻辣鍋 0.898 |
| Euclid | 直線距離,越小越近 | 酸辣粉 0.141 → 泰式酸辣湯 0.224 → 麻辣鍋 0.512 |
| Dot | 內積;向量都正規化成長度 1 時等於 Cosine | 酸辣粉 1.23 → 泰式酸辣湯 1.23 → 麻辣鍋 1.02 |
用自然語言查菜單
口味向量是手填的,真實應用的向量來自 embedding 模型。用第 1 課的 gateway 呼叫 qwen3-embedding-0.6b,八道菜的名字各變成 1024 維向量,存進一個新 collection(size=1024)。 查詢也要先經過同一個模型變成向量,再丟給 Qdrant。沒有任何關鍵字比對——實測:
| 查詢 | top-3(score) |
|---|---|
| 想喝冰冰甜甜的飲料 | 珍珠奶茶 0.59 → 檸檬紅茶 0.56 → 泰式酸辣湯 0.49 |
| 辣的湯 | 泰式酸辣湯 0.78 → 麻辣鍋 0.70 → 檸檬紅茶 0.61 |
| 飯後甜點 | 焦糖布丁 0.65 → 檸檬紅茶 0.62 → 麻辣鍋 0.61 |
注意尺度變了:真實 embedding 的「相關」大約 0.5–0.8,「不相關」也有 0.3–0.5,不像口味向量那麼極端。 做 RAG 時門檻要用自己的資料實測。
到 notebook 的 6️⃣ 節:真 embedding、打你自己的句子換你動手
加兩道你喜歡的菜(自己填口味向量),看拉桿拉到哪裡它們會被推薦。
複合條件:kind == "food" 而且 price <= 150,再試 should 與 must_not。
把 kind 與 price 也存進真向量的 collection,做「100 元以內、跟『提神』最相關的東西」。想一想:為什麼查詢與資料必須用同一個 embedding 模型?
卡住了?每一題在 notebook 末節都有折疊解答——先自己做,再打開對照。
情境測驗
離開前試試看:下面的情境都真的會遇到。每題選一個你認為的最佳做法,選了馬上看得到解釋。
Q1 情境題
你用 QdrantClient(":memory:") 把菜單搜尋的雛形做完了,現在要上正式環境,Qdrant 伺服器已經用 Docker 起好。程式該怎麼改?
記憶體模式的賣點就是 API 跟正式伺服器一模一樣——換掉連線字串那一行,建庫、upsert、查詢的程式原封不動重跑一次就上線。A 是誤解,不存在「兩套 API」;C 多此一舉,沒有什麼「伺服器格式」要轉,重跑 upsert 就是匯入;D 有致命副作用:記憶體模式的資料活在 Python 行程裡,行程一結束就消失,只適合學習、測試、demo。
Q2 情境題
使用者要「酸酸辣辣的飲料」。八道菜已經存在 dishes collection,payload 裡有 kind 欄位。最佳做法是?
向量負責「像不像」、payload 過濾負責「是不是」,而且 Qdrant 是先篩再排——在飲料子集合裡找最接近酸辣的。實測第一名是檸檬紅茶 0.60:沒有飲料真的辣,分數不高,但確實是子集合裡最近的。A 正是「先排再篩」的陷阱:酸辣的前三名(0.995/0.982/0.898)全是食物,撈回來一過濾可能一筆不剩;B 做得到,但把向量資料庫的本職(算距離、排序)搬回自己手上,資料一多就慢;C 能動但有副作用——類別一多 collection 就爆增,跨類別查詢還得自己合併。
Q3 錯誤診斷
你把 collection 改用 Distance.EUCLID 重建,同樣查「酸辣」。Cosine 時酸辣粉是 0.995,現在只剩 0.141——資料壞了嗎?
建 collection 時選的 distance 決定 score 的意義:Cosine 是相似度(1 = 同方向)、Euclid 是直線距離(0 = 同一點、越小越近)。排序一致、數字方向相反,完全正常。B 症狀不符——向量存壞的話排序也會亂,這裡前三名跟 Cosine 一模一樣;C 想到別的度量去了——正規化是 Dot 的事(向量都正規化成長度 1 時 Dot = Cosine),解釋不了這個數字;D 沒有這種換算,把不同度量的分數拿來互比本身就是誤用。
Q4 錯誤診斷
你的 dishes_real 是用 qwen3-embedding-0.6b(1024 維)建的。改用 nemotron-3-embed-1b 算查詢向量,一查就炸。最可能的原因與正確修法是?
維度對不上只是最先炸出來的症狀(正式伺服器一樣擋,回 400 Wrong input: Vector dimension error)。根本原因是:索引用哪個模型,查詢就必須用同一個模型——不同模型的座標軸意義完全不同。B 截斷後查得動,但距離毫無意義;D 是最危險的陷阱:就算維度剛好相同,兩個模型的向量空間也對不起來,查出來的「最近鄰」等於亂數;A 方向錯了,這不是記憶體模式的限制。換模型的正確代價,就是整個 collection 用新模型重新 embedding 重建。
Q5 情境題
你把真 embedding 的菜單搜尋接進客服機器人,想設一個 score 門檻把「不相關」的結果擋掉。最佳做法是?
分數尺度跟資料、模型綁在一起:本課實測「相關」約 0.5–0.8、「不相關」也有 0.3–0.5,門檻只能從自己的分數分佈量出來。A 會把所有結果全部擋掉——實測最高分也才 0.78;C 能動但有副作用:庫裡根本沒有相關內容時,top-3 照樣把不相關的端出來(「飯後甜點」的第三名是麻辣鍋 0.61);D 換度量只是換一把尺,尺度一樣要實測,而且文字 embedding 幾乎都用 Cosine。
實作在 molab 跑(免費)
molab 的登入狀態進不了內嵌框架(瀏覽器的跨站 cookie 保護), 所以 notebook 要在新分頁執行——把它跟本頁並排開,左邊教學照樣對照。
- 登入 molab(GitHub / Google)
- 開啟課程 notebook,Fork 成自己的副本即可編輯
- 從第一格往下全部執行(首次安裝套件約 1 分鐘)——免費 CPU 環境即可,不需要 GPU
不想用 molab?下載 qdrant-basics_ext.py 後在自己電腦
uvx marimo edit --sandbox qdrant-basics_ext.py,依賴會自動安裝。