diff --git a/CHANGELOG.md b/CHANGELOG.md index bbaa166..0038984 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,13 @@ All notable changes to this project will be documented in this file. The format ## [Unreleased] +### Added + +- Native vector search support: `vector(n)` column mapping for `float[]` + properties (`HasVectorType`), vector fields with metric operator classes in + ParadeDB indexes (`HasField(..., VectorMetric)`), and the `L2Distance`, + `CosineDistance`, and `InnerProduct` distance functions. + ### Changed - **Breaking**: `HasBm25Index` and `Bm25IndexBuilder` are renamed to diff --git a/NUGET-README.md b/NUGET-README.md index 4baf4f2..9fbc8c6 100644 --- a/NUGET-README.md +++ b/NUGET-README.md @@ -1,6 +1,6 @@ # ParadeDB for Entity Framework Core -The official [Entity Framework Core](https://learn.microsoft.com/en-us/ef/core/) integration for [ParadeDB](https://paradedb.com), including first-class support for managing BM25 indexes and running queries using the full ParadeDB API. Follow the [getting started guide](https://docs.paradedb.com/documentation/getting-started/environment#entity-framework-core) to begin. +The official [Entity Framework Core](https://learn.microsoft.com/en-us/ef/core/) integration for [ParadeDB](https://paradedb.com), including first-class support for managing BM25 indexes and running queries using the full ParadeDB API. The integration covers both full-text and vector search — vector search indexes pgvector `vector` columns directly, with no separate pgvector ORM plugin required. Follow the [getting started guide](https://docs.paradedb.com/documentation/getting-started/environment#entity-framework-core) to begin. ## Requirements & Compatibility @@ -10,6 +10,7 @@ The official [Entity Framework Core](https://learn.microsoft.com/en-us/ef/core/) | EF Core | 8.0+ | | ParadeDB | 0.25.0+ | | PostgreSQL | 15+ (with ParadeDB extension) | +| pgvector | Required for vector search | ## Examples @@ -18,6 +19,7 @@ The official [Entity Framework Core](https://learn.microsoft.com/en-us/ef/core/) - [Autocomplete](examples/Autocomplete/Program.cs) - [More Like This](examples/MoreLikeThis/Program.cs) - [Hybrid Search (RRF)](examples/HybridRrf/Program.cs) +- [Vector Search](examples/VectorSearch/Program.cs) - [RAG](examples/Rag/Program.cs) ## Contributing diff --git a/README.md b/README.md index 62102c8..2941317 100644 --- a/README.md +++ b/README.md @@ -36,7 +36,7 @@ ## ParadeDB for Entity Framework Core -The official [Entity Framework Core](https://learn.microsoft.com/en-us/ef/core/) integration for [ParadeDB](https://paradedb.com) (powered by the [`pg_search`](https://github.com/paradedb/paradedb) Postgres extension), including first-class support for managing BM25 indexes and running queries using the full ParadeDB API. Follow the [getting started guide](https://docs.paradedb.com/documentation/getting-started/environment#entity-framework-core) to begin. +The official [Entity Framework Core](https://learn.microsoft.com/en-us/ef/core/) integration for [ParadeDB](https://paradedb.com) (powered by the [`pg_search`](https://github.com/paradedb/paradedb) Postgres extension), including first-class support for managing BM25 indexes and running queries using the full ParadeDB API. The integration covers both full-text and vector search — vector search indexes pgvector `vector` columns directly, with no separate pgvector ORM plugin required. Follow the [getting started guide](https://docs.paradedb.com/documentation/getting-started/environment#entity-framework-core) to begin. ## Requirements & Compatibility @@ -46,6 +46,7 @@ The official [Entity Framework Core](https://learn.microsoft.com/en-us/ef/core/) | EF Core | 8.0+ | | ParadeDB | 0.25.0+ | | PostgreSQL | 15+ (with ParadeDB extension) | +| pgvector | Required for vector search | ## Examples @@ -54,6 +55,7 @@ The official [Entity Framework Core](https://learn.microsoft.com/en-us/ef/core/) - [Autocomplete](examples/Autocomplete/Program.cs) - [More Like This](examples/MoreLikeThis/Program.cs) - [Hybrid Search (RRF)](examples/HybridRrf/Program.cs) +- [Vector Search](examples/VectorSearch/Program.cs) - [RAG](examples/Rag/Program.cs) ## Contributing diff --git a/examples/Directory.Packages.props b/examples/Directory.Packages.props index 0835be6..3a599ab 100644 --- a/examples/Directory.Packages.props +++ b/examples/Directory.Packages.props @@ -1,7 +1,3 @@ - - - - diff --git a/examples/Examples.csproj b/examples/Examples.csproj index 3eb4f45..23b42c1 100644 --- a/examples/Examples.csproj +++ b/examples/Examples.csproj @@ -12,14 +12,13 @@ - + - diff --git a/examples/HybridRrf/Data/MockItemWithEmbedding.cs b/examples/HybridRrf/Data/MockItemWithEmbedding.cs index 7f22383..d9eef7b 100644 --- a/examples/HybridRrf/Data/MockItemWithEmbedding.cs +++ b/examples/HybridRrf/Data/MockItemWithEmbedding.cs @@ -1,9 +1,8 @@ -using Pgvector; using Shared; namespace HybridRrf.Data; public class MockItemWithEmbedding : MockItem { - public Vector? Embedding { get; set; } + public float[]? Embedding { get; set; } } diff --git a/examples/HybridRrf/EmbeddingLoader.cs b/examples/HybridRrf/EmbeddingLoader.cs new file mode 100644 index 0000000..5851a30 --- /dev/null +++ b/examples/HybridRrf/EmbeddingLoader.cs @@ -0,0 +1,39 @@ +using HybridRrf.Data; +using Microsoft.EntityFrameworkCore; + +namespace HybridRrf; + +public static class EmbeddingLoader +{ + public static async Task LoadAsync(AppDbContext db) + { + var csvPath = Path.Combine( + AppContext.BaseDirectory, + "HybridRrf", + "mock_items_embeddings.csv" + ); + var lines = await File.ReadAllLinesAsync(csvPath); + + var embeddings = new Dictionary(); + + for (var i = 1; i < lines.Length; i++) + { + var parts = lines[i].Split(',', 3); + embeddings[int.Parse(parts[0])] = parts[2] + .Trim('"', '[', ']') + .Split(',', StringSplitOptions.TrimEntries) + .Select(float.Parse) + .ToArray(); + } + + var ids = embeddings.Keys.ToArray(); + var items = await db.MockItems.Where(x => ids.Contains(x.Id)).ToListAsync(); + foreach (var item in items) + { + item.Embedding = embeddings[item.Id]; + } + + await db.SaveChangesAsync(); + Console.WriteLine($"Loaded {items.Count} embeddings"); + } +} diff --git a/examples/HybridRrf/Program.cs b/examples/HybridRrf/Program.cs index ed8a9b0..90e3428 100644 --- a/examples/HybridRrf/Program.cs +++ b/examples/HybridRrf/Program.cs @@ -2,19 +2,10 @@ using HybridRrf.Data; using Microsoft.EntityFrameworkCore; using ParadeDB.EntityFrameworkCore.Extensions; -using Pgvector; -using Pgvector.EntityFrameworkCore; using Shared; var options = new DbContextOptionsBuilder() - .UseNpgsql( - ExampleSetup.ConnectionString, - o => - { - o.UseParadeDb(); - o.UseVector(); - } - ) + .UseNpgsql(ExampleSetup.ConnectionString, o => o.UseParadeDb()) .UseSnakeCaseNamingConvention() .Options; @@ -27,7 +18,7 @@ Console.WriteLine("RRF formula: score = sum(1 / (k + rank)) across all rankings"); await ExampleSetup.SetupHybridAsync(dbContext); -await LoadEmbeddingsAsync(dbContext); +await EmbeddingLoader.LoadAsync(dbContext); await Demo(dbContext, "running shoes", QueryEmbeddings.Values); await Demo(dbContext, "footwear for exercise", QueryEmbeddings.Values); @@ -40,14 +31,14 @@ static async Task Demo(AppDbContext db, string query, Dictionary queryEmbeddings) { - var results = await HybridSearch(db, query, new Vector(queryEmbeddings[query])); + var results = await HybridSearch(db, query, queryEmbeddings[query]); DisplayResults(query, results); } static async Task> HybridSearch( AppDbContext db, string query, - Vector queryEmbedding, + float[] queryEmbedding, int topK = 20, int rrfK = 60, int limit = 5 @@ -71,7 +62,7 @@ static async Task Demo(AppDbContext db, string query, Dictionary x.Distance) .Take(topK) @@ -101,31 +92,3 @@ static void DisplayResults(string query, List<(string Description, double RrfSco Console.WriteLine($" {i + 1}. {desc, -60} (RRF: {results[i].RrfScore:F4})"); } } - -static async Task LoadEmbeddingsAsync(AppDbContext db) -{ - var csvPath = Path.Combine(AppContext.BaseDirectory, "HybridRrf", "mock_items_embeddings.csv"); - var lines = await File.ReadAllLinesAsync(csvPath); - - var embeddings = new Dictionary(); - - for (var i = 1; i < lines.Length; i++) - { - var parts = lines[i].Split(',', 3); - embeddings[int.Parse(parts[0])] = parts[2] - .Trim('"', '[', ']') - .Split(',', StringSplitOptions.TrimEntries) - .Select(float.Parse) - .ToArray(); - } - - var ids = embeddings.Keys.ToArray(); - var items = await db.MockItems.Where(x => ids.Contains(x.Id)).ToListAsync(); - foreach (var item in items) - { - item.Embedding = new Vector(embeddings[item.Id]); - } - - await db.SaveChangesAsync(); - Console.WriteLine($"Loaded {items.Count} embeddings"); -} diff --git a/examples/README.md b/examples/README.md index 456e32c..f462aca 100644 --- a/examples/README.md +++ b/examples/README.md @@ -47,6 +47,15 @@ ranking. dotnet run --project examples/Examples.csproj -p:Example=HybridRrf ``` +## Vector Search (`examples/VectorSearch`) + +Runs Top-K nearest-neighbor queries over a pgvector column with ParadeDB +vector support, backed by a paradedb index. + +```bash +dotnet run --project examples/Examples.csproj -p:Example=VectorSearch +``` + ## RAG: Retrieval-Augmented Generation (`examples/Rag`) Builds a small QA flow that retrieves relevant context and sends it to an LLM diff --git a/examples/Shared/ExampleSetup.cs b/examples/Shared/ExampleSetup.cs index 3bc6336..5afba1d 100644 --- a/examples/Shared/ExampleSetup.cs +++ b/examples/Shared/ExampleSetup.cs @@ -46,6 +46,9 @@ await db.Database.ExecuteSqlRawAsync( "CALL paradedb.create_bm25_test_table(schema_name => 'public', table_name => 'mock_items')" ); await db.Database.ExecuteSqlRawAsync("DROP INDEX IF EXISTS mock_items_bm25_idx"); + // The VectorSearch example may have left its own index behind, and newer + // pg_search versions allow only one ParadeDB index per table + await db.Database.ExecuteSqlRawAsync("DROP INDEX IF EXISTS mock_items_vector_idx"); await db.Database.ExecuteSqlRawAsync( """ CREATE INDEX mock_items_bm25_idx ON mock_items @@ -102,14 +105,18 @@ public static async Task SetupHybridAsync(DbContext db) { await SetupMockItemsAsync(db); await db.Database.ExecuteSqlRawAsync("CREATE EXTENSION IF NOT EXISTS vector"); + // Newer pg_search versions create mock_items with an embedding column of a + // different dimension; the examples use 384-dimensional embeddings await db.Database.ExecuteSqlRawAsync( """ DO $$ BEGIN - IF NOT EXISTS ( - SELECT 1 FROM information_schema.columns - WHERE table_name = 'mock_items' AND column_name = 'embedding' - ) THEN + IF ( + SELECT atttypmod FROM pg_attribute + WHERE attrelid = 'mock_items'::regclass + AND attname = 'embedding' AND NOT attisdropped + ) IS DISTINCT FROM 384 THEN + ALTER TABLE mock_items DROP COLUMN IF EXISTS embedding; ALTER TABLE mock_items ADD COLUMN embedding vector(384); END IF; END $$; diff --git a/examples/VectorSearch/Program.cs b/examples/VectorSearch/Program.cs new file mode 100644 index 0000000..6302bae --- /dev/null +++ b/examples/VectorSearch/Program.cs @@ -0,0 +1,65 @@ +using HybridRrf; +using HybridRrf.Data; +using Microsoft.EntityFrameworkCore; +using ParadeDB.EntityFrameworkCore.Extensions; +using Shared; + +var options = new DbContextOptionsBuilder() + .UseNpgsql(ExampleSetup.ConnectionString, o => o.UseParadeDb()) + .UseSnakeCaseNamingConvention() + .Options; + +await using var dbContext = new AppDbContext(options); + +Console.WriteLine(new string('=', 70)); +Console.WriteLine("Vector Search"); +Console.WriteLine(new string('=', 70)); +Console.WriteLine("\nTop-K nearest-neighbor search over a pgvector column"); + +await ExampleSetup.SetupHybridAsync(dbContext); +await EmbeddingLoader.LoadAsync(dbContext); + +// Newer pg_search versions allow only one ParadeDB index per table +await dbContext.Database.ExecuteSqlRawAsync("DROP INDEX IF EXISTS mock_items_bm25_idx"); +await dbContext.Database.ExecuteSqlRawAsync("DROP INDEX IF EXISTS mock_items_vector_idx"); +await dbContext.Database.ExecuteSqlRawAsync( + """ + CREATE INDEX mock_items_vector_idx ON mock_items + USING paradedb (id, embedding vector_cosine_ops) + WITH (key_field='id'); + """ +); +Console.WriteLine("Created a paradedb index over the embedding column (cosine metric)"); + +await Demo(dbContext, "running shoes"); +await Demo(dbContext, "wireless earbuds"); +return; + +static async Task Demo(AppDbContext db, string query) +{ + var queryEmbedding = QueryEmbeddings.Values[query]; + + // The @@@ predicate (EF.Functions.All) and the LIMIT are required for the + // ParadeDB index to serve the query; the ORDER BY metric must match the + // index opclass (cosine here) + var results = await db + .MockItems.Where(x => EF.Functions.All(x.Id)) + .Select(x => new + { + x.Description, + Distance = EF.Functions.CosineDistance(x.Embedding, queryEmbedding), + }) + .OrderBy(x => x.Distance) + .Take(5) + .ToListAsync(); + + Console.WriteLine($"\n{new string('=', 70)}"); + Console.WriteLine($"Query: '{query}'"); + Console.WriteLine(new string('=', 70)); + + for (var i = 0; i < results.Count; i++) + { + var desc = results[i].Description[..Math.Min(60, results[i].Description.Length)]; + Console.WriteLine($" {i + 1}. {desc, -60} (distance: {results[i].Distance:F4})"); + } +} diff --git a/scripts/run_examples.sh b/scripts/run_examples.sh index 23bd19c..934c5c6 100755 --- a/scripts/run_examples.sh +++ b/scripts/run_examples.sh @@ -13,6 +13,7 @@ examples=( Autocomplete MoreLikeThis HybridRrf + VectorSearch Rag ) diff --git a/src/Extensions/ParadeDbDbContextOptionsBuilderExtensions.cs b/src/Extensions/ParadeDbDbContextOptionsBuilderExtensions.cs index 8c0ccf2..e0c973b 100644 --- a/src/Extensions/ParadeDbDbContextOptionsBuilderExtensions.cs +++ b/src/Extensions/ParadeDbDbContextOptionsBuilderExtensions.cs @@ -1,6 +1,10 @@ using Microsoft.EntityFrameworkCore.Infrastructure; using Npgsql.EntityFrameworkCore.PostgreSQL.Infrastructure; using ParadeDB.EntityFrameworkCore.Internal; +#if NET8_0 +using Npgsql; +using ParadeDB.EntityFrameworkCore.Internal.Storage; +#endif // ReSharper disable SuspiciousTypeConversion.Global @@ -8,10 +12,26 @@ namespace ParadeDB.EntityFrameworkCore.Extensions; public static class ParadeDbDbContextOptionsBuilderExtensions { +#if NET8_0 + private static int _vectorResolverRegistered; +#endif + public static NpgsqlDbContextOptionsBuilder UseParadeDb( this NpgsqlDbContextOptionsBuilder optionsBuilder ) { +#if NET8_0 + // Npgsql.EntityFrameworkCore.PostgreSQL 8.x has no INpgsqlDataSourceConfigurationPlugin, + // so the vector resolver is registered globally instead + if (Interlocked.Exchange(ref _vectorResolverRegistered, 1) == 0) + { +#pragma warning disable CS0618 + NpgsqlConnection.GlobalTypeMapper.AddTypeInfoResolverFactory( + new PdbVectorTypeInfoResolverFactory() + ); +#pragma warning restore CS0618 + } +#endif var coreOptionsBuilder = ( (IRelationalDbContextOptionsBuilderInfrastructure)optionsBuilder ).OptionsBuilder; diff --git a/src/Extensions/ParadeDbFunctionsExtensions.cs b/src/Extensions/ParadeDbFunctionsExtensions.cs index 02f6fdb..e91b902 100644 --- a/src/Extensions/ParadeDbFunctionsExtensions.cs +++ b/src/Extensions/ParadeDbFunctionsExtensions.cs @@ -196,6 +196,27 @@ [NotParameterized] RangeTermRelation relation where TValue : IComparable => throw new InvalidOperationException(CoreStrings.FunctionOnClient(nameof(RangeTerm))); + [DbFunction] + public static double L2Distance( + this DbFunctions _, + TProperty property, + float[] queryVector + ) => throw new InvalidOperationException(CoreStrings.FunctionOnClient(nameof(L2Distance))); + + [DbFunction] + public static double CosineDistance( + this DbFunctions _, + TProperty property, + float[] queryVector + ) => throw new InvalidOperationException(CoreStrings.FunctionOnClient(nameof(CosineDistance))); + + [DbFunction] + public static double InnerProduct( + this DbFunctions _, + TProperty property, + float[] queryVector + ) => throw new InvalidOperationException(CoreStrings.FunctionOnClient(nameof(InnerProduct))); + [DbFunction] public static bool PhrasePrefix( this DbFunctions _, diff --git a/src/Extensions/ParadeDbIndexBuilderExtensions.cs b/src/Extensions/ParadeDbIndexBuilderExtensions.cs index 9d9cc11..9303549 100644 --- a/src/Extensions/ParadeDbIndexBuilderExtensions.cs +++ b/src/Extensions/ParadeDbIndexBuilderExtensions.cs @@ -27,6 +27,7 @@ public static ParadeDbIndexBuilder HasParadeDbIndex( indexBuilder.HasAnnotation(ParadeDbAnnotationNames.Bm25FieldKinds, new[] { "property" }); indexBuilder.HasAnnotation(ParadeDbAnnotationNames.Bm25FieldTokenizers, new[] { "" }); indexBuilder.HasAnnotation(ParadeDbAnnotationNames.Bm25FieldAliases, new[] { "" }); + indexBuilder.HasAnnotation(ParadeDbAnnotationNames.Bm25FieldOpclasses, new[] { "" }); return new ParadeDbIndexBuilder(indexBuilder); } @@ -117,6 +118,29 @@ public ParadeDbIndexBuilder HasField(string sql, FieldAlias alias) return this; } + public ParadeDbIndexBuilder HasField( + Expression> propertyExpression, + VectorMetric metric + ) + { + AddField( + ParadeDbIndexBuilderExtensions.GetPropertyName(propertyExpression), + "property", + null, + null, + metric.ToOpclass() + ); + + return this; + } + + public ParadeDbIndexBuilder HasField(string sql, VectorMetric metric) + { + AddField(sql, "sql", null, null, metric.ToOpclass()); + + return this; + } + public ParadeDbIndexBuilder HasFilter(string? sql) { _indexBuilder.HasFilter(sql); @@ -141,12 +165,19 @@ public ParadeDbIndexBuilder HasSearchTokenizer(Tokenizer tokenizer) return this; } - private void AddField(string field, string kind, Tokenizer? tokenizer, string? alias) + private void AddField( + string field, + string kind, + Tokenizer? tokenizer, + string? alias, + string? opclass = null + ) { var properties = GetAnnotation(ParadeDbAnnotationNames.Bm25FieldProperties); var kinds = GetAnnotation(ParadeDbAnnotationNames.Bm25FieldKinds); var tokenizers = GetAnnotation(ParadeDbAnnotationNames.Bm25FieldTokenizers); var aliases = GetAnnotation(ParadeDbAnnotationNames.Bm25FieldAliases); + var opclasses = GetAnnotation(ParadeDbAnnotationNames.Bm25FieldOpclasses); _indexBuilder.HasAnnotation( ParadeDbAnnotationNames.Bm25FieldProperties, @@ -165,6 +196,11 @@ private void AddField(string field, string kind, Tokenizer? tokenizer, string? a ParadeDbAnnotationNames.Bm25FieldAliases, aliases.Append(alias is null ? "" : alias.Replace("'", "''")).ToArray() ); + + _indexBuilder.HasAnnotation( + ParadeDbAnnotationNames.Bm25FieldOpclasses, + opclasses.Append(opclass ?? "").ToArray() + ); } private string[] GetAnnotation(string name) => diff --git a/src/Extensions/ParadeDbPropertyBuilderExtensions.cs b/src/Extensions/ParadeDbPropertyBuilderExtensions.cs new file mode 100644 index 0000000..09f7fd8 --- /dev/null +++ b/src/Extensions/ParadeDbPropertyBuilderExtensions.cs @@ -0,0 +1,12 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace ParadeDB.EntityFrameworkCore.Extensions; + +public static class ParadeDbPropertyBuilderExtensions +{ + public static PropertyBuilder HasVectorType( + this PropertyBuilder propertyBuilder, + int dimensions + ) => propertyBuilder.HasColumnType($"vector({dimensions})"); +} diff --git a/src/Internal/Metadata/ParadeDbAnnotationNames.cs b/src/Internal/Metadata/ParadeDbAnnotationNames.cs index 718078c..e56eed5 100644 --- a/src/Internal/Metadata/ParadeDbAnnotationNames.cs +++ b/src/Internal/Metadata/ParadeDbAnnotationNames.cs @@ -7,6 +7,7 @@ internal static class ParadeDbAnnotationNames public const string Bm25FieldKinds = "ParadeDB:Bm25FieldKinds"; public const string Bm25FieldTokenizers = "ParadeDB:Bm25FieldTokenizers"; public const string Bm25FieldAliases = "ParadeDB:Bm25FieldAliases"; + public const string Bm25FieldOpclasses = "ParadeDB:Bm25FieldOpclasses"; public const string Bm25SearchTokenizer = "ParadeDB:Bm25SearchTokenizer"; public const string Bm25KeyField = "ParadeDB:Bm25KeyField"; public const string Bm25Fields = "ParadeDB:Bm25Fields"; diff --git a/src/Internal/Metadata/ParadeDbAnnotationProvider.cs b/src/Internal/Metadata/ParadeDbAnnotationProvider.cs index 9d7e226..e09a813 100644 --- a/src/Internal/Metadata/ParadeDbAnnotationProvider.cs +++ b/src/Internal/Metadata/ParadeDbAnnotationProvider.cs @@ -36,15 +36,20 @@ is not string keyPropertyName mappedIndex.FindAnnotation(ParadeDbAnnotationNames.Bm25FieldTokenizers)!.Value!; var fieldAliases = (string[]) mappedIndex.FindAnnotation(ParadeDbAnnotationNames.Bm25FieldAliases)!.Value!; + var fieldOpclasses = + mappedIndex.FindAnnotation(ParadeDbAnnotationNames.Bm25FieldOpclasses)?.Value + as string[] + ?? new string[fieldProperties.Length]; if ( fieldProperties.Length != fieldKinds.Length || fieldProperties.Length != fieldTokenizers.Length || fieldProperties.Length != fieldAliases.Length + || fieldProperties.Length != fieldOpclasses.Length ) { throw new InvalidOperationException( - "A BM25 index must have one kind, tokenizer, and alias entry for each field." + "A BM25 index must have one kind, tokenizer, alias, and opclass entry for each field." ); } @@ -68,7 +73,8 @@ is not string keyPropertyName fieldKinds[i], storeObject, string.IsNullOrEmpty(fieldTokenizers[i]) ? null : fieldTokenizers[i], - string.IsNullOrEmpty(fieldAliases[i]) ? null : fieldAliases[i] + string.IsNullOrEmpty(fieldAliases[i]) ? null : fieldAliases[i], + string.IsNullOrEmpty(fieldOpclasses[i]) ? null : fieldOpclasses[i] ) ) .ToArray() @@ -92,7 +98,8 @@ private static string RenderField( string kind, StoreObjectIdentifier storeObject, string? tokenizer, - string? alias + string? alias, + string? opclass ) { var sql = kind switch @@ -102,6 +109,11 @@ private static string RenderField( _ => throw new InvalidOperationException($"Unknown field kind '{kind}'"), }; + if (opclass is not null) + { + return kind == "property" ? $"{sql} {opclass}" : $"({sql}) {opclass}"; + } + if (tokenizer is null && alias is null) { return sql; diff --git a/src/Internal/ParadeDbDataSourceConfigurationPlugin.cs b/src/Internal/ParadeDbDataSourceConfigurationPlugin.cs new file mode 100644 index 0000000..7794213 --- /dev/null +++ b/src/Internal/ParadeDbDataSourceConfigurationPlugin.cs @@ -0,0 +1,13 @@ +#if !NET8_0 +using Npgsql; +using Npgsql.EntityFrameworkCore.PostgreSQL.Infrastructure; +using ParadeDB.EntityFrameworkCore.Internal.Storage; + +namespace ParadeDB.EntityFrameworkCore.Internal; + +internal sealed class ParadeDbDataSourceConfigurationPlugin : INpgsqlDataSourceConfigurationPlugin +{ + public void Configure(NpgsqlDataSourceBuilder npgsqlDataSourceBuilder) => + npgsqlDataSourceBuilder.AddTypeInfoResolverFactory(new PdbVectorTypeInfoResolverFactory()); +} +#endif diff --git a/src/Internal/ParadeDbOptionsExtension.cs b/src/Internal/ParadeDbOptionsExtension.cs index fa04194..0cb3893 100644 --- a/src/Internal/ParadeDbOptionsExtension.cs +++ b/src/Internal/ParadeDbOptionsExtension.cs @@ -3,10 +3,15 @@ using Microsoft.EntityFrameworkCore.Metadata.Conventions.Infrastructure; using Microsoft.EntityFrameworkCore.Migrations; using Microsoft.EntityFrameworkCore.Query; +using Microsoft.EntityFrameworkCore.Storage; using Microsoft.Extensions.DependencyInjection; using ParadeDB.EntityFrameworkCore.Internal.Metadata; using ParadeDB.EntityFrameworkCore.Internal.Migrations; using ParadeDB.EntityFrameworkCore.Internal.Query; +using ParadeDB.EntityFrameworkCore.Internal.Storage; +#if !NET8_0 +using Npgsql.EntityFrameworkCore.PostgreSQL.Infrastructure; +#endif namespace ParadeDB.EntityFrameworkCore.Internal; @@ -23,6 +28,16 @@ public void ApplyServices(IServiceCollection services) services.AddScoped(); services.AddSingleton(); services.AddScoped(); + services.AddSingleton< + IRelationalTypeMappingSourcePlugin, + ParadeDbTypeMappingSourcePlugin + >(); +#if !NET8_0 + new EntityFrameworkNpgsqlServicesBuilder(services).TryAdd< + INpgsqlDataSourceConfigurationPlugin, + ParadeDbDataSourceConfigurationPlugin + >(); +#endif services.AddScoped(); services.AddSingleton(); services.AddSingleton< diff --git a/src/Internal/Query/Translator.cs b/src/Internal/Query/Translator.cs index 1afd9e3..e97a378 100644 --- a/src/Internal/Query/Translator.cs +++ b/src/Internal/Query/Translator.cs @@ -105,6 +105,15 @@ public Translator(ISqlExpressionFactory sqlExpressionFactory) arguments[1], BuildPhrasePrefix(arguments) ), + nameof(ParadeDbFunctionsExtensions.L2Distance) => BuildVectorDistance(arguments, "<->"), + nameof(ParadeDbFunctionsExtensions.CosineDistance) => BuildVectorDistance( + arguments, + "<=>" + ), + nameof(ParadeDbFunctionsExtensions.InnerProduct) => BuildVectorDistance( + arguments, + "<#>" + ), nameof(ParadeDbFunctionsExtensions.Snippet) => BuildSnippet(arguments), nameof(ParadeDbFunctionsExtensions.Snippets) => BuildSnippets(arguments), nameof(ParadeDbFunctionsExtensions.SnippetPositions) => _sqlExpressionFactory.Function( @@ -250,6 +259,22 @@ private SqlExpression BuildRangeTerm(IReadOnlyList arguments) ); } + private PgUnknownBinaryExpression BuildVectorDistance( + IReadOnlyList arguments, + string binaryOperator + ) + { + var vectorMapping = arguments[1].TypeMapping ?? PdbVectorTypeMapping.Default; + + return new PgUnknownBinaryExpression( + _sqlExpressionFactory.ApplyTypeMapping(arguments[1], vectorMapping), + _sqlExpressionFactory.ApplyTypeMapping(arguments[2], vectorMapping), + binaryOperator, + typeof(double), + PdbTypeMappings.Double + ); + } + private SqlExpression BuildModifier(IReadOnlyList arguments, string methodName) { RelationalTypeMapping typeMapping = methodName switch diff --git a/src/Internal/Storage/ParadeDbTypeMappingSourcePlugin.cs b/src/Internal/Storage/ParadeDbTypeMappingSourcePlugin.cs new file mode 100644 index 0000000..841bfeb --- /dev/null +++ b/src/Internal/Storage/ParadeDbTypeMappingSourcePlugin.cs @@ -0,0 +1,22 @@ +using Microsoft.EntityFrameworkCore.Storage; + +namespace ParadeDB.EntityFrameworkCore.Internal.Storage; + +internal sealed class ParadeDbTypeMappingSourcePlugin : IRelationalTypeMappingSourcePlugin +{ + public RelationalTypeMapping? FindMapping(in RelationalTypeMappingInfo mappingInfo) + { + if ( + !string.Equals( + mappingInfo.StoreTypeNameBase, + "vector", + StringComparison.OrdinalIgnoreCase + ) || (mappingInfo.ClrType is not null && mappingInfo.ClrType != typeof(float[])) + ) + { + return null; + } + + return new PdbVectorTypeMapping(mappingInfo.StoreTypeName!); + } +} diff --git a/src/Internal/Storage/PdbTypeMappings.cs b/src/Internal/Storage/PdbTypeMappings.cs index 618b93e..0c7945e 100644 --- a/src/Internal/Storage/PdbTypeMappings.cs +++ b/src/Internal/Storage/PdbTypeMappings.cs @@ -9,6 +9,8 @@ internal static class PdbTypeMappings { public static readonly RelationalTypeMapping Boolean = new NpgsqlBoolTypeMapping(); + public static readonly RelationalTypeMapping Double = new NpgsqlDoubleTypeMapping(); + public static readonly RelationalTypeMapping Text = new NpgsqlStringTypeMapping( "text", NpgsqlDbType.Text diff --git a/src/Internal/Storage/PdbVectorConverter.cs b/src/Internal/Storage/PdbVectorConverter.cs new file mode 100644 index 0000000..bb13b63 --- /dev/null +++ b/src/Internal/Storage/PdbVectorConverter.cs @@ -0,0 +1,106 @@ +using Npgsql.Internal; + +namespace ParadeDB.EntityFrameworkCore.Internal.Storage; + +internal sealed class PdbVectorConverter : PgStreamingConverter +{ + public override float[] Read(PgReader reader) + { + if (reader.ShouldBuffer(2 * sizeof(ushort))) + { + reader.Buffer(2 * sizeof(ushort)); + } + + var dimensions = reader.ReadUInt16(); + reader.ReadUInt16(); + + var vector = new float[dimensions]; + for (var i = 0; i < dimensions; i++) + { + if (reader.ShouldBuffer(sizeof(float))) + { + reader.Buffer(sizeof(float)); + } + + vector[i] = reader.ReadFloat(); + } + + return vector; + } + + public override async ValueTask ReadAsync( + PgReader reader, + CancellationToken cancellationToken = default + ) + { + if (reader.ShouldBuffer(2 * sizeof(ushort))) + { + await reader.BufferAsync(2 * sizeof(ushort), cancellationToken).ConfigureAwait(false); + } + + var dimensions = reader.ReadUInt16(); + reader.ReadUInt16(); + + var vector = new float[dimensions]; + for (var i = 0; i < dimensions; i++) + { + if (reader.ShouldBuffer(sizeof(float))) + { + await reader.BufferAsync(sizeof(float), cancellationToken).ConfigureAwait(false); + } + + vector[i] = reader.ReadFloat(); + } + + return vector; + } + + public override Size GetSize(SizeContext context, float[] value, ref object? writeState) => + 2 * sizeof(ushort) + sizeof(float) * value.Length; + + public override void Write(PgWriter writer, float[] value) + { + if (writer.ShouldFlush(2 * sizeof(ushort))) + { + writer.Flush(); + } + + writer.WriteUInt16(Convert.ToUInt16(value.Length)); + writer.WriteUInt16(0); + + foreach (var element in value) + { + if (writer.ShouldFlush(sizeof(float))) + { + writer.Flush(); + } + + writer.WriteFloat(element); + } + } + + public override async ValueTask WriteAsync( + PgWriter writer, + float[] value, + CancellationToken cancellationToken = default + ) + { + if (writer.ShouldFlush(2 * sizeof(ushort))) + { + await writer.FlushAsync(cancellationToken).ConfigureAwait(false); + } + + writer.WriteUInt16(Convert.ToUInt16(value.Length)); + writer.WriteUInt16(0); + + foreach (var element in value) + { + if (writer.ShouldFlush(sizeof(float))) + { + await writer.FlushAsync(cancellationToken).ConfigureAwait(false); + } + + writer.WriteFloat(element); + } + } +} diff --git a/src/Internal/Storage/PdbVectorTypeInfoResolverFactory.cs b/src/Internal/Storage/PdbVectorTypeInfoResolverFactory.cs new file mode 100644 index 0000000..0751434 --- /dev/null +++ b/src/Internal/Storage/PdbVectorTypeInfoResolverFactory.cs @@ -0,0 +1,36 @@ +using Npgsql.Internal; +using Npgsql.Internal.Postgres; + +namespace ParadeDB.EntityFrameworkCore.Internal.Storage; + +internal sealed class PdbVectorTypeInfoResolverFactory : PgTypeInfoResolverFactory +{ + public override IPgTypeInfoResolver CreateResolver() => new Resolver(); + + public override IPgTypeInfoResolver? CreateArrayResolver() => null; + + private sealed class Resolver : IPgTypeInfoResolver + { + private TypeInfoMappingCollection? _mappings; + + private TypeInfoMappingCollection Mappings => _mappings ??= AddMappings(new()); + + public PgTypeInfo? GetTypeInfo( + Type? type, + DataTypeName? dataTypeName, + PgSerializerOptions options + ) => Mappings.Find(type, dataTypeName, options); + + private static TypeInfoMappingCollection AddMappings(TypeInfoMappingCollection mappings) + { + mappings.AddType( + "vector", + static (options, mapping, _) => + mapping.CreateInfo(options, new PdbVectorConverter()), + isDefault: true + ); + + return mappings; + } + } +} diff --git a/src/Internal/Storage/PdbVectorTypeMapping.cs b/src/Internal/Storage/PdbVectorTypeMapping.cs new file mode 100644 index 0000000..0855a8d --- /dev/null +++ b/src/Internal/Storage/PdbVectorTypeMapping.cs @@ -0,0 +1,40 @@ +using System.Data.Common; +using System.Globalization; +using Microsoft.EntityFrameworkCore.ChangeTracking; +using Microsoft.EntityFrameworkCore.Storage; +using Npgsql; + +namespace ParadeDB.EntityFrameworkCore.Internal.Storage; + +internal sealed class PdbVectorTypeMapping : RelationalTypeMapping +{ + public static readonly PdbVectorTypeMapping Default = new("vector"); + + public PdbVectorTypeMapping(string storeType) + : base( + new RelationalTypeMappingParameters( + new CoreTypeMappingParameters( + typeof(float[]), + converter: null, + comparer: new ValueComparer( + (a, b) => a == null ? b == null : b != null && a.SequenceEqual(b), + v => v.Aggregate(17, (hash, value) => HashCode.Combine(hash, value)), + v => v.ToArray() + ) + ), + storeType + ) + ) { } + + private PdbVectorTypeMapping(RelationalTypeMappingParameters parameters) + : base(parameters) { } + + protected override RelationalTypeMapping Clone(RelationalTypeMappingParameters parameters) => + new PdbVectorTypeMapping(parameters); + + protected override void ConfigureParameter(DbParameter parameter) => + ((NpgsqlParameter)parameter).DataTypeName = "vector"; + + protected override string GenerateNonNullSqlLiteral(object value) => + $"'[{string.Join(",", ((float[])value).Select(v => v.ToString(CultureInfo.InvariantCulture)))}]'"; +} diff --git a/src/ParadeDB.EntityFrameworkCore.csproj b/src/ParadeDB.EntityFrameworkCore.csproj index 8e4007d..37370e5 100644 --- a/src/ParadeDB.EntityFrameworkCore.csproj +++ b/src/ParadeDB.EntityFrameworkCore.csproj @@ -1,7 +1,7 @@ Library - $(NoWarn);EF1001 + $(NoWarn);EF1001;NPG9001 diff --git a/src/VectorMetric.cs b/src/VectorMetric.cs new file mode 100644 index 0000000..8d79627 --- /dev/null +++ b/src/VectorMetric.cs @@ -0,0 +1,20 @@ +namespace ParadeDB.EntityFrameworkCore; + +public enum VectorMetric +{ + L2, + Cosine, + InnerProduct, +} + +internal static class VectorMetricExtensions +{ + public static string ToOpclass(this VectorMetric metric) => + metric switch + { + VectorMetric.L2 => "vector_l2_ops", + VectorMetric.Cosine => "vector_cosine_ops", + VectorMetric.InnerProduct => "vector_ip_ops", + _ => throw new ArgumentOutOfRangeException(nameof(metric)), + }; +} diff --git a/tests/DiagnosticsTests.cs b/tests/DiagnosticsTests.cs index abc031f..a1145e3 100644 --- a/tests/DiagnosticsTests.cs +++ b/tests/DiagnosticsTests.cs @@ -1,5 +1,6 @@ using System.Text.RegularExpressions; using Microsoft.EntityFrameworkCore; +using ParadeDB.EntityFrameworkCore.Extensions; using ParadeDB.EntityFrameworkCore.Tests.Persistence; using Shouldly; @@ -9,7 +10,7 @@ public sealed class DiagnosticsTests { private static readonly DbContextOptions Options = new DbContextOptionsBuilder() - .UseNpgsql("Host=localhost;Database=paradedb_tests") + .UseNpgsql("Host=localhost;Database=paradedb_tests", o => o.UseParadeDb()) .Options; private static void AssertSql(IQueryable query, string expected) => diff --git a/tests/IndexingTest.cs b/tests/IndexingTest.cs index 65cc433..1228539 100644 --- a/tests/IndexingTest.cs +++ b/tests/IndexingTest.cs @@ -322,6 +322,83 @@ description text await context.Database.ExecuteSqlRawAsync(sql); } + private sealed class VectorIndexContext(DbContextOptions options) + : DbContext(options) + { + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(entity => + { + entity.ToTable("indexing_items"); + entity.Property(e => e.Id).HasColumnName("id"); + entity.Property(e => e.Description).HasColumnName("description"); + entity.Property(e => e.EmbeddingL2).HasColumnName("embedding_l2").HasVectorType(3); + entity + .Property(e => e.EmbeddingCosine) + .HasColumnName("embedding_cosine") + .HasVectorType(3); + entity.Property(e => e.EmbeddingIp).HasColumnName("embedding_ip").HasVectorType(3); + + entity + .HasParadeDbIndex("indexing_items_idx", e => e.Id) + .HasField(e => e.Description) + .HasField(e => e.EmbeddingL2, VectorMetric.L2) + .HasField(e => e.EmbeddingCosine, VectorMetric.Cosine) + .HasField("embedding_ip", VectorMetric.InnerProduct); + }); + } + } + + [Test] + public async Task ParadeDbIndex_WithVectorFields() + { + var sql = GenerateCreateIndexSql(); + + sql.ShouldBe( + """ + CREATE INDEX indexing_items_idx ON indexing_items USING paradedb (id, description, embedding_l2 vector_l2_ops, embedding_cosine vector_cosine_ops, (embedding_ip) vector_ip_ops) WITH (key_field = 'id'); + + """ + ); + + await using var context = DbFixture.CreateContext(); + await context.Database.OpenConnectionAsync(); + + Skip.When( + !await SupportsVectorIndexAsync(context), + "This ParadeDB version does not support vector columns in ParadeDB indexes" + ); + + await context.Database.ExecuteSqlRawAsync( + """ + CREATE TEMP TABLE indexing_items ( + id int PRIMARY KEY, + description text, + embedding_l2 vector(3), + embedding_cosine vector(3), + embedding_ip vector(3) + ); + """ + ); + await context.Database.ExecuteSqlRawAsync(sql); + } + + internal static async Task SupportsVectorIndexAsync(Persistence.TestDbContext context) + { + var count = await context + .Database.SqlQueryRaw( + """ + SELECT count(*) AS "Value" + FROM pg_opclass o + JOIN pg_am a ON a.oid = o.opcmethod + WHERE a.amname = 'paradedb' AND o.opcname = 'vector_l2_ops' + """ + ) + .SingleAsync(); + + return count > 0; + } + private static string GenerateCreateIndexSql() where TContext : DbContext { @@ -380,5 +457,8 @@ private sealed class IndexingItem public string[] Tags { get; set; } = []; public JsonDocument? Metadata { get; set; } public int Rating { get; set; } + public float[]? EmbeddingL2 { get; set; } + public float[]? EmbeddingCosine { get; set; } + public float[]? EmbeddingIp { get; set; } } } diff --git a/tests/Persistence/DbFixture.cs b/tests/Persistence/DbFixture.cs index 3606b3b..f5e2648 100644 --- a/tests/Persistence/DbFixture.cs +++ b/tests/Persistence/DbFixture.cs @@ -28,6 +28,7 @@ public async Task InitializeAsync() .Options; await using var context = new TestDbContext(_options); + await context.Database.ExecuteSqlRawAsync("CREATE EXTENSION IF NOT EXISTS vector"); await context.Database.ExecuteSqlRawAsync( """ DO $$ diff --git a/tests/Persistence/TestDbContext.cs b/tests/Persistence/TestDbContext.cs index b17954f..5c3e3a0 100644 --- a/tests/Persistence/TestDbContext.cs +++ b/tests/Persistence/TestDbContext.cs @@ -1,4 +1,5 @@ using Microsoft.EntityFrameworkCore; +using ParadeDB.EntityFrameworkCore.Extensions; namespace ParadeDB.EntityFrameworkCore.Tests.Persistence; @@ -9,10 +10,18 @@ public TestDbContext(DbContextOptions options) public DbSet MockItems => Set(); + public DbSet VectorItems => Set(); + protected override void OnModelCreating(ModelBuilder modelBuilder) { base.OnModelCreating(modelBuilder); modelBuilder.Entity().HasKey(p => p.Id); + + modelBuilder.Entity(entity => + { + entity.HasKey(p => p.Id); + entity.Property(p => p.Embedding).HasVectorType(3); + }); } } diff --git a/tests/Persistence/VectorItem.cs b/tests/Persistence/VectorItem.cs new file mode 100644 index 0000000..379f0d0 --- /dev/null +++ b/tests/Persistence/VectorItem.cs @@ -0,0 +1,7 @@ +namespace ParadeDB.EntityFrameworkCore.Tests.Persistence; + +public sealed class VectorItem +{ + public int Id { get; set; } + public float[]? Embedding { get; set; } +} diff --git a/tests/VectorSearchTests.cs b/tests/VectorSearchTests.cs new file mode 100644 index 0000000..f8bf6ee --- /dev/null +++ b/tests/VectorSearchTests.cs @@ -0,0 +1,192 @@ +using System.Text.RegularExpressions; +using Microsoft.EntityFrameworkCore; +using ParadeDB.EntityFrameworkCore.Extensions; +using ParadeDB.EntityFrameworkCore.Tests.Persistence; +using Shouldly; + +namespace ParadeDB.EntityFrameworkCore.Tests; + +public sealed class VectorSearchTests : TestBase +{ + private static void AssertSql(IQueryable query, string expected) => + NormalizeSql(query.ToQueryString()).ShouldBe(NormalizeSql(expected)); + + private static string NormalizeSql(string sql) => + Regex.Replace( + Regex.Replace(sql.ReplaceLineEndings("\n"), @"@__(\w+?)_\d+", "@$1"), + "(?m)(^-- .+\n)\n(?=SELECT)", + "$1" + ); + + private static async Task CreateContextWithVectorItemsAsync(DbFixture dbFixture) + { + var context = dbFixture.CreateContext(); + await context.Database.OpenConnectionAsync(); + await context.Database.ExecuteSqlRawAsync( + """ + CREATE TEMP TABLE vector_items ( + id int PRIMARY KEY, + embedding vector(3) + ); + """ + ); + + context.VectorItems.AddRange( + new VectorItem { Id = 1, Embedding = [1, 0, 0] }, + new VectorItem { Id = 2, Embedding = [0.5f, 0.5f, 0] }, + new VectorItem { Id = 3, Embedding = [0, 1, 1] } + ); + await context.SaveChangesAsync(); + context.ChangeTracker.Clear(); + + return context; + } + + [Test] + public async Task Vector_RoundTrip() + { + await using var context = await CreateContextWithVectorItemsAsync(DbFixture); + + context.VectorItems.Add(new VectorItem { Id = 4, Embedding = null }); + await context.SaveChangesAsync(); + context.ChangeTracker.Clear(); + + var items = await context.VectorItems.OrderBy(v => v.Id).ToListAsync(); + + items.Count.ShouldBe(4); + items[0].Embedding.ShouldBe([1, 0, 0]); + items[1].Embedding.ShouldBe([0.5f, 0.5f, 0]); + items[3].Embedding.ShouldBeNull(); + } + + [Test] + public async Task L2Distance_TopK() + { + await using var context = await CreateContextWithVectorItemsAsync(DbFixture); + + float[] queryVector = [1, 0, 0]; + + var query = context + .VectorItems.OrderBy(v => EF.Functions.L2Distance(v.Embedding, queryVector)) + .Select(v => v.Id) + .Take(2); + + var sql = """ + -- @queryVector={ '1', '0', '0' } (DbType = Object) + -- @p='2' + SELECT v.id + FROM vector_items AS v + ORDER BY v.embedding <-> @queryVector + LIMIT @p + """; + + AssertSql(query, sql); + (await query.ToListAsync()).ShouldBe([1, 2]); + } + + [Test] + public async Task CosineDistance_TopK() + { + await using var context = await CreateContextWithVectorItemsAsync(DbFixture); + + float[] queryVector = [0, 1, 1]; + + var query = context + .VectorItems.OrderBy(v => EF.Functions.CosineDistance(v.Embedding, queryVector)) + .Select(v => v.Id) + .Take(2); + + var sql = """ + -- @queryVector={ '0', '1', '1' } (DbType = Object) + -- @p='2' + SELECT v.id + FROM vector_items AS v + ORDER BY v.embedding <=> @queryVector + LIMIT @p + """; + + AssertSql(query, sql); + (await query.ToListAsync()).ShouldBe([3, 2]); + } + + [Test] + public async Task InnerProduct_TopK() + { + await using var context = await CreateContextWithVectorItemsAsync(DbFixture); + + float[] queryVector = [1, 1, 1]; + + var query = context + .VectorItems.OrderBy(v => EF.Functions.InnerProduct(v.Embedding, queryVector)) + .Select(v => v.Id) + .Take(2); + + var sql = """ + -- @queryVector={ '1', '1', '1' } (DbType = Object) + -- @p='2' + SELECT v.id + FROM vector_items AS v + ORDER BY v.embedding <#> @queryVector + LIMIT @p + """; + + AssertSql(query, sql); + (await query.ToListAsync()).ShouldBe([3, 1]); + } + + [Test] + public async Task VectorSearch_WithMatchAllPredicate() + { + await using var context = DbFixture.CreateContext(); + + float[] queryVector = [1, 0, 0]; + + var query = context + .VectorItems.Where(v => EF.Functions.All(v.Id)) + .OrderBy(v => EF.Functions.L2Distance(v.Embedding, queryVector)) + .Select(v => v.Id) + .Take(2); + + var sql = """ + -- @queryVector={ '1', '0', '0' } (DbType = Object) + -- @p='2' + SELECT v.id + FROM vector_items AS v + WHERE v.id @@@ pdb.all() + ORDER BY v.embedding <-> @queryVector + LIMIT @p + """; + + AssertSql(query, sql); + } + + [Test] + public async Task VectorSearch_WithParadeDbIndex() + { + await using var context = await CreateContextWithVectorItemsAsync(DbFixture); + + Skip.When( + !await IndexingTest.SupportsVectorIndexAsync(context), + "This ParadeDB version does not support vector columns in ParadeDB indexes" + ); + + await context.Database.ExecuteSqlRawAsync( + """ + CREATE INDEX vector_items_idx ON vector_items + USING paradedb (id, embedding vector_l2_ops) + WITH (key_field = 'id'); + """ + ); + + float[] queryVector = [1, 0, 0]; + + var results = await context + .VectorItems.Where(v => EF.Functions.All(v.Id)) + .OrderBy(v => EF.Functions.L2Distance(v.Embedding, queryVector)) + .Select(v => v.Id) + .Take(2) + .ToListAsync(); + + results.ShouldBe([1, 2]); + } +}