Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ on:
workflow_dispatch:
inputs:
version:
description: "Semver version (e.g. 0.2.2 or v0.2.2)"
description: "Semver version (e.g. 0.2.3 or v0.2.3)"
required: true
type: string

Expand Down
6 changes: 3 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -229,12 +229,12 @@ GitHub Actions workflows are configured for:

Release options:

1. Manual (recommended): run `Release` workflow via GitHub UI with `version` input (`0.2.2` or `v0.2.2`).
1. Manual (recommended): run `Release` workflow via GitHub UI with `version` input (`0.2.3` or `v0.2.3`).
2. Tag-driven: push a semver tag and the workflow publishes release notes automatically:

```bash
git tag v0.2.2
git push origin v0.2.2
git tag v0.2.3
git push origin v0.2.3
```

## Samples
Expand Down
7 changes: 5 additions & 2 deletions docs/stores-implementation.md
Original file line number Diff line number Diff line change
Expand Up @@ -184,7 +184,10 @@ Search behavior (MVP):
6. Maintain bounded top-k max-heap in memory (`O(k)` memory)
7. Return results sorted by distance asc (tie-break by `ID`)

`EnsureIndexes` is intentionally a no-op in MSSQL backend for this MVP.
`EnsureIndexes` is explicit in MSSQL backend:

- returns `nil` when no index options are requested
- returns an error when vector/metadata index options are provided (index management is not supported yet)

## 6) Filter System and Execution Model

Expand Down Expand Up @@ -242,7 +245,7 @@ These rules are enforced in both backends:
- MSSQL: in-process AST evaluation
- Indexing:
- Postgres: vector + metadata index creation supported
- MSSQL: no-op in MVP
- MSSQL: explicit unsupported error when index options are requested
- Metric/dimension persistence:
- Postgres: inferred/validated from physical `vector(n)` column
- MSSQL: persisted in `__vector_collections`
Expand Down
32 changes: 29 additions & 3 deletions stores/mssql/collection.go
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,19 @@ func (c *MSSQLCollection) Count(ctx context.Context, filter vectordata.Filter) (
return count, nil
}

filterSQL, filterArgs, _, err := compileMSSQLFilterSQL(filter, 1)
if err == nil {
query := fmt.Sprintf("SELECT COUNT(*) FROM %s WHERE %s", c.tableName(), filterSQL)
var count int64
if err := c.store.db.QueryRowContext(ctx, query, filterArgs...).Scan(&count); err != nil {
return 0, err
}
return count, nil
}
if !errors.Is(err, errFilterPushdownUnsupported) {
return 0, err
}

count := int64(0)
if err := c.streamRecords(ctx, false, func(record vectordata.Record) error {
matches, err := matchesFilter(filter, record)
Expand All @@ -151,6 +164,18 @@ func (c *MSSQLCollection) SearchByVector(ctx context.Context, vector []float32,
return nil, err
}

plan, err := c.buildSearchSQLPlan(vector, topK, opts)
if err == nil {
return c.executeSearchSQLPlan(ctx, plan)
}
if !errors.Is(err, errFilterPushdownUnsupported) {
return nil, err
}

return c.searchByVectorStreaming(ctx, vector, topK, opts)
}

func (c *MSSQLCollection) searchByVectorStreaming(ctx context.Context, vector []float32, topK int, opts vectordata.SearchOptions) ([]vectordata.SearchResult, error) {
projection := resolveProjection(opts.Projection)
metric := defaultMetric(c.metric)
topKHeap := make(searchResultMaxHeap, 0, topK)
Expand Down Expand Up @@ -206,10 +231,11 @@ func (c *MSSQLCollection) SearchByVector(ctx context.Context, vector []float32,
}

func (c *MSSQLCollection) EnsureIndexes(ctx context.Context, opts vectordata.IndexOptions) error {
// SQL Server backend stores vectors as JSON payloads in this MVP, so index options are no-op.
_ = ctx
_ = opts
return nil
if opts.Vector == nil && opts.Metadata == nil {
return nil
}
return fmt.Errorf("%w: index management is not supported by the mssql backend", vectordata.ErrSchemaMismatch)
}

func (c *MSSQLCollection) writeRecords(ctx context.Context, records []vectordata.Record, mode writeMode) error {
Expand Down
67 changes: 67 additions & 0 deletions stores/mssql/collection_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@ package mssql

import (
"container/heap"
"context"
"errors"
"reflect"
"sort"
"strings"
"testing"
Expand Down Expand Up @@ -67,3 +70,67 @@ func TestSearchResultHeapKeepsBestTopK(t *testing.T) {
}
}
}

func TestEnsureIndexesMSSQL(t *testing.T) {
collection := &MSSQLCollection{}

if err := collection.EnsureIndexes(context.Background(), vectordata.IndexOptions{}); err != nil {
t.Fatalf("expected empty options to succeed, got %v", err)
}

err := collection.EnsureIndexes(context.Background(), vectordata.IndexOptions{
Vector: &vectordata.VectorIndexOptions{Method: vectordata.IndexMethodHNSW},
})
if err == nil {
t.Fatal("expected error when index options are provided")
}
if !errors.Is(err, vectordata.ErrSchemaMismatch) {
t.Fatalf("expected ErrSchemaMismatch, got %v", err)
}
}

func TestBuildSearchSQLPlan(t *testing.T) {
threshold := 0.55
collection := &MSSQLCollection{
store: &MSSQLVectorStore{opts: StoreOptions{Schema: "dbo"}},
name: "docs",
dimension: 2,
metric: vectordata.DistanceCosine,
}

plan, err := collection.buildSearchSQLPlan([]float32{1, 0}, 3, vectordata.SearchOptions{
Threshold: &threshold,
Projection: &vectordata.Projection{
IncludeMetadata: true,
IncludeContent: true,
},
})
if err != nil {
t.Fatalf("buildSearchSQLPlan: %v", err)
}
if !strings.Contains(plan.query, "FETCH NEXT @p4 ROWS ONLY") {
t.Fatalf("expected limit placeholder in query, got: %s", plan.query)
}
if !reflect.DeepEqual(plan.args, []any{"[1,0]", 2, threshold, 3}) {
t.Fatalf("unexpected args: %#v", plan.args)
}
}

func TestBuildSearchSQLPlanRejectsUnsupportedFilterPushdown(t *testing.T) {
collection := &MSSQLCollection{
store: &MSSQLVectorStore{opts: StoreOptions{Schema: "dbo"}},
name: "docs",
dimension: 2,
metric: vectordata.DistanceCosine,
}

_, err := collection.buildSearchSQLPlan([]float32{1, 0}, 3, vectordata.SearchOptions{
Filter: vectordata.Eq(vectordata.Column("id"), 123),
})
if err == nil {
t.Fatal("expected error")
}
if !errors.Is(err, errFilterPushdownUnsupported) {
t.Fatalf("expected errFilterPushdownUnsupported, got %v", err)
}
}
29 changes: 10 additions & 19 deletions stores/mssql/filter_eval.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@ package mssql
import (
"fmt"
"reflect"
"strings"

"github.com/gabisonia/go-vectorstore/vectordata"
)
Expand Down Expand Up @@ -115,14 +114,14 @@ func matchesFilter(filter vectordata.Filter, record vectordata.Record) (bool, er
}

func resolveFieldValue(field vectordata.FieldRef, record vectordata.Record) (value any, exists bool, err error) {
switch field.Kind {
case vectordata.FieldColumn:
name := strings.TrimSpace(field.Name)
if name == "" {
return nil, false, fmt.Errorf("%w: column field name is empty", vectordata.ErrInvalidFilter)
}
normalized, err := vectordata.NormalizeFieldRef(field)
if err != nil {
return nil, false, err
}

switch name {
switch normalized.Kind {
case vectordata.FieldColumn:
switch normalized.Name {
case idColumn:
return record.ID, true, nil
case contentColumn:
Expand All @@ -131,23 +130,15 @@ func resolveFieldValue(field vectordata.FieldRef, record vectordata.Record) (val
}
return *record.Content, true, nil
default:
return nil, false, fmt.Errorf("%w: unknown column %q", vectordata.ErrInvalidFilter, name)
return nil, false, fmt.Errorf("%w: unknown column %q", vectordata.ErrInvalidFilter, normalized.Name)
}
case vectordata.FieldMetadata:
if len(field.Path) == 0 {
return nil, false, fmt.Errorf("%w: metadata path is empty", vectordata.ErrInvalidFilter)
}
if record.Metadata == nil {
return nil, false, nil
}

var current any = record.Metadata
for _, segment := range field.Path {
key := strings.TrimSpace(segment)
if key == "" {
return nil, false, fmt.Errorf("%w: metadata path segment is empty", vectordata.ErrInvalidFilter)
}

for _, key := range normalized.Path {
asMap, ok := current.(map[string]any)
if !ok {
return nil, false, nil
Expand All @@ -162,7 +153,7 @@ func resolveFieldValue(field vectordata.FieldRef, record vectordata.Record) (val

return current, true, nil
default:
return nil, false, fmt.Errorf("%w: unsupported field kind %q", vectordata.ErrInvalidFilter, field.Kind)
return nil, false, fmt.Errorf("%w: unsupported field kind %q", vectordata.ErrInvalidFilter, normalized.Kind)
}
}

Expand Down
27 changes: 27 additions & 0 deletions stores/mssql/filter_eval_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,33 @@ func TestMatchesFilterUnknownColumnReturnsError(t *testing.T) {
}
}

func TestMatchesFilterTrimsFieldReferences(t *testing.T) {
content := "x"
record := vectordata.Record{
ID: "doc-1",
Content: &content,
Metadata: map[string]any{
"nested": map[string]any{"key": "value"},
},
}

columnMatch, err := matchesFilter(vectordata.Eq(vectordata.Column(" id "), "doc-1"), record)
if err != nil {
t.Fatalf("column match: %v", err)
}
if !columnMatch {
t.Fatalf("expected trimmed column reference to match")
}

metadataMatch, err := matchesFilter(vectordata.Eq(vectordata.Metadata(" nested ", " key "), "value"), record)
if err != nil {
t.Fatalf("metadata match: %v", err)
}
if !metadataMatch {
t.Fatalf("expected trimmed metadata path to match")
}
}

func TestDistanceBetweenMetrics(t *testing.T) {
left := []float32{1, 0}
right := []float32{0.8, 0.2}
Expand Down
Loading
Loading