Skip to content

Commit dfb3550

Browse files
committed
feat: native vector search support (paradedb/paradedb#5685)
1 parent 752a2c1 commit dfb3550

21 files changed

Lines changed: 969 additions & 25 deletions

CHANGELOG.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,11 @@ All notable changes to this project will be documented in this file. The format
88

99
- Support the `paradedb` index access method (requires pg_search 0.25.0+, unreleased). `ParadeDB::Index` and `add_paradedb_index` accept an `am:` option (`"paradedb"` or `"bm25"`), and schema dumps record `am:` when an index does not use the default.
1010
- `add_paradedb_index`, `remove_paradedb_index`, and `reindex_paradedb_index` are the primary names for the migration helpers (connection and migration DSL). The `bm25`-named helpers (`add_bm25_index`, `remove_bm25_index`, `reindex_bm25`) remain fully supported aliases. Schema dumps and generated migrations now emit the `paradedb`-named helpers; `schema.rb` files using either name continue to load.
11+
- Native pgvector `vector(n)` column support: ActiveRecord attribute type, `t.vector` / `add_column :table, :col, :vector, limit: n` migration DSL, and `schema.rb` dumping — no `neighbor` gem required.
12+
- Vector fields in BM25 indexes via `embedding: { metric: :l2 | :cosine | :ip }` in `ParadeDB::Index` fields and `add_paradedb_index`, emitted as `vector_l2_ops` / `vector_cosine_ops` / `vector_ip_ops` opclasses and round-tripped through the schema dumper.
13+
- `Model.nearest(column, vector, metric: nil)` for Top-K vector search. Orders by the pgvector distance operator (`<->`, `<=>`, `<#>`), defaults the metric from the index definition, and adds `key_field @@@ pdb.all()` when the relation has no ParadeDB predicate.
14+
- `l2_distance` / `cosine_distance` / `inner_product` / `vector_distance` Arel builder methods and `pdb_l2_distance` / `pdb_cosine_distance` / `pdb_inner_product` / `pdb_vector_distance` attribute predications.
15+
- `examples/vector_search` example. `examples/hybrid_rrf` now uses the native vector support instead of `neighbor`.
1116

1217
### Changed
1318

README.md

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,13 +47,42 @@ The official ActiveRecord integration for [ParadeDB](https://paradedb.com) (powe
4747
| ParadeDB | 0.22.0+ |
4848
| PostgreSQL | 15+ (PostgreSQL adapter with ParadeDB extension) |
4949

50+
## Vector Search
51+
52+
rails-paradedb natively supports pgvector `vector(n)` columns — no `neighbor` or other pgvector gem required. Declare the column, list it in the BM25 index with a distance metric, and run Top-K queries:
53+
54+
```ruby
55+
class AddVectorSearchToProducts < ActiveRecord::Migration[8.1]
56+
def change
57+
add_column :products, :embedding, :vector, limit: 384
58+
59+
add_bm25_index :products,
60+
fields: { id: {}, description: {}, embedding: { metric: :l2 } },
61+
key_field: :id
62+
end
63+
end
64+
```
65+
66+
```ruby
67+
Product.nearest(:embedding, query_embedding).limit(10)
68+
```
69+
70+
`nearest` orders by the pgvector distance operator for the chosen metric — `:l2``<->`, `:cosine``<=>`, `:ip``<#>` — and defaults to the metric declared in the index (`:l2` when none is declared). ParadeDB requires a `@@@` predicate alongside the vector `ORDER BY` to activate the index scan; `nearest` adds `key_field @@@ pdb.all()` automatically when the relation has no ParadeDB predicate, and composes with full-text search:
71+
72+
```ruby
73+
Product.search(:description).match_all("shoes").nearest(:embedding, query_embedding).limit(10)
74+
```
75+
76+
Top-K index pushdown requires a `LIMIT`, and the query metric must match the index opclass metric — a mismatch still returns correct results but silently falls back to a plain sort. Vector columns inside BM25 indexes require a ParadeDB build with bm25 vector opclasses (not yet in a released ParadeDB version); the plain `vector(n)` column type and distance expressions work on any ParadeDB or Postgres with pgvector.
77+
5078
## Examples
5179

5280
- [Quickstart](examples/quickstart/quickstart.rb)
5381
- [Faceted Search](examples/faceted_search/faceted_search.rb)
5482
- [Autocomplete](examples/autocomplete/autocomplete.rb)
5583
- [More Like This](examples/more_like_this/more_like_this.rb)
5684
- [Hybrid Search (RRF)](examples/hybrid_rrf/hybrid_rrf.rb)
85+
- [Vector Search](examples/vector_search/vector_search.rb)
5786
- [RAG](examples/rag/rag.rb)
5887

5988
## Contributing

examples/Gemfile

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,3 @@
11
source "https://rubygems.org"
22

33
eval_gemfile "../Gemfile"
4-
5-
# Used by the hybrid search example (BM25 + pgvector).
6-
gem "neighbor"

examples/Gemfile.lock

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -67,8 +67,6 @@ GEM
6767
minitest (6.0.6)
6868
drb (~> 2.0)
6969
prism (~> 1.5)
70-
neighbor (1.1.1)
71-
activerecord (>= 7.2)
7270
nokogiri (1.19.4-aarch64-linux-gnu)
7371
racc (~> 1.4)
7472
nokogiri (1.19.4-arm64-darwin)
@@ -79,7 +77,6 @@ GEM
7977
parser (3.3.11.1)
8078
ast (~> 2.4.1)
8179
racc
82-
pg (1.6.3)
8380
pg (1.6.3-aarch64-linux)
8481
pg (1.6.3-arm64-darwin)
8582
pg (1.6.3-x86_64-linux)
@@ -184,7 +181,6 @@ PLATFORMS
184181
DEPENDENCIES
185182
activerecord (~> 8.1)
186183
activesupport (~> 8.1)
187-
neighbor
188184
pg (~> 1.5)
189185
railties (~> 8.1)
190186
rake (~> 13.4)

examples/README.md

Lines changed: 19 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -18,9 +18,6 @@ Each example folder uses a Rails-like layout:
1818
BUNDLE_GEMFILE=examples/Gemfile bundle install
1919
```
2020

21-
The hybrid RRF example uses pgvector via `neighbor`, which is already included
22-
in `examples/Gemfile`.
23-
2421
### 2. Start ParadeDB
2522

2623
```bash
@@ -109,7 +106,7 @@ Structure:
109106
1. Hybrid Search with RRF (`hybrid_rrf/`)
110107

111108
Demonstrates Reciprocal Rank Fusion (RRF) by composing a ParadeDB BM25 relation
112-
with a semantic relation (via `neighbor`) using CTEs.
109+
with a semantic relation (using the native vector distance helpers) via CTEs.
113110

114111
```bash
115112
BUNDLE_GEMFILE=examples/Gemfile bundle exec ruby examples/hybrid_rrf/setup.rb
@@ -122,6 +119,24 @@ Structure:
122119
- `examples/hybrid_rrf/setup.rb`
123120
- `examples/hybrid_rrf/hybrid_rrf.rb`
124121

122+
1. Vector Search (`vector_search/`)
123+
124+
Top-K vector search inside the BM25 index: `vector(n)` columns, metric
125+
opclasses, and `nearest` queries. Requires a ParadeDB build with bm25 vector
126+
opclasses (not yet in a released ParadeDB version); the setup script aborts
127+
with a clear message otherwise.
128+
129+
```bash
130+
BUNDLE_GEMFILE=examples/Gemfile bundle exec ruby examples/vector_search/setup.rb
131+
BUNDLE_GEMFILE=examples/Gemfile bundle exec ruby examples/vector_search/vector_search.rb
132+
```
133+
134+
Structure:
135+
136+
- `examples/vector_search/model.rb`
137+
- `examples/vector_search/setup.rb`
138+
- `examples/vector_search/vector_search.rb`
139+
125140
1. RAG (`rag/rag.rb`)
126141

127142
Retrieves products with ParadeDB and sends context to OpenRouter.

examples/hybrid_rrf/hybrid_rrf.rb

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,10 @@ def fulltext_ranked_cte(query, top_k:)
2424
end
2525

2626
def semantic_ranked_cte(query_embedding, top_k:)
27-
semantic_source = MockItem.nearest_neighbors(:embedding, query_embedding, distance: "cosine")
27+
distance = MockItem.paradedb_arel.cosine_distance(:embedding, query_embedding)
28+
semantic_source = MockItem.where.not(embedding: nil)
29+
.select(MockItem.arel_table[:id], distance.as("neighbor_distance"))
30+
.order(distance.asc)
2831
.limit(top_k)
2932

3033
MockItem.from(semantic_source, :semantic_source)
@@ -131,7 +134,7 @@ def display_results(query, rows)
131134
puts "=" * 80
132135
puts "Hybrid Search with Reciprocal Rank Fusion (single SQL query)"
133136
puts "=" * 80
134-
puts "\nCombining ParadeDB DSL + Neighbor DSL in one CTE-based query"
137+
puts "\nCombining ParadeDB BM25 + native vector distance in one CTE-based query"
135138

136139
HybridRrfSetup.setup!
137140
MockItem.reset_column_information

examples/hybrid_rrf/model.rb

Lines changed: 0 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,5 @@
11
# frozen_string_literal: true
22

3-
begin
4-
require "neighbor"
5-
rescue LoadError
6-
abort "Example requires neighbor. Run with `BUNDLE_GEMFILE=examples/Gemfile bundle exec ...`."
7-
end
8-
93
require "active_record"
104
require_relative "../../lib/parade_db"
115

@@ -14,8 +8,6 @@ class MockItem < ActiveRecord::Base
148

159
self.table_name = "mock_items_hybrid_rrf"
1610
self.primary_key = "id"
17-
18-
has_neighbors :embedding
1911
end
2012

2113
class MockItemIndex < ParadeDB::Index

examples/vector_search/model.rb

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
# frozen_string_literal: true
2+
3+
require "active_record"
4+
require_relative "../../lib/parade_db"
5+
6+
class VectorItem < ActiveRecord::Base
7+
include ParadeDB::Model
8+
9+
self.table_name = "vector_items"
10+
self.primary_key = "id"
11+
end
12+
13+
class VectorItemIndex < ParadeDB::Index
14+
self.table_name = :vector_items
15+
self.key_field = :id
16+
self.index_name = :vector_items_bm25_idx
17+
self.fields = {
18+
id: nil,
19+
description: nil,
20+
category: { tokenizer: ParadeDB::Tokenizer.literal() },
21+
embedding: { metric: :l2 }
22+
}
23+
end

examples/vector_search/setup.rb

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
#!/usr/bin/env ruby
2+
# frozen_string_literal: true
3+
4+
require "logger"
5+
require "rails"
6+
require "active_record"
7+
require_relative "../../lib/parade_db"
8+
9+
class VectorSearchExampleApp < Rails::Application
10+
config.root = File.expand_path("../..", __dir__)
11+
config.eager_load = false
12+
config.logger = Logger.new(nil)
13+
config.secret_key_base = "paradedb_examples_secret_key_base"
14+
end
15+
16+
VectorSearchExampleApp.initialize!
17+
18+
require_relative "model"
19+
20+
module VectorSearchSetup
21+
module_function
22+
23+
SEED_ITEMS = [
24+
{ description: "Sleek running shoes", category: "Footwear", embedding: [1.0, 0.1, 0.0] },
25+
{ description: "Trail running shoes with grip", category: "Footwear", embedding: [0.9, 0.2, 0.1] },
26+
{ description: "White leather sneakers", category: "Footwear", embedding: [0.7, 0.4, 0.2] },
27+
{ description: "Innovative wireless earbuds", category: "Electronics", embedding: [0.0, 1.0, 0.1] },
28+
{ description: "Over-ear noise cancelling headphones", category: "Electronics", embedding: [0.1, 0.9, 0.2] },
29+
{ description: "Portable bluetooth speaker", category: "Electronics", embedding: [0.2, 0.8, 0.4] },
30+
{ description: "Insulated camping tent", category: "Outdoor", embedding: [0.1, 0.2, 1.0] },
31+
{ description: "Lightweight hiking backpack", category: "Outdoor", embedding: [0.3, 0.1, 0.9] }
32+
].freeze
33+
34+
def database_url
35+
return ENV["DATABASE_URL"] if ENV["DATABASE_URL"]
36+
37+
host = ENV.fetch("PGHOST", "localhost")
38+
port = ENV.fetch("PGPORT", "5432")
39+
user = ENV.fetch("PGUSER", "postgres")
40+
password = ENV.fetch("PGPASSWORD", "postgres")
41+
database = ENV.fetch("PGDATABASE", "postgres")
42+
43+
"postgresql://#{user}:#{password}@#{host}:#{port}/#{database}"
44+
end
45+
46+
def connect!
47+
return if ActiveRecord::Base.connected?
48+
49+
ActiveRecord::Base.establish_connection(database_url)
50+
ActiveRecord::Base.logger = nil
51+
end
52+
53+
def vector_search_supported?
54+
ActiveRecord::Base.connection.select_value(<<~SQL)
55+
SELECT 1
56+
FROM pg_opclass oc
57+
JOIN pg_am am ON am.oid = oc.opcmethod
58+
WHERE am.amname = 'bm25' AND oc.opcname = 'vector_l2_ops'
59+
LIMIT 1
60+
SQL
61+
end
62+
63+
def setup!
64+
connect!
65+
66+
conn = ActiveRecord::Base.connection
67+
conn.execute("CREATE EXTENSION IF NOT EXISTS pg_search;")
68+
conn.execute("CREATE EXTENSION IF NOT EXISTS vector;")
69+
70+
unless vector_search_supported?
71+
abort "This example requires a ParadeDB build with bm25 vector opclasses " \
72+
"(unreleased; see paradedb/paradedb branch mvp/vector-search)."
73+
end
74+
75+
conn.execute("DROP TABLE IF EXISTS vector_items CASCADE;")
76+
ActiveRecord::Schema.define do
77+
suppress_messages do
78+
create_table :vector_items, force: true do |t|
79+
t.text :description
80+
t.text :category
81+
t.vector :embedding, limit: 3
82+
end
83+
end
84+
end
85+
86+
VectorItem.reset_column_information
87+
SEED_ITEMS.each { |attrs| VectorItem.create!(attrs) }
88+
89+
conn.create_paradedb_index(VectorItemIndex, if_not_exists: true)
90+
VectorItem.count
91+
end
92+
end
93+
94+
if $PROGRAM_NAME == __FILE__
95+
puts "=" * 60
96+
puts "Vector Search Setup - Creating vector_items Table"
97+
puts "=" * 60
98+
99+
count = VectorSearchSetup.setup!
100+
puts "+ Seeded #{count} items with vector(3) embeddings"
101+
puts "\nSetup complete! Run: BUNDLE_GEMFILE=examples/Gemfile bundle exec ruby examples/vector_search/vector_search.rb"
102+
end
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
#!/usr/bin/env ruby
2+
# frozen_string_literal: true
3+
4+
require_relative "setup"
5+
6+
QUERY_EMBEDDING = [1.0, 0.0, 0.0].freeze
7+
8+
def demo_top_k
9+
puts "\n--- Top-K Nearest Neighbors (L2, from the index metric) ---"
10+
results = VectorItem.nearest(:embedding, QUERY_EMBEDDING).limit(3)
11+
puts(results.map { |item| " - #{item.description}" })
12+
end
13+
14+
def demo_metric_override
15+
puts "\n--- Metric Override (cosine; falls back to a plain sort without a matching opclass) ---"
16+
results = VectorItem.nearest(:embedding, QUERY_EMBEDDING, metric: :cosine).limit(3)
17+
puts(results.map { |item| " - #{item.description}" })
18+
end
19+
20+
def demo_filtered_vector_search
21+
puts "\n--- Vector Search + Full-Text Filter: category 'Footwear' ---"
22+
results = VectorItem.search(:category)
23+
.term("Footwear")
24+
.nearest(:embedding, QUERY_EMBEDDING)
25+
.limit(3)
26+
puts(results.map { |item| " - #{item.description}" })
27+
end
28+
29+
def demo_distance_projection
30+
puts "\n--- Distance Projection ---"
31+
distance = VectorItem.paradedb_arel.l2_distance(:embedding, QUERY_EMBEDDING)
32+
results = VectorItem.nearest(:embedding, QUERY_EMBEDDING)
33+
.select(VectorItem.arel_table[Arel.star], distance.as("distance"))
34+
.limit(3)
35+
puts(results.map { |item| " - #{item.description} (distance: #{item.distance.round(3)})" })
36+
end
37+
38+
if $PROGRAM_NAME == __FILE__
39+
puts "=" * 60
40+
puts "rails-paradedb Vector Search Example"
41+
puts "=" * 60
42+
43+
VectorSearchSetup.setup!
44+
45+
demo_top_k
46+
demo_metric_override
47+
demo_filtered_vector_search
48+
demo_distance_projection
49+
50+
puts "\nDone!"
51+
end

0 commit comments

Comments
 (0)