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 +4 -4
- data/CHANGELOG.md +23 -2
- data/README.md +13 -7
- data/lib/generators/parade_db/index/index_generator.rb +4 -4
- data/lib/generators/parade_db/index/templates/migration.rb.tt +1 -1
- data/lib/parade_db/arel/builder.rb +25 -0
- data/lib/parade_db/arel/predications.rb +16 -0
- data/lib/parade_db/index.rb +42 -9
- data/lib/parade_db/migration_helpers.rb +111 -109
- data/lib/parade_db/model.rb +9 -2
- data/lib/parade_db/search_methods.rb +19 -0
- data/lib/parade_db/vector.rb +119 -0
- data/lib/parade_db/version.rb +1 -1
- data/lib/parade_db.rb +1 -0
- metadata +2 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 61970bcefe75f0514836a81ab38dd8e39fe1c6f311d55f35366120d2285325b9
|
|
4
|
+
data.tar.gz: 6179c3b6d0814dcaae1179977548c4b47e6f6447110596293f242d668727d79a
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
-
## [
|
|
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
|
-
[
|
|
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
|
|
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.
|
|
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
|
|
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}
|
|
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}
|
|
35
|
+
"#{table_name}_search_idx"
|
|
36
36
|
end
|
|
37
37
|
|
|
38
38
|
def migration_class_name
|
|
39
|
-
"Create#{class_name}
|
|
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
|
-
|
|
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
|
data/lib/parade_db/index.rb
CHANGED
|
@@ -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}
|
|
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 + [
|
|
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
|
|
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 -
|
|
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
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
41
|
+
def remove_paradedb_index(table, name: nil, if_exists: false)
|
|
42
42
|
ensure_postgresql_adapter!
|
|
43
|
-
index_name = (name || "#{table}
|
|
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
|
|
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, "
|
|
51
|
+
raise ArgumentError, "reindex_paradedb_index concurrently: true cannot run inside a transaction"
|
|
52
52
|
end
|
|
53
53
|
|
|
54
|
-
index_name = (name || "#{table}
|
|
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 =
|
|
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 =
|
|
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
|
|
75
|
-
|
|
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|
|
|
88
|
-
with_options_sql =
|
|
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
|
|
93
|
+
USING paradedb (#{fields_sql})
|
|
94
94
|
WITH (#{with_options_sql})#{where_sql}
|
|
95
95
|
SQL
|
|
96
96
|
end
|
|
97
97
|
|
|
98
|
-
def
|
|
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
|
-
|
|
104
|
-
|
|
105
|
-
|
|
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
|
-
|
|
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
|
|
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 =
|
|
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
|
|
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
|
|
186
|
-
source_sql =
|
|
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
|
|
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
|
|
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
|
|
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
|
|
316
|
+
Kernel.warn("ParadeDB: unable to query paradedb indexes from catalog: #{e.message}")
|
|
311
317
|
[]
|
|
312
318
|
end
|
|
313
319
|
|
|
314
|
-
def
|
|
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 =
|
|
320
|
-
index_options =
|
|
321
|
-
fields_sql =
|
|
322
|
-
where =
|
|
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 =
|
|
326
|
-
parsed = field_sqls.map { |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.
|
|
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} #{
|
|
345
|
+
"#{source_ruby} #{paradedb_tokenizer_config_ruby(entries.first)}"
|
|
338
346
|
else
|
|
339
|
-
configs = entries.map { |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 = "
|
|
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
|
|
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
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
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 =
|
|
379
|
-
if parsed
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
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
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
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
|
-
|
|
405
|
-
|
|
406
|
-
indexdef[start..pos - 2]
|
|
398
|
+
rescue JSON::ParserError
|
|
399
|
+
[]
|
|
407
400
|
end
|
|
408
401
|
|
|
409
|
-
def
|
|
410
|
-
match = indexdef.match(/
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
681
|
-
"{ tokenizer: #{
|
|
682
|
+
def paradedb_tokenizer_config_ruby(entry)
|
|
683
|
+
"{ tokenizer: #{paradedb_tokenizer_ruby_from_entry(entry)} }"
|
|
682
684
|
end
|
|
683
685
|
|
|
684
|
-
def
|
|
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
|
-
|
|
696
|
+
paradedb_tokenizer_ruby(entry[:tokenizer], positional_args, opts)
|
|
695
697
|
end
|
|
696
698
|
|
|
697
|
-
def
|
|
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
|
|
870
|
-
connection.
|
|
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
|
|
883
|
-
connection.
|
|
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
|
|
887
|
-
connection.
|
|
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
|
-
|
|
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|
|
|
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
|
-
|
|
944
|
-
|
|
945
|
+
add_paradedb_index
|
|
946
|
+
remove_paradedb_index
|
|
945
947
|
replace_paradedb_index
|
|
946
|
-
|
|
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
|
-
[:
|
|
963
|
+
[:remove_paradedb_index, [compiled.table_name, remove_options]]
|
|
962
964
|
end
|
|
963
965
|
|
|
964
|
-
def
|
|
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
|
-
[:
|
|
974
|
+
[:remove_paradedb_index, [table, remove_options]]
|
|
973
975
|
end
|
|
974
976
|
|
|
975
|
-
def
|
|
977
|
+
def invert_remove_paradedb_index(_args)
|
|
976
978
|
raise ActiveRecord::IrreversibleMigration,
|
|
977
|
-
"
|
|
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
|
|
987
|
+
def invert_reindex_paradedb_index(_args)
|
|
986
988
|
raise ActiveRecord::IrreversibleMigration,
|
|
987
|
-
"
|
|
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)
|
data/lib/parade_db/model.rb
CHANGED
|
@@ -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
|
|
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
|
|
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
|
data/lib/parade_db/version.rb
CHANGED
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.
|
|
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:
|