IntermediateAnalysisAI

pg-jev - Jev judgements as SQL functions

What the pg-jev extension does - SQL functions that send each row to TypeSafe's Jev model and return a boolean, probability, score or class - how it batches and caches API calls, what it costs per row, and where it must not be used.

Applies to
pg-jev0.2.1 (PGXN; repository META.json)A young extension - 12 commits on the repository at review time. Function signatures are documented as stable; defaults come from the docs for that version.PostgreSQL14-17Requires the untrusted plpython3u language and a superuser to CREATE EXTENSION. Not available on managed hosts such as Supabase, Neon, RDS or Aurora that withhold either.Jev (TypeSafe System One API)jev-latest alias, or a pin such as jev-1.13.0 via jev.model
emergingUpdated Reviewed

A WHERE clause can express structure - dates, ranges, foreign keys - and it cannot express judgement. “The customer sounds frustrated” is not a column you can index. pg-jev is a PostgreSQL extension that puts that judgement inside the clause anyway: jev(reviews, 'the customer sounds frustrated') sends each row to TypeSafe’s Jev model and returns true or false, so a plain SELECT can filter, rank, classify or score rows in natural language. There is no index, no embedding column, no ANN structure - every row that reaches the function is judged by the model, and every row that reaches the function is sent to an external API. Both halves of that sentence are the product.

Figure 1. One row, one judgement
Figure 1. One row, one judgementTable rows stream out in physical order, are packed into batches of 20 as one API request, judged by the Jev model, returned as probabilities, and compared against the threshold. Rows below the bar are dropped; the rest form the result.table rowsphysical orderbatchbatch_size 20Jev APIone Noul per rowprobabilityp in 0..1thresholdjev.threshold 0.5kept rowsdiscardedbelow the bar123456
Rows leave the database in batches, come back as probabilities, and meet the threshold inside the predicate. The model never touches your SQL - it only answers questions about rows.
Text equivalent
  1. The table streams in physical order (constant memory, whatever its size).
  2. Rows are packed jev.batch_size (20) at a time into a shared request - one yes/no question per row.
  3. Up to twice jev.concurrency requests run in parallel over persistent connections.
  4. Each row comes back as a probability; jev() compares it with the threshold (argument, then jev.threshold, then 0.5) and keeps or drops the row.
  5. Answers are cached per backend session, so re-running or re-sorting costs no further calls.

Installing it

pg-jev needs a self-hosted PostgreSQL 14, 15, 16 or 17 with plpython3u, and a superuser to load it. plpython3u is untrusted - it runs with the server’s operating-system privileges - which is why managed hosts do not offer it: Supabase, Neon, RDS and Aurora are all excluded unless you control the server. Check before installing:

SHOW server_version;
SELECT * FROM pg_available_extensions WHERE name = 'plpython3u';

On Debian and Ubuntu the language is the postgresql-plpython3-NN package. The extension itself comes from PGXN or source:

pip install pgxnclient          # once
pgxn install jev                # or: make install from a clone
CREATE EXTENSION jev CASCADE;   -- CASCADE pulls in plpython3u
SELECT jev_version();

CASCADE is required: the control file declares plpython3u, and PostgreSQL only auto-creates prerequisite extensions when you ask. The licence is the PostgreSQL License.

Configuration, and the API key

All settings are ordinary GUCs. The one to treat carefully is jev.api_key: the documentation shows SET jev.api_key = 'your-key' and ALTER ROLE analyst SET jev.api_key = 'your-key', and falls back to the TYPESAFE_API_KEY environment variable of the server process - not your psql client. Prefer the environment variable or a role setting. A key set with SET lives in the session and can surface in pg_stat_activity or statement logging if you have log_statement = all; a key in postgresql.conf is a secret sitting in a file that gets backed up and diffed. A key is only sent to *.typesafe.ai hosts; point jev.api_url at a local Jev-compatible server such as stuntd and no key is needed - and no row leaves your network.

Setting Default What it controls
jev.api_key env TYPESAFE_API_KEY TypeSafe API key
jev.api_url https://api.typesafe.ai/v1/systemone Endpoint - proxies, mocks, local servers
jev.model jev-latest Model name or pin, such as jev-1.13.0
jev.threshold 0.5 Probability at which jev() returns true
jev.batch_size 20 Rows per API request; accuracy drops measurably above roughly 20-25
jev.concurrency 16 Parallel requests; up to twice that queued ahead of the executor
jev.max_prefetch_rows 5000 Read-ahead window and skipped-row retention
jev.timeout 30 Seconds per request; interruptible within 250 ms
jev.keepalive 600 Seconds a pooled connection may idle before reconnect
jev.notices on Progress NOTICE per request and a per-table summary
jev.max_rows_per_statement 0 (off) Spend guard: abort over this many rows
jev.max_chars_per_statement 0 (off) Spend guard: abort over this many characters of row data

The function surface

row is the table or view alias. kind is 'noul', 'choice' or 'score'.

Function Returns Purpose
jev(row, condition [, threshold]) boolean The WHERE predicate
jev_prob(row, condition) float8 Probability 0..1 the row satisfies the condition
jev_score(row, question, levels text[]) float8 Probability-weighted position on ordered levels, 0..n-1
jev_score_norm(row, question, levels) float8 The same, scaled to 0..1
jev_choice(row, question, options text[]) text The most likely option for the row
jev_confidence(row, question, kind, options) float8 Confidence of a score or choice answer
jev_eval(row, question, kind, options) jsonb The full raw answer - probabilities, legend, confidence
jev_stats() jsonb Requests, tokens, estimated cost, cache hits, this session
jev_cache_clear() void Forget cached judgements
jev_version() text Extension version
-- filter
SELECT * FROM reviews
WHERE jev(reviews, 'the customer sounds frustrated', 0.8);

-- rank
SELECT subject, jev_prob(tickets, 'the customer is angry') AS p
FROM tickets ORDER BY p DESC LIMIT 20;

-- classify
SELECT jev_choice(tickets, 'which team should handle this?',
                  ARRAY['billing', 'technical', 'security', 'sales']) AS team,
       count(*)
FROM tickets GROUP BY 1;

-- score
SELECT name,
       jev_score(products, 'how luxurious is this product?',
                 ARRAY['budget', 'mid-range', 'premium', 'luxury']) AS luxury
FROM products ORDER BY luxury DESC;

Choosing a threshold from the distribution rather than guessing:

SELECT width_bucket(jev_prob(tickets, 'wants a refund'), 0, 1, 10) AS bucket,
       count(*)
FROM tickets GROUP BY 1 ORDER BY 1;

Ambiguous rows cluster near 0.5; the docs recommend a high bar (0.8-0.9) when you want only the clear cases. Because threshold changes hit the session cache, this exploration costs nothing after the first scan.

Cost, latency and the planner

The mechanism, per the documentation: a read-ahead streams the table in physical order (TID range scans; OFFSET pages for views), rows go out in batches of jev.batch_size with one Noul question per row, up to twice jev.concurrency requests stay in flight over persistent connections, and answers cache per backend session keyed by row content. The vendor’s numbers: roughly 175 input tokens per row at batch size 20 versus about 435 for a row alone (a shared ~270-token request overhead is amortised); cost is input tokens at $0.042 per million (jev-1.13 list price, output free); a 2,000-row table took about 3.5 seconds and $0.012 from Europe, 50 ms from the cache on re-run. jev_stats() reports the running total as estimated_cost_usd. These are the vendor’s own measurements, not independent benchmarks.

What that means in practice - the analysis part, not the docs: jev() is an opaque function to the planner. No index can serve it, and PostgreSQL’s selectivity estimate for it is a guess. Every row the executor reaches is an API call, so the cost of a query is governed by rows scanned before the predicate, not rows returned. The saving graces are documented: cheaper predicates evaluated first skip rows before jev() sees them, and LIMIT stops the read-ahead once the in-flight window is enough. Rows arriving through a subquery or CTE (anonymous record) cannot be read ahead and are judged one request at a time - put jev() on a base table or a view. On shared deployments, jev.max_rows_per_statement and jev.max_chars_per_statement are the documented spend guards; both default to off. The docs do not document planner cost constants for the functions, so EXPLAIN will not warn you what a query will spend - jev_stats() is the meter you check after the fact.

-- shrink the set in SQL first; the model only sees survivors
SELECT * FROM tickets
WHERE created_at >= CURRENT_DATE - 7
  AND status = 'open'
  AND jev(tickets, 'the customer is angry');

SET jev.max_rows_per_statement = 2000;   -- abort rather than pay for a full scan

What can go wrong

  • Data leaves the database. Every judged row goes to TypeSafe. If the data may not be shared, that ends the evaluation - or jev.api_url goes to a local server. This is the caveats page’s first warning, and it deserves to be yours.
  • Full scans by design. A bare WHERE jev(t, ...) on a large table is a metered sequential scan. The guard GUCs abort the statement rather than cap it - an aborted statement is also a failure mode to plan for.
  • A pool with many backends warms many caches. The cache lives in PL/Python’s per-session GD; jev_cache_clear() clears only the session that calls it.
  • Results can move under you. jev.model defaults to the jev-latest alias; when the alias advances, the same predicate can return different rows. Pin a version once thresholds are tuned.
  • Determinism inside a session, not across sessions. The session cache makes a repeated query stable within a session; nothing guarantees the same answer tomorrow, from another connection, or after a model update. Do not materialise jev() answers into data you cannot recompute.
  • CREATE EXTENSION fails on managed hosts - required extension "plpython3u" is not installed, or no superuser to create it. That is where the evaluation ends on those platforms.
  • Batch accuracy has a ceiling. The docs’ own ground-truth check: batches of 1-20 scored 100% correct, 40 dropped to 92-98%, 80 to 77-94%. jev.batch_size defaulting to 20 is an accuracy choice, not just throughput.

What this is not for

  • Similarity search. “Rows like this one” is an embedding problem - use pgvector. pg-jev judges against a written condition; it does not measure distance between rows.
  • Anything a deterministic rule can say. If the condition decomposes to columns and operators, write SQL. A model call for age > 40 is cost without benefit - and it is also wrong, because the model can disagree with arithmetic.
  • Latency-critical paths and private data. Per-row API round trips and rows leaving the cluster rule it out, whatever the cache does.
  • Facts you must defend. The probability is a judgement calibrated in aggregate, not evidence about a specific row. The Jev page covers what calibration does and does not promise.

What to watch

Specific things that would change what this page concludes.

  • Whether PGXN releases keep the documented function signatures and GUC names
  • Managed-host availability - the plpython3u requirement excludes most of them today

Where pg-jev sits

How this component relates to the rest of the stack. Items without a link are not covered here yet.

Depends on

Must be present for this to run

Deployed with

Commonly run alongside

Alternatives

Solves a similar problem differently