rails-paradedb 0.9.0 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 25216744f6402f3ffdc06d8381f8ed801acec379dc15c71b92187b9e28a61b6d
4
- data.tar.gz: 64dfcdcefa05ff52d0cd6b4b0292c005a0f4c2cf3a193ee07ac27bdae175ddd0
3
+ metadata.gz: 61970bcefe75f0514836a81ab38dd8e39fe1c6f311d55f35366120d2285325b9
4
+ data.tar.gz: 6179c3b6d0814dcaae1179977548c4b47e6f6447110596293f242d668727d79a
5
5
  SHA512:
6
- metadata.gz: f3fb68ca97ffc5a30f6d503f15e97b02bc8db1de5aaeab020991848f777bc401fc62d06049490790ef75416b80c6a226edeb8cfe91e2bd8d8c3d76d12e95accb
7
- data.tar.gz: 6c1e58314734c06d138a32f8a6928d13c7178e7db44e8460a45efbfb128e168d81f22dac53f74bc57ccd19f88f4ad0fdf60c3590dfcfd8d04e39b80d39faf531
6
+ metadata.gz: 38471cccf3d392cd481dbb22cde9f35c37f09a555c4e6081c5484d9c5ad1920082172276447a2401a22c0dd46bcd1a0846f2dee26678f556f913373996a91da0
7
+ data.tar.gz: 6176c6252901db66d8f8dd72972f8053fc6a187d0d10391705dde18fabe16a4992d60655a351b3af8643e9ca4a57c2baff58a11a2dd93cb63a708750785034c6
data/CHANGELOG.md CHANGED
@@ -2,7 +2,27 @@
2
2
 
3
3
  All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
4
4
 
5
- ## [Unreleased]
5
+ ## [0.11.0] - 2026-08-04
6
+
7
+ ### Added
8
+
9
+ - Vector index build options `centroid_ratio`, `training_samples_per_centroid`, and `cluster_replication` in `ParadeDB::Index` `index_options` and `add_paradedb_index` (pg_search 0.25.0+). They are emitted in the `WITH (...)` clause and round-tripped through the schema dumper.
10
+
11
+ [0.10.0] - 2026-08-04
12
+
13
+ ### Added
14
+
15
+ - 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.
16
+ - Vector fields in ParadeDB 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.
17
+ - `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.
18
+ - `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.
19
+ - `examples/vector_search` example. `examples/hybrid_rrf` now uses the native vector support instead of `neighbor`, and `examples/rag` retrieval combines full-text search with `nearest`.
20
+
21
+ ### Changed
22
+
23
+ - **BREAKING**: Renamed the `bm25`-named migration helpers to `paradedb`: `add_bm25_index` is now `add_paradedb_index`, `remove_bm25_index` is now `remove_paradedb_index`, and `reindex_bm25` is now `reindex_paradedb_index`. The old names were removed; update migrations and `schema.rb` files to the new names.
24
+ - **BREAKING**: Index creation always emits `USING paradedb`, which requires pg_search 0.25.0+. There is no option to select the legacy `bm25` access method.
25
+ - **BREAKING**: The default index name is now `<table>_search_idx` (previously `<table>_bm25_idx`), and the index generator emits `Create<Model>SearchIndex` migrations named `create_<table>_search_index.rb`.
6
26
 
7
27
  [0.9.0] - 2026-07-14
8
28
 
@@ -154,7 +174,8 @@ All notable changes to this project will be documented in this file. The format
154
174
  - Schema dump/load round-trip for tokenizer configuration and index options
155
175
  (including `target_segment_count`)
156
176
 
157
- [Unreleased]: https://github.com/paradedb/rails-paradedb/compare/v0.9.0...HEAD
177
+ [0.11.0]: https://github.com/paradedb/rails-paradedb/releases/tag/v0.11.0
178
+ [0.10.0]: https://github.com/paradedb/rails-paradedb/releases/tag/v0.10.0
158
179
  [0.9.0]: https://github.com/paradedb/rails-paradedb/releases/tag/v0.9.0
159
180
  [0.8.0]: https://github.com/paradedb/rails-paradedb/releases/tag/v0.8.0
160
181
  [0.7.0]: https://github.com/paradedb/rails-paradedb/releases/tag/v0.7.0
data/README.md CHANGED
@@ -36,20 +36,26 @@
36
36
 
37
37
  ## ParadeDB for Rails
38
38
 
39
- The official ActiveRecord 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#rails) to begin.
39
+ The official ActiveRecord integration for [ParadeDB](https://paradedb.com) (powered by the [`pg_search`](https://github.com/paradedb/paradedb) Postgres extension), including first-class support for managing ParadeDB indexes and running queries using the full ParadeDB API. Follow the [getting started guide](https://docs.paradedb.com/documentation/getting-started/environment#rails) to begin.
40
40
 
41
41
  ## Requirements & Compatibility
42
42
 
43
- | Component | Supported |
44
- | ---------- | ------------------------------------------------ |
45
- | Ruby | 3.2+ |
46
- | Rails | 7.2+ |
47
- | ParadeDB | 0.22.0+ |
48
- | PostgreSQL | 15+ (PostgreSQL adapter with ParadeDB extension) |
43
+ | Component | Supported |
44
+ | ---------- | ----------------------------------------------------------------- |
45
+ | Ruby | 3.2+ |
46
+ | Rails | 7.2+ |
47
+ | ParadeDB | 0.25.0+ |
48
+ | PostgreSQL | 15+ (PostgreSQL adapter with ParadeDB extension) |
49
+ | pgvector | Required for vector search; included in the ParadeDB Docker image |
50
+
51
+ ## Vector Search
52
+
53
+ rails-paradedb supports full-text search and vector search over pgvector `vector(n)` columns. See the [vector search documentation](https://docs.paradedb.com/documentation/vector/overview) for details.
49
54
 
50
55
  ## Examples
51
56
 
52
57
  - [Quickstart](examples/quickstart/quickstart.rb)
58
+ - [Vector Search](examples/vector_search/vector_search.rb)
53
59
  - [Faceted Search](examples/faceted_search/faceted_search.rb)
54
60
  - [Autocomplete](examples/autocomplete/autocomplete.rb)
55
61
  - [More Like This](examples/more_like_this/more_like_this.rb)
@@ -15,7 +15,7 @@ module ParadeDB
15
15
  class_option :concurrent, type: :boolean, default: false,
16
16
  desc: "Add disable_ddl_transaction! to the migration (required for concurrent index creation)"
17
17
 
18
- desc "Creates a ParadeDB::Index class and a BM25 index migration for MODEL."
18
+ desc "Creates a ParadeDB::Index class and a ParadeDB index migration for MODEL."
19
19
 
20
20
  def self.next_migration_number(dirname)
21
21
  ActiveRecord::Generators::Base.next_migration_number(dirname)
@@ -26,17 +26,17 @@ module ParadeDB
26
26
  end
27
27
 
28
28
  def create_migration_file
29
- migration_template "migration.rb.tt", "db/migrate/create_#{table_name}_bm25_index.rb"
29
+ migration_template "migration.rb.tt", "db/migrate/create_#{table_name}_search_index.rb"
30
30
  end
31
31
 
32
32
  private
33
33
 
34
34
  def index_name
35
- "#{table_name}_bm25_idx"
35
+ "#{table_name}_search_idx"
36
36
  end
37
37
 
38
38
  def migration_class_name
39
- "Create#{class_name}Bm25Index"
39
+ "Create#{class_name}SearchIndex"
40
40
  end
41
41
  end
42
42
  end
@@ -10,6 +10,6 @@ class <%= migration_class_name %> < ActiveRecord::Migration[<%= ActiveRecord::Mi
10
10
  end
11
11
 
12
12
  def down
13
- remove_bm25_index :<%= table_name %>, name: :<%= index_name %>, if_exists: true
13
+ remove_paradedb_index :<%= table_name %>, name: :<%= index_name %>, if_exists: true
14
14
  end
15
15
  end
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
  require "date"
3
3
  require_relative "../tokenizer_sql"
4
+ require_relative "../vector"
4
5
 
5
6
  module ParadeDB
6
7
  module Arel
@@ -220,6 +221,23 @@ module ParadeDB
220
221
  infix("@@@", column_node(column), rhs)
221
222
  end
222
223
 
224
+ def l2_distance(column, vector)
225
+ vector_distance(column, vector, metric: :l2)
226
+ end
227
+
228
+ def cosine_distance(column, vector)
229
+ vector_distance(column, vector, metric: :cosine)
230
+ end
231
+
232
+ def inner_product(column, vector)
233
+ vector_distance(column, vector, metric: :ip)
234
+ end
235
+
236
+ def vector_distance(column, vector, metric: ParadeDB::Vector::DEFAULT_METRIC)
237
+ operator = ParadeDB::Vector::DISTANCE_OPERATORS.fetch(ParadeDB::Vector.normalize_metric(metric))
238
+ infix(operator, column_node(column), vector_operand(vector))
239
+ end
240
+
223
241
  def score(key)
224
242
  ::Arel::Nodes::NamedFunction.new("pdb.score", [column_node(key)])
225
243
  end
@@ -338,6 +356,13 @@ module ParadeDB
338
356
  ::Arel::Nodes.build_quoted(value)
339
357
  end
340
358
 
359
+ def vector_operand(vector)
360
+ return vector if arel_expression?(vector)
361
+
362
+ literal = vector.is_a?(String) ? vector : ParadeDB::Vector.literal(vector)
363
+ Nodes::TypeCast.new(quoted_value(literal), "vector")
364
+ end
365
+
341
366
  def proximity_query_node(proximity, boost: nil, const: nil)
342
367
  unless proximity.is_a?(ParadeDB::Proximity::Clause)
343
368
  raise ArgumentError, "near requires a ParadeDB.proximity(...) clause"
@@ -120,6 +120,22 @@ module ParadeDB
120
120
  ::Arel::Nodes::InfixOperation.new("@@@", self, rhs)
121
121
  end
122
122
 
123
+ def pdb_l2_distance(vector)
124
+ BUILDER.l2_distance(self, vector)
125
+ end
126
+
127
+ def pdb_cosine_distance(vector)
128
+ BUILDER.cosine_distance(self, vector)
129
+ end
130
+
131
+ def pdb_inner_product(vector)
132
+ BUILDER.inner_product(self, vector)
133
+ end
134
+
135
+ def pdb_vector_distance(vector, metric: ParadeDB::Vector::DEFAULT_METRIC)
136
+ BUILDER.vector_distance(self, vector, metric: metric)
137
+ end
138
+
123
139
  def pdb_score
124
140
  ::Arel::Nodes::NamedFunction.new("pdb.score", [self])
125
141
  end
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "tokenizer"
4
+ require_relative "vector"
4
5
 
5
6
  module ParadeDB
6
7
  class Index
@@ -34,7 +35,7 @@ module ParadeDB
34
35
  def default_index_name
35
36
  return nil if table_name.nil?
36
37
 
37
- "#{table_name}_bm25_idx"
38
+ "#{table_name}_search_idx"
38
39
  end
39
40
 
40
41
  def compiled_definition
@@ -82,6 +83,8 @@ module ParadeDB
82
83
  # Consumed by migration helpers; validates and normalizes the DSL class
83
84
  class DefinitionCompiler
84
85
  FIELD_OPTION_KEYS = %i[fast record normalizer expand_dots].freeze
86
+ INDEX_OPTION_KEYS = %i[target_segment_count centroid_ratio training_samples_per_centroid cluster_replication].freeze
87
+ POSITIVE_INTEGER_INDEX_OPTION_KEYS = %i[target_segment_count training_samples_per_centroid cluster_replication].freeze
85
88
 
86
89
  class Compiled
87
90
  attr_reader :table_name, :key_field, :index_name, :entries, :index_options, :field_options, :where
@@ -96,7 +99,7 @@ module ParadeDB
96
99
  @where = where
97
100
  end
98
101
  end
99
- Entry = Struct.new(:source, :expression, :tokenizer, :options, :query_key, keyword_init: true)
102
+ Entry = Struct.new(:source, :expression, :tokenizer, :options, :query_key, :metric, keyword_init: true)
100
103
 
101
104
  class << self
102
105
  def compile!(klass)
@@ -153,7 +156,7 @@ module ParadeDB
153
156
  end
154
157
  normalized = (config || {}).each_with_object({}) { |(k, v), memo| memo[k.to_sym] = v }
155
158
 
156
- unknown_keys = normalized.keys - (TokenizerParser::TOKENIZER_SINGLE_KEYS + [:tokenizers] + FIELD_OPTION_KEYS)
159
+ unknown_keys = normalized.keys - (TokenizerParser::TOKENIZER_SINGLE_KEYS + %i[tokenizers metric] + FIELD_OPTION_KEYS)
157
160
  unless unknown_keys.empty?
158
161
  raise InvalidIndexDefinition,
159
162
  "unknown field config keys for #{source_name.inspect}: #{unknown_keys.map(&:inspect).join(', ')}"
@@ -163,7 +166,27 @@ module ParadeDB
163
166
  single_tokenizer_keys_present = TokenizerParser::TOKENIZER_SINGLE_KEYS.any? { |key| normalized.key?(key) }
164
167
 
165
168
  is_alias = normalized[:alias] && normalized.length == 1
166
- if is_alias
169
+ if normalized.key?(:metric)
170
+ unless normalized.length == 1
171
+ raise InvalidIndexDefinition,
172
+ "field #{source_name.inspect} cannot mix :metric with other field config keys"
173
+ end
174
+
175
+ begin
176
+ metric = ParadeDB::Vector.normalize_metric(normalized[:metric])
177
+ rescue ArgumentError => e
178
+ raise InvalidIndexDefinition, "field #{source_name.inspect}: #{e.message}"
179
+ end
180
+
181
+ entries << Entry.new(
182
+ source: source_name,
183
+ expression: expression?(source_name),
184
+ tokenizer: nil,
185
+ options: {},
186
+ query_key: source_name,
187
+ metric: metric
188
+ )
189
+ elsif is_alias
167
190
  entries << Entry.new(
168
191
  source: source_name,
169
192
  expression: expression?(source_name),
@@ -217,16 +240,26 @@ module ParadeDB
217
240
  memo[key.to_sym] = value
218
241
  end
219
242
 
220
- unknown = normalized.keys - [:target_segment_count]
243
+ unknown = normalized.keys - INDEX_OPTION_KEYS
221
244
  unless unknown.empty?
222
245
  raise InvalidIndexDefinition,
223
246
  "unknown index_options keys: #{unknown.map(&:inspect).join(', ')}"
224
247
  end
225
248
 
226
- if normalized.key?(:target_segment_count)
227
- target = normalized[:target_segment_count]
228
- unless target.is_a?(Integer) && target.positive?
229
- raise InvalidIndexDefinition, "index_options[:target_segment_count] must be an Integer > 0"
249
+ POSITIVE_INTEGER_INDEX_OPTION_KEYS.each do |key|
250
+ next unless normalized.key?(key)
251
+
252
+ value = normalized[key]
253
+ unless value.is_a?(Integer) && value.positive?
254
+ raise InvalidIndexDefinition, "index_options[#{key.inspect}] must be an Integer > 0"
255
+ end
256
+ end
257
+
258
+ if normalized.key?(:centroid_ratio)
259
+ ratio = normalized[:centroid_ratio]
260
+ unless ratio.is_a?(Numeric) && ratio >= 0.000001 && ratio <= 1.0
261
+ raise InvalidIndexDefinition,
262
+ "index_options[:centroid_ratio] must be a Numeric between 0.000001 and 1.0"
230
263
  end
231
264
  end
232
265
 
@@ -20,12 +20,12 @@ module ParadeDB
20
20
  ensure_postgresql_adapter!
21
21
  resolved = resolve_index_klass(index_klass)
22
22
  compiled = resolved.compiled_definition
23
- remove_bm25_index(compiled.table_name, name: compiled.index_name, if_exists: true)
23
+ remove_paradedb_index(compiled.table_name, name: compiled.index_name, if_exists: true)
24
24
  execute(build_create_sql(compiled, if_not_exists: false))
25
25
  remember_schema_index_reference(resolved)
26
26
  end
27
27
 
28
- def add_bm25_index(table, fields:, key_field:, name: nil, index_options: nil, where: nil, if_not_exists: false, concurrently: false)
28
+ def add_paradedb_index(table, fields:, key_field:, name: nil, index_options: nil, where: nil, if_not_exists: false, concurrently: false)
29
29
  ensure_postgresql_adapter!
30
30
  anonymous = Class.new(ParadeDB::Index)
31
31
  anonymous.table_name = table
@@ -38,31 +38,31 @@ module ParadeDB
38
38
  create_paradedb_index(anonymous, if_not_exists: if_not_exists, concurrently: concurrently)
39
39
  end
40
40
 
41
- def remove_bm25_index(table, name: nil, if_exists: false)
41
+ def remove_paradedb_index(table, name: nil, if_exists: false)
42
42
  ensure_postgresql_adapter!
43
- index_name = (name || "#{table}_bm25_idx").to_s
43
+ index_name = (name || "#{table}_search_idx").to_s
44
44
  prefix = if_exists ? "IF EXISTS " : ""
45
45
  execute("DROP INDEX #{prefix}#{quote_table_name(index_name)}")
46
46
  end
47
47
 
48
- def reindex_bm25(table, name: nil, concurrently: false)
48
+ def reindex_paradedb_index(table, name: nil, concurrently: false)
49
49
  ensure_postgresql_adapter!
50
50
  if concurrently && transaction_open_for_paradedb?
51
- raise ArgumentError, "reindex_bm25 concurrently: true cannot run inside a transaction"
51
+ raise ArgumentError, "reindex_paradedb_index concurrently: true cannot run inside a transaction"
52
52
  end
53
53
 
54
- index_name = (name || "#{table}_bm25_idx").to_s
54
+ index_name = (name || "#{table}_search_idx").to_s
55
55
  modifier = concurrently ? " CONCURRENTLY" : ""
56
56
  execute("REINDEX INDEX#{modifier} #{quote_table_name(index_name)}")
57
57
  end
58
58
 
59
59
  def dump_paradedb_indexes(stream)
60
- rows = paradedb_bm25_index_rows
60
+ rows = paradedb_index_rows
61
61
  return if rows.empty?
62
62
 
63
63
  stream.puts
64
64
  rows.each do |row|
65
- ruby_stmt = bm25_index_to_ruby(row)
65
+ ruby_stmt = paradedb_index_to_ruby(row)
66
66
  stream.puts " #{ruby_stmt}"
67
67
  end
68
68
  end
@@ -71,8 +71,8 @@ module ParadeDB
71
71
  (@paradedb_schema_index_references || []).uniq.sort
72
72
  end
73
73
 
74
- def paradedb_bm25_index_names
75
- paradedb_bm25_index_rows.map { |r| r["index_name"] }
74
+ def paradedb_index_names
75
+ paradedb_index_rows.map { |r| r["index_name"] }
76
76
  end
77
77
 
78
78
  private
@@ -84,38 +84,41 @@ module ParadeDB
84
84
  def build_create_sql(compiled, if_not_exists:, concurrently: false)
85
85
  modifier = concurrently ? " CONCURRENTLY" : ""
86
86
  prefix = if_not_exists ? "IF NOT EXISTS " : ""
87
- fields_sql = compiled.entries.map { |entry| bm25_entry_sql(entry) }.join(", ")
88
- with_options_sql = bm25_with_options_sql(compiled)
87
+ fields_sql = compiled.entries.map { |entry| paradedb_entry_sql(entry) }.join(", ")
88
+ with_options_sql = paradedb_with_options_sql(compiled)
89
89
  where_sql = compiled.where ? "\nWHERE #{compiled.where}" : ""
90
90
 
91
91
  <<~SQL.strip.gsub(/\s+/, " ")
92
92
  CREATE INDEX#{modifier} #{prefix}#{quote_table_name(compiled.index_name)} ON #{quote_table_name(compiled.table_name)}
93
- USING bm25 (#{fields_sql})
93
+ USING paradedb (#{fields_sql})
94
94
  WITH (#{with_options_sql})#{where_sql}
95
95
  SQL
96
96
  end
97
97
 
98
- def bm25_with_options_sql(compiled)
98
+ def paradedb_with_options_sql(compiled)
99
99
  options = []
100
100
  options << "key_field=#{quote(compiled.key_field.to_s)}"
101
101
 
102
102
  compiled.index_options.each do |key, value|
103
- case key.to_sym
104
- when :target_segment_count
105
- options << "target_segment_count=#{Integer(value)}"
103
+ name = key.to_sym
104
+ case name
105
+ when :target_segment_count, :training_samples_per_centroid, :cluster_replication
106
+ options << "#{name}=#{Integer(value)}"
107
+ when :centroid_ratio
108
+ options << "centroid_ratio=#{Float(value)}"
106
109
  else
107
110
  raise ParadeDB::InvalidIndexDefinition, "unsupported index option #{key.inspect}"
108
111
  end
109
112
  end
110
113
 
111
- bm25_field_option_groups(compiled).each do |param_name, value_hash|
114
+ paradedb_field_option_groups(compiled).each do |param_name, value_hash|
112
115
  options << "#{param_name}=#{quote(JSON.generate(value_hash))}"
113
116
  end
114
117
 
115
118
  options.join(", ")
116
119
  end
117
120
 
118
- def bm25_field_option_groups(compiled)
121
+ def paradedb_field_option_groups(compiled)
119
122
  field_options = compiled.field_options || {}
120
123
  return {} if field_options.empty?
121
124
 
@@ -128,7 +131,7 @@ module ParadeDB
128
131
  column = columns_by_name[source.to_s]
129
132
  next unless column
130
133
 
131
- param_name = bm25_field_option_param_for_column(column)
134
+ param_name = paradedb_field_option_param_for_column(column)
132
135
  next if param_name.nil?
133
136
 
134
137
  normalized = normalize_field_options_for_param(opts, param_name)
@@ -141,7 +144,7 @@ module ParadeDB
141
144
  grouped
142
145
  end
143
146
 
144
- def bm25_field_option_param_for_column(column)
147
+ def paradedb_field_option_param_for_column(column)
145
148
  sql_type = column.sql_type.to_s.downcase
146
149
  return "range_fields" if sql_type.include?("range")
147
150
 
@@ -182,8 +185,10 @@ module ParadeDB
182
185
  end
183
186
  end
184
187
 
185
- def bm25_entry_sql(entry)
186
- source_sql = bm25_source_sql(entry)
188
+ def paradedb_entry_sql(entry)
189
+ source_sql = paradedb_source_sql(entry)
190
+
191
+ return "#{source_sql} #{ParadeDB::Vector::OPCLASSES.fetch(entry.metric)}" if entry.metric
187
192
 
188
193
  if entry.tokenizer.nil? && entry.query_key != entry.source
189
194
  return "(#{source_sql}::pdb.alias(#{quote(entry.query_key)}))"
@@ -194,7 +199,7 @@ module ParadeDB
194
199
  "(#{source_sql}::#{tokenizer_sql(entry.tokenizer, entry.options)})"
195
200
  end
196
201
 
197
- def bm25_source_sql(entry)
202
+ def paradedb_source_sql(entry)
198
203
  if entry.expression
199
204
  "(#{entry.source})"
200
205
  else
@@ -289,12 +294,13 @@ module ParadeDB
289
294
  false
290
295
  end
291
296
 
292
- def paradedb_bm25_index_rows
297
+ def paradedb_index_rows
293
298
  sql = <<~SQL
294
299
  SELECT
295
300
  c.relname AS index_name,
296
301
  t.relname AS table_name,
297
302
  pg_get_indexdef(c.oid) AS indexdef,
303
+ array_to_json(c.reloptions)::text AS reloptions,
298
304
  pg_get_expr(i.indpred, i.indrelid) AS where_clause
299
305
  FROM pg_class c
300
306
  JOIN pg_namespace n ON n.oid = c.relnamespace
@@ -302,28 +308,28 @@ module ParadeDB
302
308
  JOIN pg_class t ON t.oid = i.indrelid
303
309
  JOIN pg_am am ON am.oid = c.relam
304
310
  WHERE n.nspname = current_schema()
305
- AND am.amname = 'bm25'
311
+ AND am.amname IN ('paradedb', 'bm25')
306
312
  ORDER BY t.relname, c.relname
307
313
  SQL
308
314
  select_all(sql).to_a
309
315
  rescue => e
310
- Kernel.warn("ParadeDB: unable to query bm25 indexes from catalog: #{e.message}")
316
+ Kernel.warn("ParadeDB: unable to query paradedb indexes from catalog: #{e.message}")
311
317
  []
312
318
  end
313
319
 
314
- def bm25_index_to_ruby(row)
320
+ def paradedb_index_to_ruby(row)
315
321
  indexdef = row["indexdef"]
316
322
  table = row["table_name"]
317
323
  name = row["index_name"]
318
324
 
319
- key_field = extract_bm25_key_field(indexdef)
320
- index_options = extract_bm25_index_options(indexdef)
321
- fields_sql = extract_bm25_fields_sql(indexdef)
322
- where = normalize_bm25_where_clause(row["where_clause"])
325
+ key_field = extract_paradedb_key_field(indexdef)
326
+ index_options = extract_paradedb_index_options(row["reloptions"])
327
+ fields_sql = extract_paradedb_fields_sql(indexdef)
328
+ where = normalize_paradedb_where_clause(row["where_clause"])
323
329
 
324
330
  if key_field && fields_sql
325
- field_sqls = split_bm25_top_level(fields_sql).map(&:strip)
326
- parsed = field_sqls.map { |f| bm25_parse_column_entry(f) }
331
+ field_sqls = split_paradedb_top_level(fields_sql).map(&:strip)
332
+ parsed = field_sqls.map { |f| paradedb_parse_column_entry(f) }
327
333
 
328
334
  grouped = {}
329
335
  parsed.each { |e| (grouped[e[:source]] ||= []) << e }
@@ -331,17 +337,19 @@ module ParadeDB
331
337
  fields_pairs = grouped.map do |source, entries|
332
338
  source_ruby = source.match?(/[^a-zA-Z0-9_]/) ? "#{source.inspect} =>" : "#{source}:"
333
339
 
334
- if entries.all? { |e| e[:tokenizer].nil? }
340
+ if entries.length == 1 && entries.first[:metric]
341
+ "#{source_ruby} { metric: #{entries.first[:metric].inspect} }"
342
+ elsif entries.all? { |e| e[:tokenizer].nil? }
335
343
  "#{source_ruby} {}"
336
344
  elsif entries.length == 1
337
- "#{source_ruby} #{bm25_tokenizer_config_ruby(entries.first)}"
345
+ "#{source_ruby} #{paradedb_tokenizer_config_ruby(entries.first)}"
338
346
  else
339
- configs = entries.map { |e| bm25_tokenizer_ruby_from_entry(e) }
347
+ configs = entries.map { |e| paradedb_tokenizer_ruby_from_entry(e) }
340
348
  "#{source_ruby} { tokenizers: [#{configs.join(', ')}] }"
341
349
  end
342
350
  end
343
351
 
344
- statement = "add_bm25_index #{table.to_sym.inspect}, " \
352
+ statement = "add_paradedb_index #{table.to_sym.inspect}, " \
345
353
  "fields: { #{fields_pairs.join(', ')} }, " \
346
354
  "key_field: #{key_field.to_sym.inspect}, " \
347
355
  "name: #{name.inspect}"
@@ -355,7 +363,7 @@ module ParadeDB
355
363
  end
356
364
  end
357
365
 
358
- def extract_bm25_key_field(indexdef)
366
+ def extract_paradedb_key_field(indexdef)
359
367
  quoted = indexdef.match(/WITH\s*\([^)]*key_field\s*=\s*'((?:[^']|'')*)'/i)
360
368
  return quoted[1].gsub("''", "'") if quoted
361
369
 
@@ -365,49 +373,35 @@ module ParadeDB
365
373
  nil
366
374
  end
367
375
 
368
- def extract_bm25_index_options(indexdef)
369
- with_sql, = extract_bm25_with_components(indexdef)
370
- options = {}
371
- split_sql_arguments(with_sql).each do |argument|
372
- key, value_sql = split_assignment(argument)
373
- next if key.nil?
374
- next if key == "key_field"
376
+ def extract_paradedb_index_options(reloptions)
377
+ paradedb_reloption_entries(reloptions).each_with_object({}) do |entry, options|
378
+ key, separator, value = entry.to_s.partition("=")
379
+ next if separator.empty?
375
380
 
376
381
  case key
377
- when "target_segment_count"
378
- parsed = parse_sql_literal(value_sql)
379
- if parsed.is_a?(Integer)
380
- options[:target_segment_count] = parsed
381
- elsif parsed.is_a?(String) && parsed.match?(/\A\d+\z/)
382
- options[:target_segment_count] = parsed.to_i
383
- end
382
+ when "target_segment_count", "training_samples_per_centroid", "cluster_replication"
383
+ parsed = Integer(value, 10, exception: false)
384
+ options[key.to_sym] = parsed if parsed
385
+ when "centroid_ratio"
386
+ parsed = Float(value, exception: false)
387
+ options[:centroid_ratio] = parsed if parsed
384
388
  end
385
389
  end
386
- options
387
- rescue
388
- {}
389
390
  end
390
391
 
391
- def extract_bm25_fields_sql(indexdef)
392
- match = indexdef.match(/USING\s+bm25\s*\(/im)
393
-
394
- start = match.end(0)
395
- depth = 1
396
- pos = start
397
- while pos < indexdef.length && depth > 0
398
- case indexdef[pos]
399
- when "(" then depth += 1
400
- when ")" then depth -= 1
401
- end
402
- pos += 1
392
+ def paradedb_reloption_entries(reloptions)
393
+ case reloptions
394
+ when Array then reloptions
395
+ when String then Array(JSON.parse(reloptions))
396
+ else []
403
397
  end
404
- raise "Found invalid index definition `#{indexdef}`" if depth != 0
405
-
406
- indexdef[start..pos - 2]
398
+ rescue JSON::ParserError
399
+ []
407
400
  end
408
401
 
409
- def extract_bm25_with_components(indexdef)
410
- match = indexdef.match(/WITH\s*\(/im)
402
+ def extract_paradedb_fields_sql(indexdef)
403
+ match = indexdef.match(/USING\s+(?:paradedb|bm25)\s*\(/im)
404
+
411
405
  start = match.end(0)
412
406
  depth = 1
413
407
  pos = start
@@ -420,27 +414,23 @@ module ParadeDB
420
414
  end
421
415
  raise "Found invalid index definition `#{indexdef}`" if depth != 0
422
416
 
423
- with_sql = indexdef[start..pos - 2]
424
- trailing_sql = indexdef[pos..]&.strip
425
- trailing_sql = nil if trailing_sql == ""
426
-
427
- [with_sql, trailing_sql]
417
+ indexdef[start..pos - 2]
428
418
  end
429
419
 
430
- def normalize_bm25_where_clause(where)
420
+ def normalize_paradedb_where_clause(where)
431
421
  return nil if where.nil?
432
422
 
433
423
  normalized = where.to_s.strip
434
424
  return nil if normalized.empty?
435
425
 
436
- while bm25_wrapped_in_parentheses?(normalized)
426
+ while paradedb_wrapped_in_parentheses?(normalized)
437
427
  normalized = normalized[1...-1].strip
438
428
  end
439
429
 
440
430
  normalized.empty? ? nil : normalized
441
431
  end
442
432
 
443
- def bm25_wrapped_in_parentheses?(sql)
433
+ def paradedb_wrapped_in_parentheses?(sql)
444
434
  return false unless sql.start_with?("(") && sql.end_with?(")")
445
435
 
446
436
  depth = 0
@@ -456,7 +446,7 @@ module ParadeDB
456
446
  depth.zero?
457
447
  end
458
448
 
459
- def split_bm25_top_level(str)
449
+ def split_paradedb_top_level(str)
460
450
  parts = []
461
451
  current = +""
462
452
  depth = 0
@@ -479,16 +469,28 @@ module ParadeDB
479
469
  parts
480
470
  end
481
471
 
482
- def bm25_parse_column_entry(field_sql)
472
+ def paradedb_parse_column_entry(field_sql)
483
473
  stripped = field_sql.strip
484
474
 
475
+ opclass_match = stripped.match(/\A(.+?)\s+(vector_(?:l2|cosine|ip)_ops)\z/m)
476
+ if opclass_match
477
+ source_sql = opclass_match[1].strip
478
+ source_name = identifier_sql?(source_sql) ? unquote_identifier(source_sql) : source_sql
479
+ return {
480
+ source: source_name,
481
+ tokenizer: nil,
482
+ options: {},
483
+ metric: ParadeDB::Vector::METRICS_BY_OPCLASS.fetch(opclass_match[2])
484
+ }
485
+ end
486
+
485
487
  if stripped.start_with?("(") && stripped.end_with?(")")
486
488
  inner = stripped[1..-2].strip
487
489
 
488
490
  if (source_sql, tok_sql = split_tokenized_cast(inner))
489
491
  normalized = unwrap_surrounding_groupings(source_sql)
490
492
  source_name = identifier_sql?(normalized) ? unquote_identifier(normalized) : normalized
491
- bm25_parse_tokenizer(tok_sql).merge(source: source_name)
493
+ paradedb_parse_tokenizer(tok_sql).merge(source: source_name)
492
494
  else
493
495
  { source: inner, tokenizer: nil, options: {} }
494
496
  end
@@ -638,7 +640,7 @@ module ParadeDB
638
640
  sql.match?(/\A[a-zA-Z_][a-zA-Z0-9_]*\z/) || sql.match?(/\A"(?:[^"]|"")+"\z/)
639
641
  end
640
642
 
641
- def bm25_parse_tokenizer(tokenizer_sql_str)
643
+ def paradedb_parse_tokenizer(tokenizer_sql_str)
642
644
  match = tokenizer_sql_str.strip.match(/\A([a-zA-Z_][a-zA-Z0-9_]*(?:(?:::|\.)[a-zA-Z_][a-zA-Z0-9_]*)*)(?:\((.*)\))?\z/m)
643
645
  return { tokenizer: tokenizer_sql_str, options: {} } unless match
644
646
 
@@ -677,11 +679,11 @@ module ParadeDB
677
679
  { tokenizer: normalized_name, options: options }
678
680
  end
679
681
 
680
- def bm25_tokenizer_config_ruby(entry)
681
- "{ tokenizer: #{bm25_tokenizer_ruby_from_entry(entry)} }"
682
+ def paradedb_tokenizer_config_ruby(entry)
683
+ "{ tokenizer: #{paradedb_tokenizer_ruby_from_entry(entry)} }"
682
684
  end
683
685
 
684
- def bm25_tokenizer_ruby_from_entry(entry)
686
+ def paradedb_tokenizer_ruby_from_entry(entry)
685
687
  opts = entry[:options].dup
686
688
  positional_args = Array(opts.delete(:__positional))
687
689
  alias_val = opts.delete(:alias)
@@ -691,10 +693,10 @@ module ParadeDB
691
693
 
692
694
  opts[:alias] = alias_val if alias_val
693
695
 
694
- bm25_tokenizer_ruby(entry[:tokenizer], positional_args, opts)
696
+ paradedb_tokenizer_ruby(entry[:tokenizer], positional_args, opts)
695
697
  end
696
698
 
697
- def bm25_tokenizer_ruby(name, positional_args, options)
699
+ def paradedb_tokenizer_ruby(name, positional_args, options)
698
700
  if name.match?(/\A[a-z_][a-z0-9_]*\z/) && ParadeDB::Tokenizer.respond_to?(name)
699
701
  args = positional_args.map { |arg| ruby_literal(arg) }
700
702
  args << "options: #{ruby_hash_literal(options)}" unless options.empty?
@@ -866,8 +868,8 @@ if defined?(ActiveRecord::Migration)
866
868
  connection.replace_paradedb_index(index_klass)
867
869
  end
868
870
 
869
- def add_bm25_index(table, fields:, key_field:, name: nil, index_options: nil, where: nil, if_not_exists: false, concurrently: false)
870
- connection.add_bm25_index(
871
+ def add_paradedb_index(table, fields:, key_field:, name: nil, index_options: nil, where: nil, if_not_exists: false, concurrently: false)
872
+ connection.add_paradedb_index(
871
873
  table,
872
874
  fields: fields,
873
875
  key_field: key_field,
@@ -879,12 +881,12 @@ if defined?(ActiveRecord::Migration)
879
881
  )
880
882
  end
881
883
 
882
- def remove_bm25_index(table, name: nil, if_exists: false)
883
- connection.remove_bm25_index(table, name: name, if_exists: if_exists)
884
+ def remove_paradedb_index(table, name: nil, if_exists: false)
885
+ connection.remove_paradedb_index(table, name: name, if_exists: if_exists)
884
886
  end
885
887
 
886
- def reindex_bm25(table, name: nil, concurrently: false)
887
- connection.reindex_bm25(table, name: name, concurrently: concurrently)
888
+ def reindex_paradedb_index(table, name: nil, concurrently: false)
889
+ connection.reindex_paradedb_index(table, name: name, concurrently: concurrently)
888
890
  end
889
891
  end
890
892
  end
@@ -905,11 +907,11 @@ if defined?(ActiveRecord::SchemaDumper)
905
907
  def indexes_in_create(table, stream)
906
908
  conn = paradedb_connection
907
909
  if conn
908
- bm25_names = conn.paradedb_bm25_index_names
910
+ paradedb_names = conn.paradedb_index_names
909
911
  original_indexes = conn.method(:indexes)
910
912
 
911
913
  conn.define_singleton_method(:indexes) do |tbl|
912
- original_indexes.call(tbl).reject { |idx| bm25_names.include?(idx.name) }
914
+ original_indexes.call(tbl).reject { |idx| paradedb_names.include?(idx.name) }
913
915
  end
914
916
 
915
917
  begin
@@ -940,10 +942,10 @@ if defined?(ActiveRecord::Migration::CommandRecorder)
940
942
  module CommandRecorderPatch
941
943
  %i[
942
944
  create_paradedb_index
943
- add_bm25_index
944
- remove_bm25_index
945
+ add_paradedb_index
946
+ remove_paradedb_index
945
947
  replace_paradedb_index
946
- reindex_bm25
948
+ reindex_paradedb_index
947
949
  ].each do |method_name|
948
950
  define_method(method_name) do |*args, &block|
949
951
  record(method_name, args, &block)
@@ -958,10 +960,10 @@ if defined?(ActiveRecord::Migration::CommandRecorder)
958
960
  compiled = resolve_paradedb_index_klass(index_klass).compiled_definition
959
961
  remove_options = Hash.ruby2_keywords_hash(name: compiled.index_name, if_exists: true)
960
962
 
961
- [:remove_bm25_index, [compiled.table_name, remove_options]]
963
+ [:remove_paradedb_index, [compiled.table_name, remove_options]]
962
964
  end
963
965
 
964
- def invert_add_bm25_index(args)
966
+ def invert_add_paradedb_index(args)
965
967
  table, options = args
966
968
  options = symbolize_options_hash(options)
967
969
 
@@ -969,12 +971,12 @@ if defined?(ActiveRecord::Migration::CommandRecorder)
969
971
  remove_options[:name] = options[:name] if options[:name]
970
972
  remove_options = Hash.ruby2_keywords_hash(remove_options)
971
973
 
972
- [:remove_bm25_index, [table, remove_options]]
974
+ [:remove_paradedb_index, [table, remove_options]]
973
975
  end
974
976
 
975
- def invert_remove_bm25_index(_args)
977
+ def invert_remove_paradedb_index(_args)
976
978
  raise ActiveRecord::IrreversibleMigration,
977
- "remove_bm25_index is not automatically reversible. Use #up/#down or #reversible."
979
+ "remove_paradedb_index is not automatically reversible. Use #up/#down or #reversible."
978
980
  end
979
981
 
980
982
  def invert_replace_paradedb_index(_args)
@@ -982,9 +984,9 @@ if defined?(ActiveRecord::Migration::CommandRecorder)
982
984
  "replace_paradedb_index is not automatically reversible. Use #up/#down or #reversible."
983
985
  end
984
986
 
985
- def invert_reindex_bm25(_args)
987
+ def invert_reindex_paradedb_index(_args)
986
988
  raise ActiveRecord::IrreversibleMigration,
987
- "reindex_bm25 is not automatically reversible. Use #up/#down or #reversible."
989
+ "reindex_paradedb_index is not automatically reversible. Use #up/#down or #reversible."
988
990
  end
989
991
 
990
992
  def resolve_paradedb_index_klass(index_klass)
@@ -10,6 +10,7 @@ module ParadeDB
10
10
  INJECTED_CLASS_METHODS = [
11
11
  :paradedb_search,
12
12
  :more_like_this,
13
+ :nearest,
13
14
  :with_facets,
14
15
  :facets,
15
16
  :with_agg,
@@ -67,6 +68,12 @@ module ParadeDB
67
68
  all.extending(SearchMethods).more_like_this(key, fields: fields, **options)
68
69
  end
69
70
 
71
+ def nearest(column, vector, metric: nil)
72
+ ensure_postgres!
73
+ paradedb_validate_index!
74
+ all.extending(SearchMethods).nearest(column, vector, metric: metric)
75
+ end
76
+
70
77
  def with_facets(*fields, **opts)
71
78
  ensure_postgres!
72
79
  paradedb_validate_index!
@@ -158,7 +165,7 @@ module ParadeDB
158
165
  next if paradedb_catalog_index_valid?(definition)
159
166
 
160
167
  all_valid = false
161
- message = "ParadeDB index drift detected for #{name}: expected #{definition.index_name} on #{definition.table_name} with bm25."
168
+ message = "ParadeDB index drift detected for #{name}: expected #{definition.index_name} on #{definition.table_name} with a ParadeDB access method."
162
169
  case ParadeDB.index_validation_mode
163
170
  when :warn
164
171
  paradedb_log_warn(message)
@@ -263,7 +270,7 @@ module ParadeDB
263
270
  WHERE c.relname = #{connection.quote(definition.index_name.to_s)}
264
271
  AND t.relname = #{connection.quote(definition.table_name.to_s)}
265
272
  AND n.nspname = current_schema()
266
- AND am.amname = 'bm25'
273
+ AND am.amname IN ('paradedb', 'bm25')
267
274
  LIMIT 1
268
275
  SQL
269
276
 
@@ -479,6 +479,19 @@ module ParadeDB
479
479
  rel.except(:select, :group).select(*group_nodes, *aggregate_nodes).group(*group_nodes)
480
480
  end
481
481
 
482
+ # Orders by vector distance for Top-K pushdown inside the ParadeDB index.
483
+ # Adds `key_field @@@ pdb.all()` when the relation has no ParadeDB predicate,
484
+ # since vector ordering requires a @@@ predicate to activate the index scan.
485
+ # Callers must add `.limit(k)`; the metric defaults to the index opclass metric.
486
+ def nearest(column, vector, metric: nil)
487
+ ensure_paradedb_runtime!
488
+ resolved_metric = metric || index_vector_metric(column) || ParadeDB::Vector::DEFAULT_METRIC
489
+ node = builder.vector_distance(column, vector, metric: resolved_metric)
490
+
491
+ rel = has_paradedb_predicate? ? self : ensure_paradedb_predicate
492
+ rel.order(node.asc)
493
+ end
494
+
482
495
  def has_paradedb_predicate?
483
496
  PredicateInspector.relation_has_paradedb_predicate?(self)
484
497
  end
@@ -490,6 +503,12 @@ module ParadeDB
490
503
 
491
504
  private
492
505
 
506
+ def index_vector_metric(column)
507
+ return nil unless klass.respond_to?(:paradedb_index_entry, true)
508
+
509
+ klass.send(:paradedb_index_entry, column)&.metric
510
+ end
511
+
493
512
  def paradedb_runtime_key_field
494
513
  return primary_key unless klass.respond_to?(:paradedb_key_field)
495
514
 
@@ -0,0 +1,119 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_model"
4
+
5
+ module ParadeDB
6
+ class Vector < ActiveModel::Type::Value
7
+ METRICS = %i[l2 cosine ip].freeze
8
+ METRIC_ALIASES = { inner_product: :ip }.freeze
9
+ OPCLASSES = { l2: "vector_l2_ops", cosine: "vector_cosine_ops", ip: "vector_ip_ops" }.freeze
10
+ METRICS_BY_OPCLASS = OPCLASSES.invert.freeze
11
+ DISTANCE_OPERATORS = { l2: "<->", cosine: "<=>", ip: "<#>" }.freeze
12
+ DEFAULT_METRIC = :l2
13
+
14
+ def self.normalize_metric(metric)
15
+ normalized = metric.to_sym
16
+ normalized = METRIC_ALIASES.fetch(normalized, normalized)
17
+ return normalized if METRICS.include?(normalized)
18
+
19
+ raise ArgumentError,
20
+ "unknown vector metric #{metric.inspect}. Valid metrics: #{(METRICS + METRIC_ALIASES.keys).map(&:inspect).join(', ')}"
21
+ end
22
+
23
+ def self.literal(value)
24
+ "[#{Array(value).map { |v| Float(v) }.join(',')}]"
25
+ end
26
+
27
+ attr_reader :limit
28
+
29
+ def initialize(limit: nil)
30
+ super()
31
+ @limit = limit
32
+ end
33
+
34
+ def type
35
+ :vector
36
+ end
37
+
38
+ def cast(value)
39
+ case value
40
+ when nil
41
+ nil
42
+ when Array
43
+ validate_dimensions!(value.map { |v| Float(v) })
44
+ when String
45
+ cast_string(value)
46
+ else
47
+ raise ArgumentError, "cannot cast #{value.class} to vector"
48
+ end
49
+ end
50
+
51
+ def serialize(value)
52
+ return nil if value.nil?
53
+
54
+ self.class.literal(cast(value))
55
+ end
56
+
57
+ def deserialize(value)
58
+ cast(value)
59
+ end
60
+
61
+ def changed_in_place?(raw_old_value, new_value)
62
+ deserialize(raw_old_value) != new_value
63
+ end
64
+
65
+ def self.register_with_type_map(m)
66
+ m.register_type "vector" do |_, _, sql_type|
67
+ limit = sql_type.to_s[/\((\d+)\)/, 1]
68
+ ParadeDB::Vector.new(limit: limit&.to_i)
69
+ end
70
+ end
71
+
72
+ module PostgreSQLAdapterPatch
73
+ def initialize_type_map(m)
74
+ super
75
+ ParadeDB::Vector.register_with_type_map(m)
76
+ end
77
+ end
78
+
79
+ def self.install_postgresql_adapter!(adapter_class)
80
+ adapter_class::NATIVE_DATABASE_TYPES[:vector] = { name: "vector" }
81
+
82
+ unless adapter_class.singleton_class.ancestors.include?(PostgreSQLAdapterPatch)
83
+ adapter_class.singleton_class.prepend(PostgreSQLAdapterPatch)
84
+ end
85
+
86
+ register_with_type_map(adapter_class::TYPE_MAP) if adapter_class.const_defined?(:TYPE_MAP)
87
+
88
+ table_definition = ActiveRecord::ConnectionAdapters::PostgreSQL::TableDefinition
89
+ unless table_definition.method_defined?(:vector)
90
+ table_definition.send(:define_column_methods, :vector)
91
+ end
92
+ end
93
+
94
+ private
95
+
96
+ def cast_string(value)
97
+ stripped = value.strip
98
+ unless stripped.start_with?("[") && stripped.end_with?("]")
99
+ raise ArgumentError, "malformed vector literal: #{value.inspect}"
100
+ end
101
+
102
+ inner = stripped[1..-2].strip
103
+ values = inner.empty? ? [] : inner.split(",").map { |v| Float(v) }
104
+ validate_dimensions!(values)
105
+ end
106
+
107
+ def validate_dimensions!(values)
108
+ if limit && values.length != limit
109
+ raise ArgumentError, "expected #{limit} dimensions, got #{values.length}"
110
+ end
111
+
112
+ values
113
+ end
114
+ end
115
+ end
116
+
117
+ ActiveSupport.on_load(:active_record_postgresqladapter) do
118
+ ParadeDB::Vector.install_postgresql_adapter!(self)
119
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module ParadeDB
4
- VERSION = "0.9.0"
4
+ VERSION = "0.11.0"
5
5
  end
data/lib/parade_db.rb CHANGED
@@ -3,6 +3,7 @@
3
3
  require_relative "parade_db/version"
4
4
  require_relative "parade_db/errors"
5
5
  require_relative "parade_db/arel"
6
+ require_relative "parade_db/vector"
6
7
  require_relative "parade_db/index"
7
8
  require_relative "parade_db/aggregations"
8
9
  require_relative "parade_db/proximity"
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: rails-paradedb
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.9.0
4
+ version: 0.11.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - ParadeDB
@@ -115,6 +115,7 @@ files:
115
115
  - lib/parade_db/search_methods.rb
116
116
  - lib/parade_db/tokenizer.rb
117
117
  - lib/parade_db/tokenizer_sql.rb
118
+ - lib/parade_db/vector.rb
118
119
  - lib/parade_db/version.rb
119
120
  homepage: https://github.com/paradedb/rails-paradedb
120
121
  licenses: