rails-paradedb 0.9.0 → 0.10.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: 296e696bfa9711537e984df359c43be2481b53a91f75c880dc33ac36aacf4b1c
4
+ data.tar.gz: 78781d5fc912c5b57053818d7f3e76e2d01251fb7d31a586edd5d28b88426e69
5
5
  SHA512:
6
- metadata.gz: f3fb68ca97ffc5a30f6d503f15e97b02bc8db1de5aaeab020991848f777bc401fc62d06049490790ef75416b80c6a226edeb8cfe91e2bd8d8c3d76d12e95accb
7
- data.tar.gz: 6c1e58314734c06d138a32f8a6928d13c7178e7db44e8460a45efbfb128e168d81f22dac53f74bc57ccd19f88f4ad0fdf60c3590dfcfd8d04e39b80d39faf531
6
+ metadata.gz: 5b4fecd2d8620a91e782ea62df980ab6638fec2906ad5a380664e97b77a8d0e1b4b656aa330cc4b9e80a99e18f2223cd2d50da6cea98245c63c1c4fe88ca7450
7
+ data.tar.gz: a6c42abf6b75aad3ee2d3b58651cfde545ba86deef7e33df0593cafc94da529818dbe41f290286e0acc3eb9bc62071d4d2b06d7976cb8c44d5a94be7c9a37722
data/CHANGELOG.md CHANGED
@@ -4,6 +4,22 @@ All notable changes to this project will be documented in this file. The format
4
4
 
5
5
  ## [Unreleased]
6
6
 
7
+ [0.10.0] - 2026-08-04
8
+
9
+ ### Added
10
+
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 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.
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`, and `examples/rag` retrieval combines full-text search with `nearest`.
16
+
17
+ ### Changed
18
+
19
+ - **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.
20
+ - **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.
21
+ - **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`.
22
+
7
23
  [0.9.0] - 2026-07-14
8
24
 
9
25
  ### Added
@@ -154,7 +170,8 @@ All notable changes to this project will be documented in this file. The format
154
170
  - Schema dump/load round-trip for tokenizer configuration and index options
155
171
  (including `target_segment_count`)
156
172
 
157
- [Unreleased]: https://github.com/paradedb/rails-paradedb/compare/v0.9.0...HEAD
173
+ [Unreleased]: https://github.com/paradedb/rails-paradedb/compare/v0.10.0...HEAD
174
+ [0.10.0]: https://github.com/paradedb/rails-paradedb/releases/tag/v0.10.0
158
175
  [0.9.0]: https://github.com/paradedb/rails-paradedb/releases/tag/v0.9.0
159
176
  [0.8.0]: https://github.com/paradedb/rails-paradedb/releases/tag/v0.8.0
160
177
  [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
@@ -96,7 +97,7 @@ module ParadeDB
96
97
  @where = where
97
98
  end
98
99
  end
99
- Entry = Struct.new(:source, :expression, :tokenizer, :options, :query_key, keyword_init: true)
100
+ Entry = Struct.new(:source, :expression, :tokenizer, :options, :query_key, :metric, keyword_init: true)
100
101
 
101
102
  class << self
102
103
  def compile!(klass)
@@ -153,7 +154,7 @@ module ParadeDB
153
154
  end
154
155
  normalized = (config || {}).each_with_object({}) { |(k, v), memo| memo[k.to_sym] = v }
155
156
 
156
- unknown_keys = normalized.keys - (TokenizerParser::TOKENIZER_SINGLE_KEYS + [:tokenizers] + FIELD_OPTION_KEYS)
157
+ unknown_keys = normalized.keys - (TokenizerParser::TOKENIZER_SINGLE_KEYS + %i[tokenizers metric] + FIELD_OPTION_KEYS)
157
158
  unless unknown_keys.empty?
158
159
  raise InvalidIndexDefinition,
159
160
  "unknown field config keys for #{source_name.inspect}: #{unknown_keys.map(&:inspect).join(', ')}"
@@ -163,7 +164,27 @@ module ParadeDB
163
164
  single_tokenizer_keys_present = TokenizerParser::TOKENIZER_SINGLE_KEYS.any? { |key| normalized.key?(key) }
164
165
 
165
166
  is_alias = normalized[:alias] && normalized.length == 1
166
- if is_alias
167
+ if normalized.key?(:metric)
168
+ unless normalized.length == 1
169
+ raise InvalidIndexDefinition,
170
+ "field #{source_name.inspect} cannot mix :metric with other field config keys"
171
+ end
172
+
173
+ begin
174
+ metric = ParadeDB::Vector.normalize_metric(normalized[:metric])
175
+ rescue ArgumentError => e
176
+ raise InvalidIndexDefinition, "field #{source_name.inspect}: #{e.message}"
177
+ end
178
+
179
+ entries << Entry.new(
180
+ source: source_name,
181
+ expression: expression?(source_name),
182
+ tokenizer: nil,
183
+ options: {},
184
+ query_key: source_name,
185
+ metric: metric
186
+ )
187
+ elsif is_alias
167
188
  entries << Entry.new(
168
189
  source: source_name,
169
190
  expression: expression?(source_name),
@@ -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,18 +84,18 @@ 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
 
@@ -108,14 +108,14 @@ module ParadeDB
108
108
  end
109
109
  end
110
110
 
111
- bm25_field_option_groups(compiled).each do |param_name, value_hash|
111
+ paradedb_field_option_groups(compiled).each do |param_name, value_hash|
112
112
  options << "#{param_name}=#{quote(JSON.generate(value_hash))}"
113
113
  end
114
114
 
115
115
  options.join(", ")
116
116
  end
117
117
 
118
- def bm25_field_option_groups(compiled)
118
+ def paradedb_field_option_groups(compiled)
119
119
  field_options = compiled.field_options || {}
120
120
  return {} if field_options.empty?
121
121
 
@@ -128,7 +128,7 @@ module ParadeDB
128
128
  column = columns_by_name[source.to_s]
129
129
  next unless column
130
130
 
131
- param_name = bm25_field_option_param_for_column(column)
131
+ param_name = paradedb_field_option_param_for_column(column)
132
132
  next if param_name.nil?
133
133
 
134
134
  normalized = normalize_field_options_for_param(opts, param_name)
@@ -141,7 +141,7 @@ module ParadeDB
141
141
  grouped
142
142
  end
143
143
 
144
- def bm25_field_option_param_for_column(column)
144
+ def paradedb_field_option_param_for_column(column)
145
145
  sql_type = column.sql_type.to_s.downcase
146
146
  return "range_fields" if sql_type.include?("range")
147
147
 
@@ -182,8 +182,10 @@ module ParadeDB
182
182
  end
183
183
  end
184
184
 
185
- def bm25_entry_sql(entry)
186
- source_sql = bm25_source_sql(entry)
185
+ def paradedb_entry_sql(entry)
186
+ source_sql = paradedb_source_sql(entry)
187
+
188
+ return "#{source_sql} #{ParadeDB::Vector::OPCLASSES.fetch(entry.metric)}" if entry.metric
187
189
 
188
190
  if entry.tokenizer.nil? && entry.query_key != entry.source
189
191
  return "(#{source_sql}::pdb.alias(#{quote(entry.query_key)}))"
@@ -194,7 +196,7 @@ module ParadeDB
194
196
  "(#{source_sql}::#{tokenizer_sql(entry.tokenizer, entry.options)})"
195
197
  end
196
198
 
197
- def bm25_source_sql(entry)
199
+ def paradedb_source_sql(entry)
198
200
  if entry.expression
199
201
  "(#{entry.source})"
200
202
  else
@@ -289,7 +291,7 @@ module ParadeDB
289
291
  false
290
292
  end
291
293
 
292
- def paradedb_bm25_index_rows
294
+ def paradedb_index_rows
293
295
  sql = <<~SQL
294
296
  SELECT
295
297
  c.relname AS index_name,
@@ -302,28 +304,28 @@ module ParadeDB
302
304
  JOIN pg_class t ON t.oid = i.indrelid
303
305
  JOIN pg_am am ON am.oid = c.relam
304
306
  WHERE n.nspname = current_schema()
305
- AND am.amname = 'bm25'
307
+ AND am.amname IN ('paradedb', 'bm25')
306
308
  ORDER BY t.relname, c.relname
307
309
  SQL
308
310
  select_all(sql).to_a
309
311
  rescue => e
310
- Kernel.warn("ParadeDB: unable to query bm25 indexes from catalog: #{e.message}")
312
+ Kernel.warn("ParadeDB: unable to query paradedb indexes from catalog: #{e.message}")
311
313
  []
312
314
  end
313
315
 
314
- def bm25_index_to_ruby(row)
316
+ def paradedb_index_to_ruby(row)
315
317
  indexdef = row["indexdef"]
316
318
  table = row["table_name"]
317
319
  name = row["index_name"]
318
320
 
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"])
321
+ key_field = extract_paradedb_key_field(indexdef)
322
+ index_options = extract_paradedb_index_options(indexdef)
323
+ fields_sql = extract_paradedb_fields_sql(indexdef)
324
+ where = normalize_paradedb_where_clause(row["where_clause"])
323
325
 
324
326
  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) }
327
+ field_sqls = split_paradedb_top_level(fields_sql).map(&:strip)
328
+ parsed = field_sqls.map { |f| paradedb_parse_column_entry(f) }
327
329
 
328
330
  grouped = {}
329
331
  parsed.each { |e| (grouped[e[:source]] ||= []) << e }
@@ -331,17 +333,19 @@ module ParadeDB
331
333
  fields_pairs = grouped.map do |source, entries|
332
334
  source_ruby = source.match?(/[^a-zA-Z0-9_]/) ? "#{source.inspect} =>" : "#{source}:"
333
335
 
334
- if entries.all? { |e| e[:tokenizer].nil? }
336
+ if entries.length == 1 && entries.first[:metric]
337
+ "#{source_ruby} { metric: #{entries.first[:metric].inspect} }"
338
+ elsif entries.all? { |e| e[:tokenizer].nil? }
335
339
  "#{source_ruby} {}"
336
340
  elsif entries.length == 1
337
- "#{source_ruby} #{bm25_tokenizer_config_ruby(entries.first)}"
341
+ "#{source_ruby} #{paradedb_tokenizer_config_ruby(entries.first)}"
338
342
  else
339
- configs = entries.map { |e| bm25_tokenizer_ruby_from_entry(e) }
343
+ configs = entries.map { |e| paradedb_tokenizer_ruby_from_entry(e) }
340
344
  "#{source_ruby} { tokenizers: [#{configs.join(', ')}] }"
341
345
  end
342
346
  end
343
347
 
344
- statement = "add_bm25_index #{table.to_sym.inspect}, " \
348
+ statement = "add_paradedb_index #{table.to_sym.inspect}, " \
345
349
  "fields: { #{fields_pairs.join(', ')} }, " \
346
350
  "key_field: #{key_field.to_sym.inspect}, " \
347
351
  "name: #{name.inspect}"
@@ -355,7 +359,7 @@ module ParadeDB
355
359
  end
356
360
  end
357
361
 
358
- def extract_bm25_key_field(indexdef)
362
+ def extract_paradedb_key_field(indexdef)
359
363
  quoted = indexdef.match(/WITH\s*\([^)]*key_field\s*=\s*'((?:[^']|'')*)'/i)
360
364
  return quoted[1].gsub("''", "'") if quoted
361
365
 
@@ -365,8 +369,8 @@ module ParadeDB
365
369
  nil
366
370
  end
367
371
 
368
- def extract_bm25_index_options(indexdef)
369
- with_sql, = extract_bm25_with_components(indexdef)
372
+ def extract_paradedb_index_options(indexdef)
373
+ with_sql, = extract_paradedb_with_components(indexdef)
370
374
  options = {}
371
375
  split_sql_arguments(with_sql).each do |argument|
372
376
  key, value_sql = split_assignment(argument)
@@ -388,8 +392,8 @@ module ParadeDB
388
392
  {}
389
393
  end
390
394
 
391
- def extract_bm25_fields_sql(indexdef)
392
- match = indexdef.match(/USING\s+bm25\s*\(/im)
395
+ def extract_paradedb_fields_sql(indexdef)
396
+ match = indexdef.match(/USING\s+(?:paradedb|bm25)\s*\(/im)
393
397
 
394
398
  start = match.end(0)
395
399
  depth = 1
@@ -406,7 +410,7 @@ module ParadeDB
406
410
  indexdef[start..pos - 2]
407
411
  end
408
412
 
409
- def extract_bm25_with_components(indexdef)
413
+ def extract_paradedb_with_components(indexdef)
410
414
  match = indexdef.match(/WITH\s*\(/im)
411
415
  start = match.end(0)
412
416
  depth = 1
@@ -427,20 +431,20 @@ module ParadeDB
427
431
  [with_sql, trailing_sql]
428
432
  end
429
433
 
430
- def normalize_bm25_where_clause(where)
434
+ def normalize_paradedb_where_clause(where)
431
435
  return nil if where.nil?
432
436
 
433
437
  normalized = where.to_s.strip
434
438
  return nil if normalized.empty?
435
439
 
436
- while bm25_wrapped_in_parentheses?(normalized)
440
+ while paradedb_wrapped_in_parentheses?(normalized)
437
441
  normalized = normalized[1...-1].strip
438
442
  end
439
443
 
440
444
  normalized.empty? ? nil : normalized
441
445
  end
442
446
 
443
- def bm25_wrapped_in_parentheses?(sql)
447
+ def paradedb_wrapped_in_parentheses?(sql)
444
448
  return false unless sql.start_with?("(") && sql.end_with?(")")
445
449
 
446
450
  depth = 0
@@ -456,7 +460,7 @@ module ParadeDB
456
460
  depth.zero?
457
461
  end
458
462
 
459
- def split_bm25_top_level(str)
463
+ def split_paradedb_top_level(str)
460
464
  parts = []
461
465
  current = +""
462
466
  depth = 0
@@ -479,16 +483,28 @@ module ParadeDB
479
483
  parts
480
484
  end
481
485
 
482
- def bm25_parse_column_entry(field_sql)
486
+ def paradedb_parse_column_entry(field_sql)
483
487
  stripped = field_sql.strip
484
488
 
489
+ opclass_match = stripped.match(/\A(.+?)\s+(vector_(?:l2|cosine|ip)_ops)\z/m)
490
+ if opclass_match
491
+ source_sql = opclass_match[1].strip
492
+ source_name = identifier_sql?(source_sql) ? unquote_identifier(source_sql) : source_sql
493
+ return {
494
+ source: source_name,
495
+ tokenizer: nil,
496
+ options: {},
497
+ metric: ParadeDB::Vector::METRICS_BY_OPCLASS.fetch(opclass_match[2])
498
+ }
499
+ end
500
+
485
501
  if stripped.start_with?("(") && stripped.end_with?(")")
486
502
  inner = stripped[1..-2].strip
487
503
 
488
504
  if (source_sql, tok_sql = split_tokenized_cast(inner))
489
505
  normalized = unwrap_surrounding_groupings(source_sql)
490
506
  source_name = identifier_sql?(normalized) ? unquote_identifier(normalized) : normalized
491
- bm25_parse_tokenizer(tok_sql).merge(source: source_name)
507
+ paradedb_parse_tokenizer(tok_sql).merge(source: source_name)
492
508
  else
493
509
  { source: inner, tokenizer: nil, options: {} }
494
510
  end
@@ -638,7 +654,7 @@ module ParadeDB
638
654
  sql.match?(/\A[a-zA-Z_][a-zA-Z0-9_]*\z/) || sql.match?(/\A"(?:[^"]|"")+"\z/)
639
655
  end
640
656
 
641
- def bm25_parse_tokenizer(tokenizer_sql_str)
657
+ def paradedb_parse_tokenizer(tokenizer_sql_str)
642
658
  match = tokenizer_sql_str.strip.match(/\A([a-zA-Z_][a-zA-Z0-9_]*(?:(?:::|\.)[a-zA-Z_][a-zA-Z0-9_]*)*)(?:\((.*)\))?\z/m)
643
659
  return { tokenizer: tokenizer_sql_str, options: {} } unless match
644
660
 
@@ -677,11 +693,11 @@ module ParadeDB
677
693
  { tokenizer: normalized_name, options: options }
678
694
  end
679
695
 
680
- def bm25_tokenizer_config_ruby(entry)
681
- "{ tokenizer: #{bm25_tokenizer_ruby_from_entry(entry)} }"
696
+ def paradedb_tokenizer_config_ruby(entry)
697
+ "{ tokenizer: #{paradedb_tokenizer_ruby_from_entry(entry)} }"
682
698
  end
683
699
 
684
- def bm25_tokenizer_ruby_from_entry(entry)
700
+ def paradedb_tokenizer_ruby_from_entry(entry)
685
701
  opts = entry[:options].dup
686
702
  positional_args = Array(opts.delete(:__positional))
687
703
  alias_val = opts.delete(:alias)
@@ -691,10 +707,10 @@ module ParadeDB
691
707
 
692
708
  opts[:alias] = alias_val if alias_val
693
709
 
694
- bm25_tokenizer_ruby(entry[:tokenizer], positional_args, opts)
710
+ paradedb_tokenizer_ruby(entry[:tokenizer], positional_args, opts)
695
711
  end
696
712
 
697
- def bm25_tokenizer_ruby(name, positional_args, options)
713
+ def paradedb_tokenizer_ruby(name, positional_args, options)
698
714
  if name.match?(/\A[a-z_][a-z0-9_]*\z/) && ParadeDB::Tokenizer.respond_to?(name)
699
715
  args = positional_args.map { |arg| ruby_literal(arg) }
700
716
  args << "options: #{ruby_hash_literal(options)}" unless options.empty?
@@ -866,8 +882,8 @@ if defined?(ActiveRecord::Migration)
866
882
  connection.replace_paradedb_index(index_klass)
867
883
  end
868
884
 
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(
885
+ def add_paradedb_index(table, fields:, key_field:, name: nil, index_options: nil, where: nil, if_not_exists: false, concurrently: false)
886
+ connection.add_paradedb_index(
871
887
  table,
872
888
  fields: fields,
873
889
  key_field: key_field,
@@ -879,12 +895,12 @@ if defined?(ActiveRecord::Migration)
879
895
  )
880
896
  end
881
897
 
882
- def remove_bm25_index(table, name: nil, if_exists: false)
883
- connection.remove_bm25_index(table, name: name, if_exists: if_exists)
898
+ def remove_paradedb_index(table, name: nil, if_exists: false)
899
+ connection.remove_paradedb_index(table, name: name, if_exists: if_exists)
884
900
  end
885
901
 
886
- def reindex_bm25(table, name: nil, concurrently: false)
887
- connection.reindex_bm25(table, name: name, concurrently: concurrently)
902
+ def reindex_paradedb_index(table, name: nil, concurrently: false)
903
+ connection.reindex_paradedb_index(table, name: name, concurrently: concurrently)
888
904
  end
889
905
  end
890
906
  end
@@ -905,11 +921,11 @@ if defined?(ActiveRecord::SchemaDumper)
905
921
  def indexes_in_create(table, stream)
906
922
  conn = paradedb_connection
907
923
  if conn
908
- bm25_names = conn.paradedb_bm25_index_names
924
+ paradedb_names = conn.paradedb_index_names
909
925
  original_indexes = conn.method(:indexes)
910
926
 
911
927
  conn.define_singleton_method(:indexes) do |tbl|
912
- original_indexes.call(tbl).reject { |idx| bm25_names.include?(idx.name) }
928
+ original_indexes.call(tbl).reject { |idx| paradedb_names.include?(idx.name) }
913
929
  end
914
930
 
915
931
  begin
@@ -940,10 +956,10 @@ if defined?(ActiveRecord::Migration::CommandRecorder)
940
956
  module CommandRecorderPatch
941
957
  %i[
942
958
  create_paradedb_index
943
- add_bm25_index
944
- remove_bm25_index
959
+ add_paradedb_index
960
+ remove_paradedb_index
945
961
  replace_paradedb_index
946
- reindex_bm25
962
+ reindex_paradedb_index
947
963
  ].each do |method_name|
948
964
  define_method(method_name) do |*args, &block|
949
965
  record(method_name, args, &block)
@@ -958,10 +974,10 @@ if defined?(ActiveRecord::Migration::CommandRecorder)
958
974
  compiled = resolve_paradedb_index_klass(index_klass).compiled_definition
959
975
  remove_options = Hash.ruby2_keywords_hash(name: compiled.index_name, if_exists: true)
960
976
 
961
- [:remove_bm25_index, [compiled.table_name, remove_options]]
977
+ [:remove_paradedb_index, [compiled.table_name, remove_options]]
962
978
  end
963
979
 
964
- def invert_add_bm25_index(args)
980
+ def invert_add_paradedb_index(args)
965
981
  table, options = args
966
982
  options = symbolize_options_hash(options)
967
983
 
@@ -969,12 +985,12 @@ if defined?(ActiveRecord::Migration::CommandRecorder)
969
985
  remove_options[:name] = options[:name] if options[:name]
970
986
  remove_options = Hash.ruby2_keywords_hash(remove_options)
971
987
 
972
- [:remove_bm25_index, [table, remove_options]]
988
+ [:remove_paradedb_index, [table, remove_options]]
973
989
  end
974
990
 
975
- def invert_remove_bm25_index(_args)
991
+ def invert_remove_paradedb_index(_args)
976
992
  raise ActiveRecord::IrreversibleMigration,
977
- "remove_bm25_index is not automatically reversible. Use #up/#down or #reversible."
993
+ "remove_paradedb_index is not automatically reversible. Use #up/#down or #reversible."
978
994
  end
979
995
 
980
996
  def invert_replace_paradedb_index(_args)
@@ -982,9 +998,9 @@ if defined?(ActiveRecord::Migration::CommandRecorder)
982
998
  "replace_paradedb_index is not automatically reversible. Use #up/#down or #reversible."
983
999
  end
984
1000
 
985
- def invert_reindex_bm25(_args)
1001
+ def invert_reindex_paradedb_index(_args)
986
1002
  raise ActiveRecord::IrreversibleMigration,
987
- "reindex_bm25 is not automatically reversible. Use #up/#down or #reversible."
1003
+ "reindex_paradedb_index is not automatically reversible. Use #up/#down or #reversible."
988
1004
  end
989
1005
 
990
1006
  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.10.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.10.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: