Design
Why VecStore SDK is a compile-time filter AST with compilers that rewrite rather than degrade, and which alternatives lost.
This page explains the design decisions behind VecStore SDK. It is background reading, not a guide.
Problem
Seven vector databases share four verbs and disagree on the metadata filter language, the id rules, namespace support, and the error types. An application that starts on one provider cannot move without rewriting every query. The design has to express filters once and compile them exactly on every provider, without pulling any provider SDK into the core bundle.
Three constraints applied:
- The provider SDKs stay optional peer dependencies. The adapters import only their types.
- Types are the contract. There is no runtime schema library and no validation of caller input.
- Provider payloads are narrowed at the boundary with named type predicates.
Shape
Filter AST. A discriminated union on kind with leaf nodes (eq, ne, gt, gte, lt, lte, in, nin, exists) and combinators (and, or, not). Membership and combinator children are non-empty tuples, so an empty in or and cannot be built.
Compilers. One pure function per provider from Filter to the provider's native filter type. Five of the seven are total on the operators: no filter fails to compile for want of one. Qdrant has no float match, so eq on a float becomes a closed range. Pinecone has no $not, so the compiler pushes negation to the leaves with De Morgan's laws. Pinecone's $in rejects booleans, so those expand to $or of $eq. Upstash has no NOT either and takes the same pushdown. Upstash can also reject its input, because its filter is a string: a field name or a string value its grammar cannot hold safely throws. Vectorize is the one provider whose filter language is smaller than the AST. Its filter is a flat object of fields joined with AND, so or has no rewrite and exists has no operator, and compileVectorizeFilter returns a Result instead. Redis expresses every operator, but a Redis query names a field its index schema has to declare, so compileRedisFilter takes the declared fields as a second argument and returns a Result too. A filter on a field Redis has no index entry for fails at compile time rather than coming back as an empty page. Supabase has no compiler at all. Its filter is already JSON, so it travels as data and vecstore_filter_sql emits the pgvector predicates inside Postgres.
Adapters. Most adapters take a client that satisfies a structural *ClientLike interface, RedisClientLike included: it names the eight ft, json, and unlink calls the adapter makes, so a node-redis client and a cluster client both fit. The Vectorize adapter names the cloudflare type instead, because two of the responses it needs are typed unknown there and a structural interface would have to restate that unknown in its own signatures. The store is generic over the client type either way, so raw keeps the caller's concrete type. Every verb runs inside one run helper that converts a thrown SDK error to a VecstoreError and returns a Result.
Emulation. Qdrant gets namespaces through a _namespace payload key with a tenant index, and arbitrary ids through a deterministic UUID. Vectorize has native namespaces but scopes ids to the whole index and caps them at 64 bytes, so it borrows the same id hashing. pgvector and Supabase get namespaces through a column in the primary key. Upstash spends its one namespace level on the index name, so by default it keeps the namespace in a _namespace metadata key and prefixes stored ids with it. namespaceMode: "native" spends a real Upstash namespace per index and namespace pair instead. Redis puts the namespace in a tag on the document and in the key, which leaves the id as the last segment of the key and the metadata untouched. All of them round-trip exactly.
Tradeoffs accepted
- A flat metadata type, in exchange for records that upsert on every provider.
- Extra payload keys on Qdrant and Upstash and an extra column on pgvector and Supabase, in exchange for namespaces everywhere.
- Provider-native scores, in exchange for not guessing a normalization that Pinecone's squared euclidean distance would break. Upstash normalizes on its own and the adapter passes that through unchanged.
- Negation on missing fields differs by provider, and the docs say so. In exchange, the compilers use native operators and keep indexes usable.
- pgvector runs one catalog query per index handle to learn the metric, in exchange for working against tables the caller created.
- Supabase asks for a one-time SQL install, in exchange for the whole store contract working over HTTP where PostgREST alone cannot order by a vector operator.
- Upstash's default mode makes every query a filtered query, and so subject to Upstash's filtering budget, in exchange for a tenant count no provider limit bounds.
namespaceMode: "native"inverts the trade. - Three Vectorize verbs return
unsupported, in exchange for an adapter that says what the product does instead of pretending. Vectorize deletes by id only, and its filter language has no OR. - A Vectorize query always asks for metadata, which caps
topKat 50 instead of 100, in exchange for the original id coming back on every match. - Redis records are JSON documents rather than hashes, which costs memory on the document copy of the vector, in exchange for metadata that keeps its types, string lists that index one tag per element, and a vector that reads back at full precision.
- Redis filtering asks for a schema up front, in exchange for an
invalid_argumenterror where an undeclared field would otherwise return nothing.
Alternatives considered
- A MongoDB-style object filter as the public type. Familiar, but it bends the AST toward Pinecone, cannot enforce non-empty lists, and makes
notawkward. - Compilers that return
Resultwith anunsupportedbranch. Rejected for the first five providers, because every operator is expressible there once the rewrites above exist and the branch would be unreachable. Vectorize brought a filter language with no OR and no presence test, and Redis brought a schema a query has to agree with, so those two compilers return aResultand the other five still do not. - A Redis adapter over hashes. Rejected because a hash stores every value as a string, so
year: 1999and"1999"read back the same, a string list has to be joined into one field, and the vector has to travel as a binary blob that the client decodes differently across versions. JSON keeps all three exact. - A Vectorize adapter over the Workers binding. Rejected because a binding holds one index and cannot create, delete, or list indexes, or enumerate namespaces or vectors. Three store verbs would be permanently
unsupported. The HTTP API is the only shape where the whole contract works. - Adapters that construct the client from a config object. Rejected because a runtime import of the SDK turns an optional peer into a real dependency.
- A registry table for pgvector metrics. Rejected in favor of reading the index operator class from
pg_indexes, which also works for tables the SDK did not create. - Sending compiled SQL text to a Supabase function. Rejected because the function is a public PostgREST endpoint. Any caller could pass their own SQL, so the filter crosses as data and Postgres builds the predicate.
- A Supabase adapter over a Postgres connection. Rejected because
createPgvectorStorealready covers that, and it rules out Edge Functions and the browser.
Not in v0
Embedding generation, hybrid and sparse search, reranking, chunking, and an Effect integration.