在 PostgreSQL 的 WHERE 裡接上一個語言模型,同一條連線裡把查詢重跑幾遍,每一列拿到的答案都跟第一次一樣。替你守住這件事的,是一個放在 session 記憶體裡的 Python 字典。

pg-jev 是一個 PostgreSQL extension,讓你在 WHERE 裡寫白話條件,每一列交給 TypeSafe 的 Jev 模型判斷,模型回一個機率,過門檻就算 true。README 說 jev() 就是一個普通的 boolean 函式,所以能跟 AND age > 40、join、GROUP BY、LIMIT、ORDER BY jev_prob(...) 混著用。2026 年 10 月 14 日用 GitHub API 查,它有 1,162 顆星、92 個 fork,release 只有 10 月 3 日的 v0.2.1,contributors API 回 1 位。

我沒有安裝它,沒跑過任何一句 SQL,也沒打過 Jev 的 API。下面的內容來自 README、CHANGELOG,以及 sql/jev--0.2.1.sql 這份 566 行的原始碼。這篇講的是程式碼寫了什麼,跑起來是不是完全照這樣,我沒驗證。

五天前寫 jevgrep 時順帶提過 pg-jev,那次談的是批次大小和帳單。這次想看另一層:一個每次問都可能給出不同答案的模型,怎麼能被宣告成資料庫認可的「穩定」函式。

資料庫答應了什麼

PostgreSQL 的函式要自己報上 volatility,三選一。官方文件對 STABLE 的定義是這句:

A STABLE function cannot modify the database and is guaranteed to return the same results given the same arguments for all rows within a single statement. This category allows the optimizer to optimize multiple calls of the function to a single call.

同一條語句裡,同樣的引數,保證同樣的結果,optimizer 可以據此把多次呼叫併成一次。VOLATILE 什麼都不保證,文件還特別寫了,就算沒有副作用,只要值在同一個查詢裡可能改變,也得標 VOLATILE。

照這個定義,語言模型天生比較像 VOLATILE。pg-jev 的宣告卻是這樣(行號以 0.2.1 那份 SQL 檔為準):

函式 volatility 位置
_jev_eval(核心,plpython3u) STABLE 第 39–42 行
jev_eval、jev_prob、jev、jev_score、jev_score_norm、jev_choice、jev_confidence、jev_stats STABLE 第 497–540 行
jev_cache_clear() VOLATILE 第 554–555 行
jev_version() IMMUTABLE 第 561 行

整份檔案只有清快取那一支是 VOLATILE。作者為什麼選 STABLE,README 和程式碼註解都沒解釋,我也沒找到他的說法。我在意的是後面那件事:既然宣告了,這個承諾總得有人兌現。

順手一提,這份 SQL 檔沒有寫 COST,也沒有 PARALLEL、STRICT 之類的屬性。照 CREATE FUNCTION 文件,沒寫 COST 的非 C 函式預設是 100 個單位,跟一支普通的 PL 函式同級,看不出它背後要打一趟 API。包成 LANGUAGE sql 的那幾支實際怎麼估、planner 怎麼排這些條件,我沒跑過 EXPLAIN,不知道。

兌現它的是一個字典

模型不會記得上一題自己答了什麼。pg-jev 在 PL/Python 的 GD 裡開了一塊 GD["jev"]["cache"](第 55–58 行),註解寫得很直白:cache_key -> {row_hash: answer}。查到了就直接回傳舊答案,cache_hits 加一,完全不碰 API(第 444–452 行)。

想像一位閱卷老師,同一份考卷給他改兩次,分數可能差個幾分。旁邊坐了個助教拿本子記:哪一題、哪一份卷子、給了幾分。第二次再遞過來,助教直接念本子上的數字,老師根本沒再看。從外面看,這位老師打分數穩得不得了。

README 把這件事當賣點寫:

Answers are cached per row content and question for the backend session, so re-running, changing the threshold or sorting by probability is free while those answers remain cached.

重跑免費,改門檻免費,按機率排序也免費。改門檻為什麼不用重問?看 jev() 的定義就懂。它是一層 LANGUAGE sql 的薄包裝,本體是 jev_prob($1,$2) >= coalesce($3, …, 0.5)(第 509–513 行)。門檻是在模型回答之後才拿來比的,根本沒進到問模型的那一層。

所以 STABLE 要的「同語句、同引數、同結果」,在 pg-jev 這裡成立的條件是:答案還在本子上。本子屬於誰、什麼時候被撕掉,就是承諾的邊界。README 的 Caveats 寫了兩種撕法:呼叫 jev_cache_clear(),或 backend session 結束。同一段還提醒,連線池裡每個 session 各記各的本子,而且本子會隨著不同的列與問題一直長大,jev.max_prefetch_rows 管不到它。

連線池那條值得多想一下。同一個查詢這次落在連線 A,下次落在連線 B,兩本本子各自從空白開始記。兩邊對同一列的答案會不會一樣,README 沒說,我也不知道。

本子的索引怎麼寫

助教翻本子靠的是索引。pg-jev 的索引在第 439–441 行:外層鍵是 json.dumps([rel_type, query, kind, opts], sort_keys=True),也就是資料表型別、問題文字、題型、選項四樣;內層鍵是整列 JSON 的 SHA-1。

整列 JSON 從哪來?jev_prob 傳給核心的是 to_json($1)::text,整個 row 直接轉成 JSON。從這裡可以直接讀出兩件事。列裡任何一個欄位變了,雜湊就跟著變,這一列在本子上就成了一份新考卷,會重新問模型,就算你問的事跟那個欄位八竿子打不著。問題文字差一個字,外層鍵也不同,等於翻開另一本本子。

第一件事正好對上兩則還開著的 issue。#8(10 月 4 日)的使用者說,他的表有 30 個欄位,有些是長文字,他只想分類 name 那一欄,非得先建一個 view 讓他覺得很彆扭,問作者為什麼這樣設計。#9(10 月 8 日)也在講每個請求都帶上整列的所有欄位。到 10 月 14 日,兩則都還沒有回覆。

README 給的解法就是 view:只放判斷需要的欄位,對 view 呼叫 jev()。照程式碼推,這招順便縮小了雜湊涵蓋的範圍,跟判斷無關的欄位改了,就不會讓本子上的答案作廢。這一點我同樣沒實際驗證。

再來看鍵裡沒有的東西。jev.model 在第 96 行讀進 cfg["model"],只在第 123 行組請求時用到;jev.api_url 在第 90 行解析。全檔 cache_key 只出現在三行,鍵的組成就是上面那四樣。

照這樣讀,同一個 session 裡先用一個模型跑過,再 SET jev.model 換成另一個,同一列同一題可能直接拿到舊模型的答案。換端點也一樣。這是我讀程式碼推出來的,我沒有實際換過模型或端點,也沒用 mock 驗證,請當成待確認的疑點。

換模型、換端點都是文件明說可以做的事。jev.model 的預設值是 jev-latest,README 另外給了釘住版本的寫法,例如 jev-1.13.0。0.2.1 還開放把 jev.api_url 指向本機的相容伺服器,例如 stuntd,沒設 key 就不帶 Authorization header。要是有人在同一個 psql 視窗裡切來切去比兩邊的答案,就正好落在上面那個疑點裡。會這樣用的人多不多,我不知道。

LIMIT 為什麼煞得住

快取管的是問過的不再問。另一個省錢的機制管的是不必問的不要問。README Caveats 第一條寫,這是刻意的全表掃描:執行器問到哪一列,哪一列就送 API;同一個 WHERE 裡比較便宜的條件先跑,被它們刷掉的列直接跳過;LIMIT 會提早停;jev.max_rows_per_statement 幫花費設上限。

0.1.0 的時候,這個煞車其實不太靈。0.2.0 的 CHANGELOG 寫,以前在第一個答案出來之前,整張表(最多讀到 jev.max_prefetch_rows)會先被讀完。改版後依實體順序串流讀取,分批判斷,同時最多有 2 × jev.concurrency 個請求在路上,每一列在它那批回來時就答。作者在 2,000 列的表上量,條件是從歐洲連 API、來回約 190 ms:對一個新條件下 LIMIT 3,從 8.4 秒變成 0.6 秒;完整的第一次執行從 8.5 秒變 3.5 秒。這是作者自己的量測,我沒重現。

同一段結尾還有半句:答案現在會「match single-row evaluation instead of drifting」。反過來讀,0.2.0 以前,批次裡判斷出來的答案會偏離單列判斷的結果。偏多少、多常發生,CHANGELOG 沒寫。

第 464–466 行的註解把執行器跟預讀的關係講白了:執行器回頭要一列被預讀跳過的資料時(index scan、反向掃描、join 順序),就把它跟最近被跳過的幾列一起判斷,因為那些很可能是它的鄰居。主導權在執行器手上,它一列一列來要答案,pg-jev 只是在背後先把批次備好。

換個引擎會怎樣?10 月 14 日剛好有人開了 issue #10,說他把 jev 移植成 ClickHouse 的 executable UDF。ClickHouse 一次把一整個 block 交給 UDF,於是外層查詢的 LIMIT 管不到送去判斷的列數,連他自己都嚇一跳。這是 issue 作者對他自己移植版的描述,他也寫明測試是對 mock 跑的;我沒裝他的版本,維護者也還沒回應。

兩邊放在一起看,我猜這個煞車是跟 PostgreSQL 的執行器借來的,換了引擎就不一定還在。這是推論,沒有驗證。

裝之前

兩件跟快取無關的事。每一列的內容都會送到第三方 API,README 直說不能分享的資料別用。安裝要 superuser 和 plpython3u,README 點名 Supabase、Neon、RDS 這類不給這兩樣的託管服務跑不了;plpython3u 是 untrusted language,函式以資料庫伺服器的 OS 權限執行。

報表要另外存

在 psql 裡探索資料、問完就走,快取給的一致性夠用了,那正是 README 說的重跑免費。要拿去做報表、隔天還得對得上的,就是另一回事。jev() 的結果比較像「這條連線第一次問到的答案」,不是這一列本來就有的屬性,我會把機率連同當時的 jev.model 寫進一張表,之後查那張表。

換模型那個疑點,要等鍵裡加進模型名稱與端點才算解掉;連線池之間的答案能一致到什麼程度,作者還沒寫,現在沒有數字可以判斷。只想送一個欄位的 #8、#9,到 10 月 14 日也還在等作者的第一則回覆。


參考來源