From 771c081c3ce775e4871dc5101e997c2faf9e9bfc Mon Sep 17 00:00:00 2001 From: PunGrumpy <108584943+PunGrumpy@users.noreply.github.com> Date: Sun, 18 Oct 2026 16:22:53 +0700 Subject: [PATCH 1/6] test(pgvector,supabase): cover negated ranges, zero vectors, opclasses Add tests for three query defects that give wrong results without an error. not around a range filter skips a record that lacks the field, a cosine zero vector ranks first once an HNSW query is wrapped in an outer sort by score, and a euclidean table whose name contains an operator class name is read as that metric. The negated range cases run on both PGlite suites and in the filter differential corpus. A plain-path zero vector test is a control. 11 of the new tests fail at this commit and pass once the adapters are fixed. --- .../vecstore-sdk/test/live/conformance.ts | 26 ++++++++ .../test/postgres/filter-differential.test.ts | 7 ++ packages/vecstore-sdk/test/postgres/pglite.ts | 11 ++++ .../test/postgres/pgvector.test.ts | 66 +++++++++++++++++-- .../test/postgres/supabase.test.ts | 65 ++++++++++++++++-- 5 files changed, 167 insertions(+), 8 deletions(-) diff --git a/packages/vecstore-sdk/test/live/conformance.ts b/packages/vecstore-sdk/test/live/conformance.ts index a30ae61..2eb63dc 100644 --- a/packages/vecstore-sdk/test/live/conformance.ts +++ b/packages/vecstore-sdk/test/live/conformance.ts @@ -159,6 +159,32 @@ export const containsCases: LiveCase[] = [ ], ]; +export const negatedRangeCases: LiveCase[] = [ + [ + "not around a range keeps a record that lacks the field", + async ({ index }) => { + const matches = unwrap( + await index.query({ + filter: not(gt("year", 2000)), + topK: 10, + vector: vectors.a, + }) + ); + expect(idsOf(matches).toSorted()).toStrictEqual(["doc-a", "doc-d"]); + }, + ], + [ + "delete by not around a range removes a record that lacks the field", + async ({ index }) => { + unwrap(await index.delete({ filter: not(gt("year", 2000)) })); + const found = unwrap( + await index.fetch(["doc-a", "doc-b", "doc-c", "doc-d"]) + ); + expect(idsOf(found)).toStrictEqual(["doc-b", "doc-c"]); + }, + ], +]; + export const liveCases: LiveCase[] = [ [ "listIndexes includes the new index", diff --git a/packages/vecstore-sdk/test/postgres/filter-differential.test.ts b/packages/vecstore-sdk/test/postgres/filter-differential.test.ts index 3a8cbb0..c2040a7 100644 --- a/packages/vecstore-sdk/test/postgres/filter-differential.test.ts +++ b/packages/vecstore-sdk/test/postgres/filter-differential.test.ts @@ -41,6 +41,13 @@ const corpus: [string, Filter, string[] | undefined][] = [ ["nin on a list field", notIn("tags", ["y"]), ["c", "d"]], ["exists", exists("tags"), ["a", "b", "d"]], ["not exists", not(exists("tags")), ["c"]], + ["not gt matches a missing field", not(gt("year", 2000)), ["a", "c", "d"]], + ["not lte matches a missing field", not(lte("year", 1999)), ["b", "c"]], + [ + "not around an or of a range matches a missing field", + not(or(gt("year", 2000), eq("genre", "comedy"))), + ["a", "c", "d"], + ], ["and", and(eq("genre", "drama"), gt("year", 1990)), ["a"]], ["or", or(eq("genre", "horror"), isIn("tags", ["y"])), ["a", "b", "c"]], ["not in", not(isIn("tags", ["x"])), ["b", "c", "d"]], diff --git a/packages/vecstore-sdk/test/postgres/pglite.ts b/packages/vecstore-sdk/test/postgres/pglite.ts index a1ae126..3e6ceca 100644 --- a/packages/vecstore-sdk/test/postgres/pglite.ts +++ b/packages/vecstore-sdk/test/postgres/pglite.ts @@ -54,6 +54,17 @@ export const installSupabaseSql = async (db: PGlite): Promise => { export const FORCE_INDEX_SCAN = "set enable_seqscan = off; set enable_bitmapscan = off; set enable_sort = off"; +export const AVOID_INDEX_SCAN = "set enable_indexscan = off"; + +export const zeroVectorRecords: VectorRecord[] = [ + { id: "zero", vector: [0, 0] }, + { id: "x", vector: [1, 0] }, + { id: "y", vector: [0, 1] }, + { id: "nx", vector: [-1, 0] }, +]; + +export const NEAREST_FIRST_ZERO_LAST = ["x", "y", "nx", "zero"]; + const SPREAD = 7; const RARE_EVERY = 10; const RECORDS_PER_NAMESPACE = 200; diff --git a/packages/vecstore-sdk/test/postgres/pgvector.test.ts b/packages/vecstore-sdk/test/postgres/pgvector.test.ts index 32dbcb0..02ff8b5 100644 --- a/packages/vecstore-sdk/test/postgres/pgvector.test.ts +++ b/packages/vecstore-sdk/test/postgres/pgvector.test.ts @@ -5,14 +5,22 @@ import type { PGlite } from "@electric-sql/pglite"; import { eq, gte, ne } from "../../src/filter/ast"; import { createPgvectorStore } from "../../src/pgvector"; import type { DeleteSelector } from "../../src/types"; -import { containsCases, liveCases, setupLive } from "../live/conformance"; +import { + containsCases, + liveCases, + negatedRangeCases, + setupLive, +} from "../live/conformance"; import { emptySelector, filterWithoutValue } from "../malformed"; import { + AVOID_INDEX_SCAN, ensureRoles, FORCE_INDEX_SCAN, isDescending, + NEAREST_FIRST_ZERO_LAST, spreadRecords, usePostgres, + zeroVectorRecords, } from "./pglite"; describe("pgvector on PGlite", () => { @@ -21,9 +29,12 @@ describe("pgvector on PGlite", () => { isolated: true, }); - test.each([...containsCases, ...liveCases])("%s", async (_name, run) => { - await expect(run(live())).resolves.toBeUndefined(); - }); + test.each([...containsCases, ...liveCases, ...negatedRangeCases])( + "%s", + async (_name, run) => { + await expect(run(live())).resolves.toBeUndefined(); + } + ); }); describe("index lifecycle on PGlite", () => { @@ -60,6 +71,29 @@ describe("index lifecycle on PGlite", () => { } }); + test("a euclidean index named after an opclass keeps its metric", async () => { + const store = createPgvectorStore({ client: db() }); + const name = "x_vector_cosine_ops"; + const created = await store.createIndex({ + dimension: 3, + metric: "euclidean", + name, + }); + expect(created.ok).toBeTruthy(); + const index = store.index(name); + await index.upsert([ + { id: "same", vector: [1, 0, 0] }, + { id: "far", vector: [-9, 0, 0] }, + ]); + const result = await index.query({ topK: 2, vector: [1, 0, 0] }); + expect( + result.ok && result.value.map((match) => [match.id, match.score]) + ).toStrictEqual([ + ["same", 0], + ["far", 10], + ]); + }); + test("createIndex rejects a name over 49 bytes and creates nothing", async () => { const name = "a".repeat(50); const store = createPgvectorStore({ client: db() }); @@ -123,6 +157,30 @@ describe("queries through the HNSW index on PGlite", () => { }); }); +describe("a cosine query that meets a zero vector on PGlite", () => { + const db = usePostgres(async (client) => { + const store = createPgvectorStore({ client }); + await store.createIndex({ dimension: 2, name: "wrapped" }); + await store.index("wrapped").upsert(zeroVectorRecords); + await store.createIndex({ dimension: 2, name: "plain" }); + await client.exec('drop index "plain_embedding_idx"'); + await store.index("plain").upsert(zeroVectorRecords); + await client.exec(AVOID_INDEX_SCAN); + }); + + test.each([["wrapped"], ["plain"]])( + "ranks the zero vector last on the %s path", + async (name) => { + const result = await createPgvectorStore({ client: db() }) + .index(name) + .query({ topK: 4, vector: [1, 0] }); + expect(result.ok && result.value.map((match) => match.id)).toStrictEqual( + NEAREST_FIRST_ZERO_LAST + ); + } + ); +}); + describe("a store that met a missing table on PGlite", () => { const db = usePostgres(); diff --git a/packages/vecstore-sdk/test/postgres/supabase.test.ts b/packages/vecstore-sdk/test/postgres/supabase.test.ts index 16e182f..71b4e81 100644 --- a/packages/vecstore-sdk/test/postgres/supabase.test.ts +++ b/packages/vecstore-sdk/test/postgres/supabase.test.ts @@ -11,14 +11,22 @@ import { createSupabaseStore, normalizeSupabaseError, } from "../../src/supabase"; -import { containsCases, liveCases, setupLive } from "../live/conformance"; import { + containsCases, + liveCases, + negatedRangeCases, + setupLive, +} from "../live/conformance"; +import { + AVOID_INDEX_SCAN, ensureRoles, FORCE_INDEX_SCAN, installSupabaseSql, isDescending, + NEAREST_FIRST_ZERO_LAST, spreadRecords, usePostgres, + zeroVectorRecords, } from "./pglite"; type ValueOf = T extends object ? T[keyof T] : never; @@ -87,9 +95,12 @@ describe("supabase on PGlite", () => { { isolated: true } ); - test.each([...containsCases, ...liveCases])("%s", async (_name, run) => { - await expect(run(live())).resolves.toBeUndefined(); - }); + test.each([...containsCases, ...liveCases, ...negatedRangeCases])( + "%s", + async (_name, run) => { + await expect(run(live())).resolves.toBeUndefined(); + } + ); }); describe("vecstore_create_index index lifecycle on PGlite", () => { @@ -114,6 +125,52 @@ describe("vecstore_create_index index lifecycle on PGlite", () => { }); }); +describe("vecstore_metric on PGlite", () => { + const db = usePostgres(installSupabaseSql); + + test("a euclidean index named after an opclass keeps its metric", async () => { + const store = createSupabaseStore({ client: rpcClient(db()) }); + const name = "x_vector_cosine_ops"; + const created = await store.createIndex({ + dimension: 3, + metric: "euclidean", + name, + }); + expect(created.ok).toBeTruthy(); + const index = store.index(name); + await index.upsert([ + { id: "same", vector: [1, 0, 0] }, + { id: "far", vector: [-9, 0, 0] }, + ]); + const result = await index.query({ topK: 2, vector: [1, 0, 0] }); + expect( + result.ok && result.value.map((match) => [match.id, match.score]) + ).toStrictEqual([ + ["same", 0], + ["far", 10], + ]); + }); +}); + +describe("a cosine vecstore_query that meets a zero vector on PGlite", () => { + const db = usePostgres(async (client) => { + await installSupabaseSql(client); + const store = createSupabaseStore({ client: rpcClient(client) }); + await store.createIndex({ dimension: 2, name: "zeroed" }); + await store.index("zeroed").upsert(zeroVectorRecords); + await client.exec(AVOID_INDEX_SCAN); + }); + + test("ranks the zero vector last", async () => { + const result = await createSupabaseStore({ client: rpcClient(db()) }) + .index("zeroed") + .query({ topK: 4, vector: [1, 0] }); + expect(result.ok && result.value.map((match) => match.id)).toStrictEqual( + NEAREST_FIRST_ZERO_LAST + ); + }); +}); + describe("supabase install script", () => { const db = usePostgres(installSupabaseSql); From f388df6d7f96fd2f510aa5034bda14fa7a3bd318 Mon Sep 17 00:00:00 2001 From: PunGrumpy <108584943+PunGrumpy@users.noreply.github.com> Date: Sun, 18 Oct 2026 16:23:33 +0700 Subject: [PATCH 2/6] fix(pgvector,supabase): match missing fields under a negated range A range leaf compiled to a jsonb_typeof check and a comparison. With the field missing, the path is NULL, so the leaf was NULL and NOT of it was NULL too, which dropped the record. not(gt("year", 2000)) skipped records without a year, and delete by that filter left them behind, against the docs. The leaf now starts with an IS NOT NULL check on the path, so a missing field makes it false and NOT of it true. The TypeScript compiler and vecstore_filter_sql change together, and the SQL text expectations that pin the leaf follow. --- packages/vecstore-sdk/sql/supabase.sql | 2 +- packages/vecstore-sdk/src/filter/pgvector.ts | 2 +- packages/vecstore-sdk/test/adapters/pgvector.test.ts | 2 +- packages/vecstore-sdk/test/filter/pgvector.test.ts | 6 +++--- 4 files changed, 6 insertions(+), 6 deletions(-) diff --git a/packages/vecstore-sdk/sql/supabase.sql b/packages/vecstore-sdk/sql/supabase.sql index b49faa0..3c32656 100644 --- a/packages/vecstore-sdk/sql/supabase.sql +++ b/packages/vecstore-sdk/sql/supabase.sql @@ -104,7 +104,7 @@ begin when 'lt' then '<' else '<=' end; - return '(jsonb_typeof(' || path || ') = ''number'' AND ' || path || ' ' + return '(' || path || ' IS NOT NULL AND jsonb_typeof(' || path || ') = ''number'' AND ' || path || ' ' || operator || ' ' || quote_literal(match_filter -> 'value') || '::jsonb)'; end if; diff --git a/packages/vecstore-sdk/src/filter/pgvector.ts b/packages/vecstore-sdk/src/filter/pgvector.ts index 17758ba..9e8bd9d 100644 --- a/packages/vecstore-sdk/src/filter/pgvector.ts +++ b/packages/vecstore-sdk/src/filter/pgvector.ts @@ -49,7 +49,7 @@ const compileNode = (filter: Filter, column: string, param: Param): string => { const path = fieldPath(filter.field); const operator = RANGE_OPERATORS[filter.kind]; const value = `${param(JSON.stringify(filter.value))}::jsonb`; - return `(jsonb_typeof(${path}) = 'number' AND ${path} ${operator} ${value})`; + return `(${path} IS NOT NULL AND jsonb_typeof(${path}) = 'number' AND ${path} ${operator} ${value})`; } case "in": { return containsAny(column, filter.field, filter.values, param); diff --git a/packages/vecstore-sdk/test/adapters/pgvector.test.ts b/packages/vecstore-sdk/test/adapters/pgvector.test.ts index 357e476..3063b03 100644 --- a/packages/vecstore-sdk/test/adapters/pgvector.test.ts +++ b/packages/vecstore-sdk/test/adapters/pgvector.test.ts @@ -251,7 +251,7 @@ describe(createPgvectorStore, () => { "2000", 3, ], - text: `SELECT id, metadata, embedding::text AS embedding, -(embedding <#> $2::vector) AS score FROM "docs" WHERE namespace = $1 AND ((metadata @> $3::jsonb OR metadata @> $4::jsonb) AND (jsonb_typeof((metadata->$5::text)) = 'number' AND (metadata->$5::text) > $6::jsonb)) ORDER BY embedding <#> $2::vector LIMIT $7`, + text: `SELECT id, metadata, embedding::text AS embedding, -(embedding <#> $2::vector) AS score FROM "docs" WHERE namespace = $1 AND ((metadata @> $3::jsonb OR metadata @> $4::jsonb) AND ((metadata->$5::text) IS NOT NULL AND jsonb_typeof((metadata->$5::text)) = 'number' AND (metadata->$5::text) > $6::jsonb)) ORDER BY embedding <#> $2::vector LIMIT $7`, }); await store.index("docs").query({ topK: 1, vector: [0, 0] }); expect(calls).toHaveLength(3); diff --git a/packages/vecstore-sdk/test/filter/pgvector.test.ts b/packages/vecstore-sdk/test/filter/pgvector.test.ts index 8150f6e..719a7d0 100644 --- a/packages/vecstore-sdk/test/filter/pgvector.test.ts +++ b/packages/vecstore-sdk/test/filter/pgvector.test.ts @@ -31,10 +31,10 @@ describe(compilePgvectorFilter, () => { }); }); - test("range operators guard on the JSON type and compare as jsonb", () => { + test("range operators guard on a present JSON number and compare as jsonb", () => { expect(compilePgvectorFilter(gt("year", 2000))).toStrictEqual({ params: ["year", "2000"], - text: "(jsonb_typeof((metadata->$1::text)) = 'number' AND (metadata->$1::text) > $2::jsonb)", + text: "((metadata->$1::text) IS NOT NULL AND jsonb_typeof((metadata->$1::text)) = 'number' AND (metadata->$1::text) > $2::jsonb)", }); expect(compilePgvectorFilter(gte("year", 2000)).text).toContain(" >= "); expect(compilePgvectorFilter(lt("year", 2000)).text).toContain(" < "); @@ -89,7 +89,7 @@ describe(compilePgvectorFilter, () => { }) ).toStrictEqual({ params: ['{"a":1}', '{"a":[1]}', "b", "2"], - text: "((t.meta @> $4::jsonb OR t.meta @> $5::jsonb) AND (jsonb_typeof((t.meta->$6::text)) = 'number' AND (t.meta->$6::text) > $7::jsonb))", + text: "((t.meta @> $4::jsonb OR t.meta @> $5::jsonb) AND ((t.meta->$6::text) IS NOT NULL AND jsonb_typeof((t.meta->$6::text)) = 'number' AND (t.meta->$6::text) > $7::jsonb))", }); }); }); From 70f01aba91a09dec2f2a11ddc4e84593f645223e Mon Sep 17 00:00:00 2001 From: PunGrumpy <108584943+PunGrumpy@users.noreply.github.com> Date: Sun, 18 Oct 2026 16:24:36 +0700 Subject: [PATCH 3/6] fix(pgvector,supabase): sort wrapped queries by distance, zero last An HNSW query is wrapped so an outer sort restores the order after an iterative scan. That sort used the score, descending for cosine and dot. A zero vector has a cosine distance of NaN, so its score is NaN, and Postgres sorts NaN above every number. The zero vector came first, where the inner sort by distance and the plain statement put it last. The wrapped statement now selects the distance next to the score and the outer query sorts by it, ascending for every metric. The plain statement is unchanged. vecstore_query does the same, and the SCORE_ORDERS table and the score_order variable are gone. --- packages/vecstore-sdk/sql/supabase.sql | 9 ++++--- packages/vecstore-sdk/src/pgvector/index.ts | 24 ++++++++++--------- .../test/adapters/pgvector.test.ts | 2 +- 3 files changed, 18 insertions(+), 17 deletions(-) diff --git a/packages/vecstore-sdk/sql/supabase.sql b/packages/vecstore-sdk/sql/supabase.sql index 3c32656..00d7ace 100644 --- a/packages/vecstore-sdk/sql/supabase.sql +++ b/packages/vecstore-sdk/sql/supabase.sql @@ -284,7 +284,6 @@ declare when 'dot' then '-(' || distance || ')' else '1 - (' || distance || ')' end; - score_order text := case metric when 'euclidean' then 'asc' else 'desc' end; where_sql text := 'namespace = ' || quote_literal(match_namespace); begin if match_filter is not null then @@ -309,16 +308,16 @@ begin return query execute format( 'select hits.id, hits.metadata, hits.embedding, hits.score from ( - select id, metadata, %s as embedding, %s as score + select id, metadata, %s as embedding, %s as score, %s as distance from %s where %s order by %s limit %s - ) as hits order by hits.score %s', + ) as hits order by hits.distance', case when include_vector then 'embedding::text' else 'null::text' end, score_sql, + distance, vecstore_table(index_name, index_schema), where_sql, distance, - match_count, - score_order + match_count ); end; $$; diff --git a/packages/vecstore-sdk/src/pgvector/index.ts b/packages/vecstore-sdk/src/pgvector/index.ts index d369716..e1b52b3 100644 --- a/packages/vecstore-sdk/src/pgvector/index.ts +++ b/packages/vecstore-sdk/src/pgvector/index.ts @@ -74,12 +74,6 @@ const DISTANCE_OPERATORS: Record = { euclidean: "<->", }; -const SCORE_ORDERS: Record = { - cosine: "DESC", - dot: "DESC", - euclidean: "ASC", -}; - const HNSW_METHOD = "USING hnsw"; const ITERATIVE_SCAN_MINOR = 8; const ITERATIVE_SCAN_SETTING = @@ -124,8 +118,11 @@ const OPCLASS_METRICS: readonly (readonly [string, Metric])[] = [ const metricFromIndexDef = (definition: string): Metric | undefined => OPCLASS_METRICS.find(([opclass]) => definition.includes(opclass))?.[1]; +const distanceExpression = (metric: Metric, vectorParam: string): string => + `embedding ${DISTANCE_OPERATORS[metric]} ${vectorParam}::vector`; + const scoreExpression = (metric: Metric, vectorParam: string): string => { - const distance = `embedding ${DISTANCE_OPERATORS[metric]} ${vectorParam}::vector`; + const distance = distanceExpression(metric, vectorParam); switch (metric) { case "cosine": { return `1 - (${distance})`; @@ -187,8 +184,8 @@ const resolveProfile = async ( return DEFAULT_PROFILE; }; -const withIterativeScan = (search: string, metric: Metric): string => - `SELECT hits.* FROM (SELECT ${ITERATIVE_SCAN_SETTING} AS scan) AS tuning CROSS JOIN LATERAL (${search}) AS hits ORDER BY hits.score ${SCORE_ORDERS[metric]}`; +const withIterativeScan = (search: string): string => + `SELECT hits.* FROM (SELECT ${ITERATIVE_SCAN_SETTING} AS scan) AS tuning CROSS JOIN LATERAL (${search}) AS hits ORDER BY hits.distance`; const searchRows = async ( context: TableContext, @@ -313,10 +310,15 @@ const createIndex = ( const vectorColumn = query.includeVector ? "embedding::text AS embedding," : ""; - const search = `SELECT id, metadata, ${vectorColumn} ${scoreExpression(metric, "$2")} AS score FROM ${table} WHERE ${where} ORDER BY embedding ${DISTANCE_OPERATORS[metric]} $2::vector LIMIT $${params.length}`; + const distance = distanceExpression(metric, "$2"); + const score = `${scoreExpression(metric, "$2")} AS score`; + const columns = iterativeScan + ? `${score}, ${distance} AS distance` + : score; + const search = `SELECT id, metadata, ${vectorColumn} ${columns} FROM ${table} WHERE ${where} ORDER BY ${distance} LIMIT $${params.length}`; const rows = await searchRows( context, - iterativeScan ? withIterativeScan(search, metric) : search, + iterativeScan ? withIterativeScan(search) : search, params ); const includeMetadata = query.includeMetadata ?? true; diff --git a/packages/vecstore-sdk/test/adapters/pgvector.test.ts b/packages/vecstore-sdk/test/adapters/pgvector.test.ts index 3063b03..cf21969 100644 --- a/packages/vecstore-sdk/test/adapters/pgvector.test.ts +++ b/packages/vecstore-sdk/test/adapters/pgvector.test.ts @@ -299,7 +299,7 @@ describe(createPgvectorStore, () => { .index("docs") .query({ topK: 3, vector: [1, 2] }); expect(calls[1]?.text).toBe( - `SELECT hits.* FROM (SELECT set_config('hnsw.iterative_scan', CASE WHEN current_setting('hnsw.iterative_scan', true) IN ('strict_order', 'relaxed_order') THEN current_setting('hnsw.iterative_scan', true) ELSE 'strict_order' END, true) AS scan) AS tuning CROSS JOIN LATERAL (SELECT id, metadata, embedding <-> $2::vector AS score FROM "docs" WHERE namespace = $1 AND tuning.scan IS NOT NULL ORDER BY embedding <-> $2::vector LIMIT $3) AS hits ORDER BY hits.score ASC` + `SELECT hits.* FROM (SELECT set_config('hnsw.iterative_scan', CASE WHEN current_setting('hnsw.iterative_scan', true) IN ('strict_order', 'relaxed_order') THEN current_setting('hnsw.iterative_scan', true) ELSE 'strict_order' END, true) AS scan) AS tuning CROSS JOIN LATERAL (SELECT id, metadata, embedding <-> $2::vector AS score, embedding <-> $2::vector AS distance FROM "docs" WHERE namespace = $1 AND tuning.scan IS NOT NULL ORDER BY embedding <-> $2::vector LIMIT $3) AS hits ORDER BY hits.distance` ); }); From fca4544ad8354c092413278c3fd2aef65bbf1b8b Mon Sep 17 00:00:00 2001 From: PunGrumpy <108584943+PunGrumpy@users.noreply.github.com> Date: Sun, 18 Oct 2026 16:25:52 +0700 Subject: [PATCH 4/6] fix(pgvector,supabase): read the metric from the embedding opclass The adapter and vecstore_metric read the metric by looking for an operator class name anywhere in an index definition of the table. Every definition contains the table name, so a euclidean table named x_vector_cosine_ops was read as cosine from its primary key. The SQL used like, where the underscore is also a wildcard. Both now match the exact text of the embedding column's index, such as (embedding vector_cosine_ops), and the SQL uses strpos so nothing is read as a pattern. --- packages/vecstore-sdk/sql/supabase.sql | 12 ++++++------ packages/vecstore-sdk/src/pgvector/index.ts | 6 +++--- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/packages/vecstore-sdk/sql/supabase.sql b/packages/vecstore-sdk/sql/supabase.sql index 00d7ace..8dbd9e6 100644 --- a/packages/vecstore-sdk/sql/supabase.sql +++ b/packages/vecstore-sdk/sql/supabase.sql @@ -135,17 +135,17 @@ as $$ select coalesce( ( select case - when indexdef like '%vector_cosine_ops%' then 'cosine' - when indexdef like '%vector_ip_ops%' then 'dot' - when indexdef like '%vector_l2_ops%' then 'euclidean' + when strpos(indexdef, '(embedding vector_cosine_ops)') > 0 then 'cosine' + when strpos(indexdef, '(embedding vector_ip_ops)') > 0 then 'dot' + when strpos(indexdef, '(embedding vector_l2_ops)') > 0 then 'euclidean' end from pg_indexes where tablename = index_name and schemaname = coalesce(index_schema, current_schema()) and ( - indexdef like '%vector_cosine_ops%' - or indexdef like '%vector_ip_ops%' - or indexdef like '%vector_l2_ops%' + strpos(indexdef, '(embedding vector_cosine_ops)') > 0 + or strpos(indexdef, '(embedding vector_ip_ops)') > 0 + or strpos(indexdef, '(embedding vector_l2_ops)') > 0 ) limit 1 ), diff --git a/packages/vecstore-sdk/src/pgvector/index.ts b/packages/vecstore-sdk/src/pgvector/index.ts index e1b52b3..1ac5b9f 100644 --- a/packages/vecstore-sdk/src/pgvector/index.ts +++ b/packages/vecstore-sdk/src/pgvector/index.ts @@ -110,9 +110,9 @@ const extensionVersion = (row: IndexDefinitionRow): string => "extversion" in row && isString(row.extversion) ? row.extversion : ""; const OPCLASS_METRICS: readonly (readonly [string, Metric])[] = [ - [OPCLASSES.cosine, "cosine"], - [OPCLASSES.dot, "dot"], - [OPCLASSES.euclidean, "euclidean"], + [`(embedding ${OPCLASSES.cosine})`, "cosine"], + [`(embedding ${OPCLASSES.dot})`, "dot"], + [`(embedding ${OPCLASSES.euclidean})`, "euclidean"], ]; const metricFromIndexDef = (definition: string): Metric | undefined => From 12ec78ee0c9b15aac07291b89ba98b1d6d98f3e6 Mon Sep 17 00:00:00 2001 From: PunGrumpy <108584943+PunGrumpy@users.noreply.github.com> Date: Sun, 18 Oct 2026 16:26:41 +0700 Subject: [PATCH 5/6] docs: show the range leaf's null guard in the compiled examples The range leaf now starts with an IS NOT NULL check on the path. Update the compiled SQL on the filters page and the pgvector and Supabase outputs of the Match and Range demo cards to show it. --- apps/web/content/docs/core/filters.mdx | 3 ++- apps/web/lib/landing-content.ts | 33 ++++++++++++++++---------- 2 files changed, 22 insertions(+), 14 deletions(-) diff --git a/apps/web/content/docs/core/filters.mdx b/apps/web/content/docs/core/filters.mdx index 3ce6a5a..41d5ae1 100644 --- a/apps/web/content/docs/core/filters.mdx +++ b/apps/web/content/docs/core/filters.mdx @@ -49,7 +49,8 @@ The filter `and(eq("genre", "drama"), gt("year", 2000))` compiles to: ```sql title="pgvector and Supabase" ((metadata @> $1::jsonb OR metadata @> $2::jsonb) - AND (jsonb_typeof((metadata->$3::text)) = 'number' + AND ((metadata->$3::text) IS NOT NULL + AND jsonb_typeof((metadata->$3::text)) = 'number' AND (metadata->$3::text) > $4::jsonb)) -- $1 = '{"genre":"drama"}', $2 = '{"genre":["drama"]}', $3 = 'year', $4 = '2000' ``` diff --git a/apps/web/lib/landing-content.ts b/apps/web/lib/landing-content.ts index c19b5aa..602db60 100644 --- a/apps/web/lib/landing-content.ts +++ b/apps/web/lib/landing-content.ts @@ -171,11 +171,13 @@ export const demoExamples: readonly DemoExample[] = [ output: { pgvector: `((metadata @> $1::jsonb OR metadata @> $2::jsonb) - AND (jsonb_typeof( - (metadata->$3::text)) - = 'number' + AND ((metadata->$3::text) + IS NOT NULL + AND jsonb_typeof( + (metadata->$3::text)) + = 'number' AND (metadata->$3::text) - > $4::jsonb)) + > $4::jsonb)) $1 = '{"genre":"drama"}' $2 = '{"genre":["drama"]}' @@ -203,7 +205,8 @@ $4 = '2000'`, @year:[(2000 +inf])`, supabase: `((metadata @> '{"genre": "drama"}'::jsonb OR metadata @> '{"genre": ["drama"]}'::jsonb) - AND (jsonb_typeof((metadata -> 'year'::text)) + AND ((metadata -> 'year'::text) IS NOT NULL + AND jsonb_typeof((metadata -> 'year'::text)) = 'number' AND (metadata -> 'year'::text) > '2000'::jsonb))`, upstash: `(genre = 'drama' AND year > 2000)`, @@ -222,10 +225,12 @@ $4 = '2000'`, id: "range", label: "Range", output: { - pgvector: `((jsonb_typeof((metadata->$1::text)) + pgvector: `(((metadata->$1::text) IS NOT NULL + AND jsonb_typeof((metadata->$1::text)) = 'number' AND (metadata->$1::text) >= $2::jsonb) - AND (jsonb_typeof((metadata->$3::text)) + AND ((metadata->$3::text) IS NOT NULL + AND jsonb_typeof((metadata->$3::text)) = 'number' AND (metadata->$3::text) <= $4::jsonb)) @@ -251,12 +256,14 @@ $3 = 'price' $4 = '100'`, }`, redis: `(@price:[10 +inf] @price:[-inf 100])`, - supabase: `((jsonb_typeof((metadata -> 'price'::text)) - = 'number' - AND (metadata -> 'price'::text) >= '10'::jsonb) - AND (jsonb_typeof((metadata -> 'price'::text)) - = 'number' - AND (metadata -> 'price'::text) <= '100'::jsonb))`, + supabase: `(((metadata -> 'price'::text) IS NOT NULL + AND jsonb_typeof((metadata -> 'price'::text)) + = 'number' + AND (metadata -> 'price'::text) >= '10'::jsonb) + AND ((metadata -> 'price'::text) IS NOT NULL + AND jsonb_typeof((metadata -> 'price'::text)) + = 'number' + AND (metadata -> 'price'::text) <= '100'::jsonb))`, upstash: `(price >= 10 AND price <= 100)`, vectorize: `{ "price": { "$gte": 10, "$lte": 100 } From 8d8fe98d336c99340f7b1a8e84bb0c3b3e1a20f3 Mon Sep 17 00:00:00 2001 From: PunGrumpy <108584943+PunGrumpy@users.noreply.github.com> Date: Sun, 18 Oct 2026 16:27:19 +0700 Subject: [PATCH 6/6] chore: add a changeset --- .changeset/postgres-query-correctness.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/postgres-query-correctness.md diff --git a/.changeset/postgres-query-correctness.md b/.changeset/postgres-query-correctness.md new file mode 100644 index 0000000..0a06948 --- /dev/null +++ b/.changeset/postgres-query-correctness.md @@ -0,0 +1,5 @@ +--- +"vecstore-sdk": patch +--- + +Fix three pgvector and Supabase query defects. `not` around a range filter, such as `not(gt("year", 2000))`, now matches records that have no `year`, as the docs describe; the range comparison evaluated to SQL `NULL` for a missing field, so those records were skipped by queries and by `delete({ filter })`. A record with a zero vector, whose cosine score is NaN, now ranks last instead of first when Postgres reads an HNSW-indexed table without the index. The metric is now read from the `embedding` column's operator class only, so a table whose name contains an operator class name, such as `x_vector_cosine_ops`, keeps its own metric. Re-run `sql/supabase.sql` to get the new `vecstore_filter_sql`, `vecstore_metric`, and `vecstore_query`.