Skip to content

Commit 140938e

Browse files
committed
feat: native vector search support (paradedb/paradedb#5685)
1 parent a225499 commit 140938e

33 files changed

Lines changed: 849 additions & 56 deletions

CHANGELOG.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,13 @@ All notable changes to this project will be documented in this file. The format
44

55
## [Unreleased]
66

7+
### Added
8+
9+
- Native vector search support: `vector(n)` column mapping for `float[]`
10+
properties (`HasVectorType`), vector fields with metric operator classes in
11+
ParadeDB indexes (`HasField(..., VectorMetric)`), and the `L2Distance`,
12+
`CosineDistance`, and `InnerProduct` distance functions.
13+
714
### Changed
815

916
- **Breaking**: `HasBm25Index` and `Bm25IndexBuilder<TEntity>` are renamed to

NUGET-README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ The official [Entity Framework Core](https://learn.microsoft.com/en-us/ef/core/)
1818
- [Autocomplete](examples/Autocomplete/Program.cs)
1919
- [More Like This](examples/MoreLikeThis/Program.cs)
2020
- [Hybrid Search (RRF)](examples/HybridRrf/Program.cs)
21+
- [Vector Search](examples/VectorSearch/Program.cs)
2122
- [RAG](examples/Rag/Program.cs)
2223

2324
## Contributing

README.md

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,13 +47,57 @@ The official [Entity Framework Core](https://learn.microsoft.com/en-us/ef/core/)
4747
| ParadeDB | 0.25.0+ |
4848
| PostgreSQL | 15+ (with ParadeDB extension) |
4949

50+
## Vector Search
51+
52+
ParadeDB indexes pgvector `vector` columns directly inside its BM25 index — no
53+
pgvector ORM plugin is required. Map a `float[]` property to a `vector(n)`
54+
column, pick a distance metric for the index, and order by the matching
55+
distance function:
56+
57+
```csharp
58+
public class Item
59+
{
60+
public int Id { get; set; }
61+
public float[]? Embedding { get; set; }
62+
}
63+
64+
// Model configuration
65+
modelBuilder.Entity<Item>(entity =>
66+
{
67+
entity.Property(x => x.Embedding).HasVectorType(384);
68+
69+
entity
70+
.HasParadeDbIndex("items_idx", x => x.Id)
71+
.HasField(x => x.Embedding, VectorMetric.Cosine);
72+
});
73+
74+
// Top-K query: a `@@@` predicate (`EF.Functions.All` for match-all here) and
75+
// a LIMIT (`Take`) are required for the index to serve the query
76+
float[] queryEmbedding = GetQueryEmbedding();
77+
78+
var results = await db
79+
.Items.Where(x => EF.Functions.All(x.Id))
80+
.OrderBy(x => EF.Functions.CosineDistance(x.Embedding, queryEmbedding))
81+
.Take(10)
82+
.ToListAsync();
83+
```
84+
85+
`VectorMetric.L2` (`<->`, the default), `VectorMetric.Cosine` (`<=>`), and
86+
`VectorMetric.InnerProduct` (`<#>`) map to the `vector_l2_ops`,
87+
`vector_cosine_ops`, and `vector_ip_ops` operator classes. The distance
88+
function used in `OrderBy` must match the metric of the index opclass —
89+
`L2Distance` with `L2`, `CosineDistance` with `Cosine`, and `InnerProduct`
90+
with `InnerProduct` — otherwise the query still returns correct results but
91+
falls back to a sequential scan instead of Top-K index pushdown.
92+
5093
## Examples
5194

5295
- [Quickstart](examples/Quickstart/Program.cs)
5396
- [Faceted Search](examples/FacetedSearch/Program.cs)
5497
- [Autocomplete](examples/Autocomplete/Program.cs)
5598
- [More Like This](examples/MoreLikeThis/Program.cs)
5699
- [Hybrid Search (RRF)](examples/HybridRrf/Program.cs)
100+
- [Vector Search](examples/VectorSearch/Program.cs)
57101
- [RAG](examples/Rag/Program.cs)
58102

59103
## Contributing

examples/Directory.Packages.props

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,3 @@
11
<Project>
22
<Import Project="..\Directory.Packages.props" />
3-
4-
<ItemGroup>
5-
<PackageVersion Include="Pgvector.EntityFrameworkCore" Version="0.3.0" />
6-
</ItemGroup>
73
</Project>

examples/Examples.csproj

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,14 +12,13 @@
1212
</PropertyGroup>
1313

1414
<Target Name="RequireExample" BeforeTargets="CoreCompile" Condition="'$(Example)' == ''">
15-
<Error Text="Set the Example property to one of: Quickstart, FacetedSearch, Autocomplete, MoreLikeThis, HybridRrf, Rag." />
15+
<Error Text="Set the Example property to one of: Quickstart, FacetedSearch, Autocomplete, MoreLikeThis, HybridRrf, VectorSearch, Rag." />
1616
</Target>
1717

1818
<ItemGroup>
1919
<PackageReference Include="EFCore.NamingConventions" />
2020
<PackageReference Include="Microsoft.Extensions.Configuration.UserSecrets" />
2121
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" />
22-
<PackageReference Include="Pgvector.EntityFrameworkCore" />
2322
</ItemGroup>
2423

2524
<ItemGroup>
Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,8 @@
1-
using Pgvector;
21
using Shared;
32

43
namespace HybridRrf.Data;
54

65
public class MockItemWithEmbedding : MockItem
76
{
8-
public Vector? Embedding { get; set; }
7+
public float[]? Embedding { get; set; }
98
}
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
using HybridRrf.Data;
2+
using Microsoft.EntityFrameworkCore;
3+
4+
namespace HybridRrf;
5+
6+
public static class EmbeddingLoader
7+
{
8+
public static async Task LoadAsync(AppDbContext db)
9+
{
10+
var csvPath = Path.Combine(
11+
AppContext.BaseDirectory,
12+
"HybridRrf",
13+
"mock_items_embeddings.csv"
14+
);
15+
var lines = await File.ReadAllLinesAsync(csvPath);
16+
17+
var embeddings = new Dictionary<int, float[]>();
18+
19+
for (var i = 1; i < lines.Length; i++)
20+
{
21+
var parts = lines[i].Split(',', 3);
22+
embeddings[int.Parse(parts[0])] = parts[2]
23+
.Trim('"', '[', ']')
24+
.Split(',', StringSplitOptions.TrimEntries)
25+
.Select(float.Parse)
26+
.ToArray();
27+
}
28+
29+
var ids = embeddings.Keys.ToArray();
30+
var items = await db.MockItems.Where(x => ids.Contains(x.Id)).ToListAsync();
31+
foreach (var item in items)
32+
{
33+
item.Embedding = embeddings[item.Id];
34+
}
35+
36+
await db.SaveChangesAsync();
37+
Console.WriteLine($"Loaded {items.Count} embeddings");
38+
}
39+
}

examples/HybridRrf/Program.cs

Lines changed: 5 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -2,19 +2,10 @@
22
using HybridRrf.Data;
33
using Microsoft.EntityFrameworkCore;
44
using ParadeDB.EntityFrameworkCore.Extensions;
5-
using Pgvector;
6-
using Pgvector.EntityFrameworkCore;
75
using Shared;
86

97
var options = new DbContextOptionsBuilder<AppDbContext>()
10-
.UseNpgsql(
11-
ExampleSetup.ConnectionString,
12-
o =>
13-
{
14-
o.UseParadeDb();
15-
o.UseVector();
16-
}
17-
)
8+
.UseNpgsql(ExampleSetup.ConnectionString, o => o.UseParadeDb())
189
.UseSnakeCaseNamingConvention()
1910
.Options;
2011

@@ -27,7 +18,7 @@
2718
Console.WriteLine("RRF formula: score = sum(1 / (k + rank)) across all rankings");
2819

2920
await ExampleSetup.SetupHybridAsync(dbContext);
30-
await LoadEmbeddingsAsync(dbContext);
21+
await EmbeddingLoader.LoadAsync(dbContext);
3122

3223
await Demo(dbContext, "running shoes", QueryEmbeddings.Values);
3324
await Demo(dbContext, "footwear for exercise", QueryEmbeddings.Values);
@@ -40,14 +31,14 @@
4031

4132
static async Task Demo(AppDbContext db, string query, Dictionary<string, float[]> queryEmbeddings)
4233
{
43-
var results = await HybridSearch(db, query, new Vector(queryEmbeddings[query]));
34+
var results = await HybridSearch(db, query, queryEmbeddings[query]);
4435
DisplayResults(query, results);
4536
}
4637

4738
static async Task<List<(string Description, double RrfScore)>> HybridSearch(
4839
AppDbContext db,
4940
string query,
50-
Vector queryEmbedding,
41+
float[] queryEmbedding,
5142
int topK = 20,
5243
int rrfK = 60,
5344
int limit = 5
@@ -71,7 +62,7 @@ static async Task Demo(AppDbContext db, string query, Dictionary<string, float[]
7162
{
7263
x.Id,
7364
x.Description,
74-
Distance = x.Embedding!.CosineDistance(queryEmbedding),
65+
Distance = EF.Functions.CosineDistance(x.Embedding, queryEmbedding),
7566
})
7667
.OrderBy(x => x.Distance)
7768
.Take(topK)
@@ -101,31 +92,3 @@ static void DisplayResults(string query, List<(string Description, double RrfSco
10192
Console.WriteLine($" {i + 1}. {desc, -60} (RRF: {results[i].RrfScore:F4})");
10293
}
10394
}
104-
105-
static async Task LoadEmbeddingsAsync(AppDbContext db)
106-
{
107-
var csvPath = Path.Combine(AppContext.BaseDirectory, "HybridRrf", "mock_items_embeddings.csv");
108-
var lines = await File.ReadAllLinesAsync(csvPath);
109-
110-
var embeddings = new Dictionary<int, float[]>();
111-
112-
for (var i = 1; i < lines.Length; i++)
113-
{
114-
var parts = lines[i].Split(',', 3);
115-
embeddings[int.Parse(parts[0])] = parts[2]
116-
.Trim('"', '[', ']')
117-
.Split(',', StringSplitOptions.TrimEntries)
118-
.Select(float.Parse)
119-
.ToArray();
120-
}
121-
122-
var ids = embeddings.Keys.ToArray();
123-
var items = await db.MockItems.Where(x => ids.Contains(x.Id)).ToListAsync();
124-
foreach (var item in items)
125-
{
126-
item.Embedding = new Vector(embeddings[item.Id]);
127-
}
128-
129-
await db.SaveChangesAsync();
130-
Console.WriteLine($"Loaded {items.Count} embeddings");
131-
}

examples/README.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,15 @@ ranking.
4747
dotnet run --project examples/Examples.csproj -p:Example=HybridRrf
4848
```
4949

50+
## Vector Search (`examples/VectorSearch`)
51+
52+
Runs Top-K nearest-neighbor queries over a pgvector column with the native
53+
vector support, backed by a bm25 index when the server supports it.
54+
55+
```bash
56+
dotnet run --project examples/Examples.csproj -p:Example=VectorSearch
57+
```
58+
5059
## RAG: Retrieval-Augmented Generation (`examples/Rag`)
5160

5261
Builds a small QA flow that retrieves relevant context and sends it to an LLM

examples/VectorSearch/Program.cs

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
using HybridRrf;
2+
using HybridRrf.Data;
3+
using Microsoft.EntityFrameworkCore;
4+
using ParadeDB.EntityFrameworkCore.Extensions;
5+
using Shared;
6+
7+
var options = new DbContextOptionsBuilder<AppDbContext>()
8+
.UseNpgsql(ExampleSetup.ConnectionString, o => o.UseParadeDb())
9+
.UseSnakeCaseNamingConvention()
10+
.Options;
11+
12+
await using var dbContext = new AppDbContext(options);
13+
14+
Console.WriteLine(new string('=', 70));
15+
Console.WriteLine("Vector Search");
16+
Console.WriteLine(new string('=', 70));
17+
Console.WriteLine("\nTop-K nearest-neighbor search over a pgvector column");
18+
19+
await ExampleSetup.SetupHybridAsync(dbContext);
20+
await EmbeddingLoader.LoadAsync(dbContext);
21+
22+
await dbContext.Database.ExecuteSqlRawAsync("DROP INDEX IF EXISTS mock_items_vector_idx");
23+
await dbContext.Database.ExecuteSqlRawAsync(
24+
"""
25+
CREATE INDEX mock_items_vector_idx ON mock_items
26+
USING paradedb (id, embedding vector_cosine_ops)
27+
WITH (key_field='id');
28+
"""
29+
);
30+
Console.WriteLine("Created a paradedb index over the embedding column (cosine metric)");
31+
32+
await Demo(dbContext, "running shoes");
33+
await Demo(dbContext, "wireless earbuds");
34+
return;
35+
36+
static async Task Demo(AppDbContext db, string query)
37+
{
38+
var queryEmbedding = QueryEmbeddings.Values[query];
39+
40+
// The @@@ predicate (EF.Functions.All) and the LIMIT are required for the
41+
// ParadeDB index to serve the query; the ORDER BY metric must match the
42+
// index opclass (cosine here)
43+
var results = await db
44+
.MockItems.Where(x => EF.Functions.All(x.Id))
45+
.Select(x => new
46+
{
47+
x.Description,
48+
Distance = EF.Functions.CosineDistance(x.Embedding, queryEmbedding),
49+
})
50+
.OrderBy(x => x.Distance)
51+
.Take(5)
52+
.ToListAsync();
53+
54+
Console.WriteLine($"\n{new string('=', 70)}");
55+
Console.WriteLine($"Query: '{query}'");
56+
Console.WriteLine(new string('=', 70));
57+
58+
for (var i = 0; i < results.Count; i++)
59+
{
60+
var desc = results[i].Description[..Math.Min(60, results[i].Description.Length)];
61+
Console.WriteLine($" {i + 1}. {desc, -60} (distance: {results[i].Distance:F4})");
62+
}
63+
}

0 commit comments

Comments
 (0)