Documentation
¶
Overview ¶
Package vector is the backend-neutral vector-search layer shared by drops/pg (pgvector), drops/clickhouse and drops/qdrant.
Before this package each backend had its own search vocabulary: Qdrant took a JSON Filter tree with Must / Should / MustNot blocks, pgvector took drops.Expression predicates and a distance operator, and ClickHouse took neither. Application code that wanted to move a collection from one store to the other — or fan a query out across two of them — had to be rewritten around the new vocabulary.
The types here are that vocabulary, written once:
- Filter is a portable predicate tree (And / Or / Not over Eq, In, Range, IsNull, MatchText, HasID, GeoWithin).
- Query is a similarity search: query vector, TopK, Metric, filter, distance ceiling, cursor.
- Hit and Results are the answer, with both a distance (lower is closer) and a score (higher is better) whatever the metric.
- Store is the one-method interface every backend implements.
A backend turns a Filter into its own representation by handing a Visitor to Compile; the traversal, the error handling and the empty/degenerate cases live here rather than being re-derived three times.
Using it ¶
q := vector.Search(embedding).
TopK(20).
Metric(vector.Cosine).
Where(vector.And(
vector.Eq("lang", "it"),
vector.Gte("published_at", 1700000000),
)).
WithPayload().
Build()
res, err := store.Search(ctx, q) // store is any vector.Store
The same q runs against a github.com/bernardoforcillo/drops/qdrant collection, a pgvector table or a ClickHouse table — only the value bound to store changes.
Pagination ¶
Every backend paginates through one opaque Cursor string. Callers never build or parse one: pass Results.NextCursor to QueryBuilder.After to get the following page, and stop when Results.HasMore is false.
for cursor := ""; ; {
res, err := store.Search(ctx, vector.Search(v).TopK(50).After(cursor).Build())
if err != nil {
return err
}
handle(res.Hits)
if !res.HasMore {
break
}
cursor = res.NextCursor
}
What the cursor carries underneath differs, because the backends genuinely differ:
- SQL backends (pgvector, ClickHouse) use a keyset on (distance, id). The next page's guard is (distance, id) > (lastDistance, lastID), so concurrent inserts never shift rows across a page boundary the way OFFSET does.
- Qdrant's search API has no keyset — it exposes an integer offset — so the cursor carries that offset instead.
Both encode to the same opaque base64 string, and each cursor is stamped with the backend that issued it: replaying a Qdrant cursor against a pgvector store fails with ErrCursorMismatch rather than silently returning the wrong page.
Detecting a further page costs nothing extra — every backend asks for TopK+1 rows and trims.
Index ¶
- Constants
- Variables
- func Compile[R any](f Filter, v Visitor[R]) (out R, ok bool, err error)
- func DistanceFromScore(m Metric, s float64) float64
- func ScoreFromDistance(m Metric, d float64) float64
- type Backend
- type Cursor
- type Filter
- func And(subs ...Filter) Filter
- func Between(field string, low, high any) Filter
- func Eq(field string, value any) Filter
- func GeoWithin(field string, box GeoBox) Filter
- func Gt(field string, value any) Filter
- func Gte(field string, value any) Filter
- func HasID(ids ...any) Filter
- func In(field string, values ...any) Filter
- func IsNotNull(field string) Filter
- func IsNull(field string) Filter
- func Lt(field string, value any) Filter
- func Lte(field string, value any) Filter
- func MatchText(field, text string) Filter
- func Ne(field string, value any) Filter
- func Not(sub Filter) Filter
- func NotIn(field string, values ...any) Filter
- func Or(subs ...Filter) Filter
- type GeoBox
- type GeoPoint
- type Hit
- type Metric
- type Op
- type Query
- type QueryBuilder
- func (b *QueryBuilder) After(cursor string) *QueryBuilder
- func (b *QueryBuilder) Build() Query
- func (b *QueryBuilder) MaxDistance(d float64) *QueryBuilder
- func (b *QueryBuilder) Metric(m Metric) *QueryBuilder
- func (b *QueryBuilder) Param(key string, value any) *QueryBuilder
- func (b *QueryBuilder) TopK(n int) *QueryBuilder
- func (b *QueryBuilder) Where(filters ...Filter) *QueryBuilder
- func (b *QueryBuilder) WithPayload() *QueryBuilder
- func (b *QueryBuilder) WithVector() *QueryBuilder
- type Results
- type Store
- type Visitor
Examples ¶
Constants ¶
const DefaultTopK = 10
DefaultTopK is the page size a Query gets when it sets none.
Variables ¶
var ( // ErrNoVector is returned when a Query carries an empty query // vector. ErrNoVector = errors.New("drops/vector: query vector is empty") // ErrInvalidCursor is returned when a cursor string is malformed // — not base64, or not the JSON payload a cursor encodes to. ErrInvalidCursor = errors.New("drops/vector: invalid cursor") // ErrCursorMismatch is returned when a cursor issued by one // backend is replayed against another. Cursors are opaque but not // portable: a Qdrant offset means nothing to a keyset-paginated // SQL store, so replaying one is a caller bug worth surfacing // rather than a page worth guessing at. ErrCursorMismatch = errors.New("drops/vector: cursor was issued by a different backend") // ErrUnsupportedMetric is returned when a backend cannot serve // the requested Metric — e.g. Hamming against a ClickHouse // Array(Float32) column. ErrUnsupportedMetric = errors.New("drops/vector: metric not supported by this backend") // ErrUnsupportedOp is returned when a backend cannot express a // filter operator. ErrUnsupportedOp = errors.New("drops/vector: filter operator not supported by this backend") // ErrUnknownField is returned by SQL backends when a filter names // a field that maps to no column and the store has no payload // column to fall back on. ErrUnknownField = errors.New("drops/vector: filter field maps to no column") // ErrNotNumeric is returned when a range comparison is handed a // value that is not a number. ErrNotNumeric = errors.New("drops/vector: range comparison requires a numeric value") )
Functions ¶
func Compile ¶
Compile walks f depth-first and hands each node to v, returning the backend representation of the whole tree.
The zero Filter compiles to the zero R and ok=false, which is how a backend distinguishes "no predicate at all" (emit no WHERE clause, send no filter object) from "a predicate that happens to match everything".
func DistanceFromScore ¶
DistanceFromScore is the inverse of ScoreFromDistance. Adapters over stores that return a score natively — Qdrant — use it to fill in Hit.Distance.
func ScoreFromDistance ¶
ScoreFromDistance converts a metric distance into a larger-is-better score.
For Cosine the natural similarity is 1-d, which lands in [-1, 1] and reads the way people expect a cosine similarity to read. For InnerProduct the distance is already the negated product, so the score is its negation — the raw inner product. Every other metric (L2, L1, Hamming, Jaccard) has no bounded similarity to map onto, so the score is simply -d: unbounded, but correctly ordered, which is all a score has to be.
Types ¶
type Backend ¶
type Backend string
Backend names the store that issued a cursor. It is stamped into every cursor so replaying one against the wrong store fails loudly — see ErrCursorMismatch.
type Cursor ¶
type Cursor struct {
// Backend is the store that issued the cursor.
Backend Backend `json:"b"`
// Offset is the number of hits to skip. Offset-paginated backends
// only.
Offset int `json:"o,omitempty"`
// Distance is the last hit's metric distance. Keyset backends
// only.
Distance *float64 `json:"d,omitempty"`
// IDKind and IDText hold the last hit's ID in a form that
// round-trips exactly. Use [Cursor.SetID] and [Cursor.ID] rather
// than touching them directly.
IDKind string `json:"k,omitempty"`
IDText string `json:"i,omitempty"`
}
Cursor is the decoded form of a Results.NextCursor string.
Callers never build one — they pass the opaque string back through QueryBuilder.After. Adapters do, and each fills in only the fields its pagination strategy needs:
- Keyset backends (pgvector, ClickHouse) set Distance and the ID, and resume with the guard (distance, id) > (Distance, ID). Because the ordering key is fully carried in the cursor, rows inserted between two pages cannot shift a result across the boundary.
- Offset backends (Qdrant, whose search API exposes no keyset) set Offset and resume by skipping that many hits.
The ID is stored as text plus a kind tag rather than as a bare JSON value: a large int64 ID would otherwise come back through encoding/json as a float64 and lose its low bits, silently skipping or repeating a row at the page boundary.
func DecodeCursor ¶
DecodeCursor parses a cursor string and checks it came from the expected backend.
An empty string decodes to the zero Cursor with ok=false, which is how an adapter recognises a first-page request without the caller having to special-case it.
func KeysetCursor ¶
KeysetCursor builds the cursor a keyset-paginated backend hands back after serving a page, from the last hit on that page.
func OffsetCursor ¶
OffsetCursor builds the cursor an offset-paginated backend hands back after serving a page.
func (Cursor) Encode ¶
Encode renders the cursor as the opaque, URL-safe string callers pass back through QueryBuilder.After.
func (Cursor) ID ¶
ID returns the ID recorded by Cursor.SetID, restored to its original Go type. The second result is false when the cursor carries no ID — the case for an offset-paginated backend.
type Filter ¶
type Filter struct {
// Op is the node kind. Always set.
Op Op
// Field is the payload key a leaf predicate tests. Empty for
// connectives, for OpHasID (which tests the record ID) and for
// nothing else.
Field string
// Value holds the single operand of Eq / Ne / Lt / Lte / Gt / Gte
// / MatchText.
Value any
// Values holds the operand list of In / NotIn / HasID.
Values []any
// Children holds the operands of And / Or / Not. Not always has
// exactly one.
Children []Filter
// Box is the rectangle of a GeoWithin test.
Box *GeoBox
}
Filter is a portable predicate over a record's payload — the one filter vocabulary the pgvector, ClickHouse and Qdrant backends all compile from.
It is a plain struct rather than an interface so a Filter is comparable-by-inspection, trivially serialisable, and exhaustively switchable by a backend that wants to bypass Compile.
Build one with the constructors — And, Or, Not, Eq, In, Gte and friends — rather than by hand: they are what keeps the field/value/children triple consistent per Op.
func And ¶
And requires every sub-filter to match.
Zero sub-filters — including the case where every argument was the zero Filter — yield the zero Filter, i.e. no constraint at all. That is what makes And the natural way to assemble optional predicates:
f := vector.And(base, maybeLangFilter(req), maybeDateFilter(req))
A single sub-filter collapses to that sub-filter, so And never adds a redundant layer of nesting to the compiled output.
func Between ¶
Between tests low <= payload[field] <= high. It expands to an And of two bounds so every backend gets it for free.
func HasID ¶
HasID tests that the record's own ID is in the given set — the one predicate that addresses the record rather than its payload. An empty list matches nothing.
func MatchText ¶
MatchText runs a substring / tokenised text match on payload[field]. The exact semantics are the backend's: Qdrant tokenises through the field's text index, the SQL backends use a case-insensitive LIKE.
func Not ¶
Not negates a filter. Negating the zero Filter yields the zero Filter — "not nothing" stays "no constraint" rather than becoming an unsatisfiable one.
func Or ¶
Or requires at least one sub-filter to match. Like And, zero sub-filters yield the zero Filter — no constraint. Neither constructor can express an unsatisfiable predicate; to return nothing, don't run the query.
func (Filter) IsZero ¶
IsZero reports whether f is the zero Filter — the shape a caller gets from `var f vector.Filter` and the one Query.Filter uses to mean "no filter".
type GeoBox ¶
type GeoBox struct {
TopLeft GeoPoint `json:"top_left"`
BottomRight GeoPoint `json:"bottom_right"`
}
GeoBox is an axis-aligned geographic rectangle.
type Hit ¶
type Hit struct {
// ID is the record's identifier — an integer or a string,
// whichever the store holds.
ID any
// Distance is the metric distance to the query vector. Lower is
// closer.
Distance float64
// Score is the similarity. Higher is better.
Score float64
// Vector is the stored embedding, present only when the query set
// [Query.IncludeVector].
Vector []float32
// Payload is the record's metadata, present only when the query
// set [Query.IncludePayload].
Payload map[string]any
}
Hit is one search result.
Distance and Score are two views of the same number, both always populated, because backends disagree about which one they return natively and callers disagree about which one they want:
- Distance is in the Metric's own units, and smaller is closer. Compare it against a threshold, or against another hit from the same query.
- Score is a ranking value where larger is better. Sort by it without caring which metric produced it.
ScoreFromDistance and DistanceFromScore are the single conversion between them; every adapter goes through those two functions rather than re-deriving the sign convention.
type Metric ¶
type Metric string
Metric is the distance function a search ranks by.
Not every backend serves every metric: pgvector covers all six, ClickHouse covers Cosine / L2 / L1 / InnerProduct, and Qdrant fixes the metric when the collection is created — there the Metric on a Query only tells the adapter how to convert Qdrant's score into a distance. A backend asked for a metric it cannot serve returns ErrUnsupportedMetric rather than quietly ranking by another one.
const ( // Cosine is the cosine distance, 1 - cos(θ), in [0, 2]. Cosine Metric = "cosine" // L2 is the Euclidean distance. L2 Metric = "l2" // InnerProduct ranks by the negated inner product, so that — like // every other Metric — smaller is closer. InnerProduct Metric = "inner_product" // L1 is the Manhattan distance. L1 Metric = "l1" // Hamming is the bit-vector Hamming distance. Hamming Metric = "hamming" // Jaccard is the bit-vector Jaccard distance. Jaccard Metric = "jaccard" )
type Op ¶
type Op string
Op identifies a node in a Filter tree — either a logical connective (And / Or / Not) or a leaf predicate.
type Query ¶
type Query struct {
// Vector is the query embedding. Required.
Vector []float32
// TopK is the maximum number of hits to return. Zero means
// [DefaultTopK].
TopK int
// Metric is the distance function. Empty means [Cosine].
Metric Metric
// Filter narrows the candidate set. The zero Filter means no
// filtering.
Filter Filter
// MaxDistance, when non-nil, drops hits farther than this bound.
// It is expressed in the Metric's own units — a cosine distance
// of 0.25, a Euclidean distance of 3.0 — never as a similarity,
// so the same number means the same thing on every backend.
MaxDistance *float64
// IncludeVector asks the backend to return each hit's stored
// vector. Off by default: it is the expensive part of the payload.
IncludeVector bool
// IncludePayload asks the backend to return each hit's payload.
IncludePayload bool
// Cursor resumes after a previous page. Empty starts at the first.
Cursor string
// Params carries backend-specific tuning that has no portable
// meaning — Qdrant's hnsw_ef and exact, ClickHouse SETTINGS,
// pgvector's ivfflat.probes. A backend ignores keys it does not
// recognise, so a Query stays runnable everywhere.
Params map[string]any
}
Query is one similarity search. Build it with Search rather than by hand — the builder is what applies the defaults and keeps the cursor opaque.
func (Query) Normalized ¶
Normalized returns a copy of q with defaults applied: TopK becomes DefaultTopK when unset, Metric becomes Cosine when empty. Backends call it first so the defaults are identical across stores.
type QueryBuilder ¶
type QueryBuilder struct {
// contains filtered or unexported fields
}
QueryBuilder assembles a Query. The zero value is not useful; start from Search.
func Search ¶
func Search(v []float32) *QueryBuilder
Search starts a query for the given embedding.
q := vector.Search(emb).TopK(20).Where(vector.Eq("lang", "it")).Build()
func (*QueryBuilder) After ¶
func (b *QueryBuilder) After(cursor string) *QueryBuilder
After resumes from a cursor returned as Results.NextCursor. The empty string means "first page", so passing a fresh loop variable through works without a special case.
func (*QueryBuilder) Build ¶
func (b *QueryBuilder) Build() Query
Build returns the assembled query with defaults applied.
func (*QueryBuilder) MaxDistance ¶
func (b *QueryBuilder) MaxDistance(d float64) *QueryBuilder
MaxDistance drops hits farther than d, in the metric's own units.
func (*QueryBuilder) Metric ¶
func (b *QueryBuilder) Metric(m Metric) *QueryBuilder
Metric selects the distance function.
func (*QueryBuilder) Param ¶
func (b *QueryBuilder) Param(key string, value any) *QueryBuilder
Param sets a backend-specific tuning parameter. See Query.Params.
func (*QueryBuilder) TopK ¶
func (b *QueryBuilder) TopK(n int) *QueryBuilder
TopK caps the page size. Non-positive values are ignored, leaving the default in place.
func (*QueryBuilder) Where ¶
func (b *QueryBuilder) Where(filters ...Filter) *QueryBuilder
Where sets the filter. Multiple filters are ANDed, so Where can be called repeatedly as a query is built up in stages.
func (*QueryBuilder) WithPayload ¶
func (b *QueryBuilder) WithPayload() *QueryBuilder
WithPayload asks for each hit's payload.
func (*QueryBuilder) WithVector ¶
func (b *QueryBuilder) WithVector() *QueryBuilder
WithVector asks for each hit's stored vector.
type Results ¶
type Results struct {
// Hits are the matches, nearest first.
Hits []Hit
// NextCursor resumes the search after the last hit. Empty when
// there is no further page.
NextCursor string
// HasMore reports whether a further page exists. It is decided by
// asking the backend for TopK+1 hits and trimming, so it costs no
// extra round trip and never lies about a page that turns out to
// be empty.
HasMore bool
}
Results is one page of hits.
Example (Pagination) ¶
Paging is the same three lines whichever store is underneath: pass the previous page's cursor back, stop when HasMore goes false.
package main
import (
"context"
"fmt"
"math"
"sort"
"github.com/bernardoforcillo/drops/vector"
)
// memStore is a toy in-memory vector.Store. A real one is
// pg.NewVectorStore, clickhouse.NewVectorStore or (*qdrant.Client).Store
// — this one exists so the examples run without a database.
type memStore struct {
ids []string
vectors [][]float32
}
func (m *memStore) Search(_ context.Context, q vector.Query) (*vector.Results, error) {
q = q.Normalized()
if err := q.Validate(); err != nil {
return nil, err
}
hits := make([]vector.Hit, 0, len(m.ids))
for i, v := range m.vectors {
d := cosineDistance(q.Vector, v)
if q.MaxDistance != nil && d > *q.MaxDistance {
continue
}
hits = append(hits, vector.Hit{
ID: m.ids[i],
Distance: d,
Score: vector.ScoreFromDistance(q.Metric, d),
})
}
sort.Slice(hits, func(a, b int) bool { return hits[a].Distance < hits[b].Distance })
cur, ok, err := vector.DecodeCursor(q.Cursor, vector.BackendPostgres)
if err != nil {
return nil, err
}
if ok && cur.Distance != nil {
for len(hits) > 0 && hits[0].Distance <= *cur.Distance {
hits = hits[1:]
}
}
res := &vector.Results{HasMore: len(hits) > q.TopK}
if res.HasMore {
hits = hits[:q.TopK]
next, kErr := vector.KeysetCursor(vector.BackendPostgres, hits[len(hits)-1])
if kErr != nil {
return nil, kErr
}
if res.NextCursor, err = next.Encode(); err != nil {
return nil, err
}
}
res.Hits = hits
return res, nil
}
func cosineDistance(a, b []float32) float64 {
var dot, na, nb float64
for i := range a {
dot += float64(a[i]) * float64(b[i])
na += float64(a[i]) * float64(a[i])
nb += float64(b[i]) * float64(b[i])
}
if na == 0 || nb == 0 {
return 1
}
return 1 - dot/(math.Sqrt(na)*math.Sqrt(nb))
}
func main() {
store := &memStore{
ids: []string{"a", "b", "c", "d"},
vectors: [][]float32{{1, 0, 0}, {0.9, 0.1, 0}, {0.5, 0.5, 0}, {0, 1, 0}},
}
ctx := context.Background()
for cursor, page := "", 1; ; page++ {
res, err := store.Search(ctx, vector.Search([]float32{1, 0, 0}).TopK(2).After(cursor).Build())
if err != nil {
panic(err)
}
for _, h := range res.Hits {
fmt.Printf("page %d: %s\n", page, h.ID)
}
if !res.HasMore {
break
}
cursor = res.NextCursor
}
}
Output: page 1: a page 1: b page 2: c page 2: d
type Store ¶
Store is a searchable collection of vectors. It is the whole contract a backend has to satisfy to be usable through this package — drops/pg, drops/clickhouse and drops/qdrant each provide one.
Example ¶
The point of the package: search is written once against vector.Store, so the same function runs against pgvector, ClickHouse or Qdrant depending only on what is passed in.
package main
import (
"context"
"fmt"
"math"
"sort"
"github.com/bernardoforcillo/drops/vector"
)
// memStore is a toy in-memory vector.Store. A real one is
// pg.NewVectorStore, clickhouse.NewVectorStore or (*qdrant.Client).Store
// — this one exists so the examples run without a database.
type memStore struct {
ids []string
vectors [][]float32
}
func (m *memStore) Search(_ context.Context, q vector.Query) (*vector.Results, error) {
q = q.Normalized()
if err := q.Validate(); err != nil {
return nil, err
}
hits := make([]vector.Hit, 0, len(m.ids))
for i, v := range m.vectors {
d := cosineDistance(q.Vector, v)
if q.MaxDistance != nil && d > *q.MaxDistance {
continue
}
hits = append(hits, vector.Hit{
ID: m.ids[i],
Distance: d,
Score: vector.ScoreFromDistance(q.Metric, d),
})
}
sort.Slice(hits, func(a, b int) bool { return hits[a].Distance < hits[b].Distance })
cur, ok, err := vector.DecodeCursor(q.Cursor, vector.BackendPostgres)
if err != nil {
return nil, err
}
if ok && cur.Distance != nil {
for len(hits) > 0 && hits[0].Distance <= *cur.Distance {
hits = hits[1:]
}
}
res := &vector.Results{HasMore: len(hits) > q.TopK}
if res.HasMore {
hits = hits[:q.TopK]
next, kErr := vector.KeysetCursor(vector.BackendPostgres, hits[len(hits)-1])
if kErr != nil {
return nil, kErr
}
if res.NextCursor, err = next.Encode(); err != nil {
return nil, err
}
}
res.Hits = hits
return res, nil
}
func cosineDistance(a, b []float32) float64 {
var dot, na, nb float64
for i := range a {
dot += float64(a[i]) * float64(b[i])
na += float64(a[i]) * float64(a[i])
nb += float64(b[i]) * float64(b[i])
}
if na == 0 || nb == 0 {
return 1
}
return 1 - dot/(math.Sqrt(na)*math.Sqrt(nb))
}
func main() {
store := &memStore{
ids: []string{"go", "rust", "cooking"},
vectors: [][]float32{{1, 0, 0}, {0.9, 0.1, 0}, {0, 0, 1}},
}
q := vector.Search([]float32{1, 0, 0}).
TopK(2).
Metric(vector.Cosine).
Where(vector.And(
vector.Eq("lang", "en"),
vector.Gte("year", 2020),
)).
WithPayload().
Build()
res, err := store.Search(context.Background(), q)
if err != nil {
panic(err)
}
for _, h := range res.Hits {
fmt.Printf("%s (distance %.2f)\n", h.ID, h.Distance)
}
}
Output: go (distance 0.00) rust (distance 0.01)
type Visitor ¶
type Visitor[R any] interface { // And / Or combine already-compiled children. Compile always // passes at least two parts: empty connectives collapse to the // zero Filter and single-child ones collapse to that child, both // before the visitor is ever reached. And(parts []R) (R, error) Or(parts []R) (R, error) // Not negates an already-compiled child. Not(part R) (R, error) // Compare handles Eq / Ne / Lt / Lte / Gt / Gte. Compare(op Op, field string, value any) (R, error) // Set handles In / NotIn. Set(op Op, field string, values []any) (R, error) // Null handles IsNull / IsNotNull. Null(op Op, field string) (R, error) // MatchText handles a text match on a payload field. MatchText(field, text string) (R, error) // HasID handles an ID membership test. HasID(ids []any) (R, error) // GeoWithin handles a bounding-box test. GeoWithin(field string, box GeoBox) (R, error) }
Visitor turns Filter nodes into a backend's own representation R — a qdrant.Condition, a drops.Expression, whatever the store speaks.
Compile drives it: a Visitor never walks the tree itself, so the traversal order, the child recursion and the degenerate cases are decided once here instead of once per backend.