activerecord-turbopuffer-adapter 0.1.6 → 0.1.8
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/README.md +26 -0
- data/lib/active_record/connection_adapters/turbopuffer_adapter.rb +8 -3
- data/lib/arel/visitors/turbopuffer.rb +24 -11
- data/lib/turbopuffer/active_record/namespace_scope.rb +15 -0
- data/lib/turbopuffer/active_record/schema/attribute.rb +7 -2
- data/lib/turbopuffer/active_record/schema/model.rb +29 -3
- data/lib/turbopuffer/active_record/schema.rb +1 -0
- data/lib/turbopuffer/active_record/version.rb +1 -1
- data/lib/turbopuffer/active_record.rb +14 -0
- metadata +7 -6
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: f7e4579b0235c712aef39dedae94abdb3e8053ef5b04fff2e46ee38c0fd5710a
|
|
4
|
+
data.tar.gz: dadc48af421f105ed95d7964b7929ced1dd613217fdda1b88b00a58c291867cf
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 6037d2898aa914ed3990f238ac00b86c4b710d630fd3aa7c9dd3af9200f768736b9207cf8cedf079c01d60fd8fce614cdafdaa5873241b1c7a47fa1f6bb4b9df
|
|
7
|
+
data.tar.gz: 8b6c7caf5e1b82fbed33da7e5e06e1232cf9603aab035b6a9ef8540223047020429927721928911c80512c778fbdef5a52b1628ca0110ac4414d0419f18a9aed
|
data/README.md
CHANGED
|
@@ -56,6 +56,17 @@ class Document < ApplicationRecord
|
|
|
56
56
|
end
|
|
57
57
|
```
|
|
58
58
|
|
|
59
|
+
Alternatively, turbopuffer can generate embeddings for you. Declare `embed` on a string attribute and the vector is computed on write:
|
|
60
|
+
|
|
61
|
+
```ruby
|
|
62
|
+
class Document < ApplicationRecord
|
|
63
|
+
turbopuffer_attribute "id", "uuid", not_null: 1
|
|
64
|
+
turbopuffer_attribute "body", "string", embed: "openai/text-embedding-3-small"
|
|
65
|
+
end
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
turbopuffer stores the vector in a computed `embed_body` attribute. `embed` also accepts a hash (`model:`, `attribute:`, `dims:`, `dtype:`) to name the vector attribute or set its size. Embedding is billed per token by model, see https://turbopuffer.com/docs/embedding for the supported models.
|
|
69
|
+
|
|
59
70
|
### Creating records
|
|
60
71
|
|
|
61
72
|
```ruby
|
|
@@ -104,6 +115,7 @@ Document.find("018f...")
|
|
|
104
115
|
|
|
105
116
|
Document.rank_by("vector", "ANN", query_vector).limit(10)
|
|
106
117
|
Document.rank_by("text", "BM25", "quick walrus").limit(10)
|
|
118
|
+
Document.rank_by("body", "ANN", ["Embed", "sea mammals"]).limit(10)
|
|
107
119
|
|
|
108
120
|
Document.consistency(:eventual).where(published: true)
|
|
109
121
|
|
|
@@ -120,6 +132,20 @@ Document
|
|
|
120
132
|
|
|
121
133
|
> Eventual reads are cheaper but may not include writes from the last few seconds. Set a default with `turbopuffer_consistency "eventual"` in a model or `consistency: eventual` in `database.yml`. Strong is the default.
|
|
122
134
|
|
|
135
|
+
### Namespaces per tenant
|
|
136
|
+
|
|
137
|
+
turbopuffer recommends one namespace per tenant: a query can only ever see that tenant's documents, and search covers just the documents that matter rather than everyone's. `namespace` scopes a model to any namespace, and records remember where they were loaded from, so `update!`, `reload` and `destroy` go back to the same place.
|
|
138
|
+
|
|
139
|
+
```ruby
|
|
140
|
+
Document.namespace("documents-#{tenant.id}").create!(title: "Hello")
|
|
141
|
+
Document.namespace("documents-#{tenant.id}").where(published: true)
|
|
142
|
+
|
|
143
|
+
doc = Document.namespace("documents-#{tenant.id}").find(id)
|
|
144
|
+
doc.update!(published: true)
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
The name is used as given (under `namespace_prefix` if one is set). Without `namespace`, a model's table name is its namespace.
|
|
148
|
+
|
|
123
149
|
### Errors
|
|
124
150
|
|
|
125
151
|
Turbopuffer errors are raised as the ActiveRecord exceptions a Rails app already handles.
|
|
@@ -151,7 +151,7 @@ module ActiveRecord
|
|
|
151
151
|
schema: model.turbopuffer_schema_hash
|
|
152
152
|
}
|
|
153
153
|
|
|
154
|
-
if model.turbopuffer_attributes.any?
|
|
154
|
+
if model.turbopuffer_attributes.any? { |attribute| attribute.ann || attribute.embed }
|
|
155
155
|
tpuf_insert_args[:distance_metric] = model.turbopuffer_distance_metric
|
|
156
156
|
end
|
|
157
157
|
|
|
@@ -299,8 +299,7 @@ module ActiveRecord
|
|
|
299
299
|
def perform_query(raw_connection, sql, binds, type_casted_binds, prepare:, notification_payload:, batch:)
|
|
300
300
|
tpuf_query = sql.is_a?(Array) ? sql.first : sql
|
|
301
301
|
|
|
302
|
-
|
|
303
|
-
namespace = raw_connection.namespace(namespace_name)
|
|
302
|
+
namespace = raw_connection.namespace(namespace_name_for(tpuf_query))
|
|
304
303
|
|
|
305
304
|
result = case tpuf_query.op
|
|
306
305
|
when :insert then turbopuffer_insert(namespace, tpuf_query)
|
|
@@ -318,6 +317,12 @@ module ActiveRecord
|
|
|
318
317
|
result
|
|
319
318
|
end
|
|
320
319
|
|
|
320
|
+
def namespace_name_for(query)
|
|
321
|
+
override = query.namespace_override || ::Turbopuffer::ActiveRecord.namespace_override
|
|
322
|
+
|
|
323
|
+
[ @config[:namespace_prefix], override || query.namespace ].compact_blank.join("-")
|
|
324
|
+
end
|
|
325
|
+
|
|
321
326
|
def arel_visitor
|
|
322
327
|
Arel::Visitors::Turbopuffer.new
|
|
323
328
|
end
|
|
@@ -11,7 +11,8 @@ module Arel::Visitors
|
|
|
11
11
|
include_attributes: @include_attributes,
|
|
12
12
|
aggregate_by: @aggregate_by,
|
|
13
13
|
upsert_rows: @upsert_rows,
|
|
14
|
-
consistency: @consistency
|
|
14
|
+
consistency: @consistency,
|
|
15
|
+
namespace_override: @namespace_override
|
|
15
16
|
}
|
|
16
17
|
end
|
|
17
18
|
def to_s = JSON.generate(to_h)
|
|
@@ -21,10 +22,10 @@ module Arel::Visitors
|
|
|
21
22
|
alias == eql?
|
|
22
23
|
|
|
23
24
|
attr_reader :op, :namespace, :filters, :top_k, :rank_by, :include_attributes, :upsert_rows, :aggregate_by, :group_by,
|
|
24
|
-
:consistency
|
|
25
|
+
:consistency, :namespace_override
|
|
25
26
|
|
|
26
27
|
def initialize(op:, namespace:, filters: nil, top_k: nil, rank_by: nil, include_attributes: nil,
|
|
27
|
-
upsert_rows: nil, aggregate_by: nil, group_by: nil, consistency: nil)
|
|
28
|
+
upsert_rows: nil, aggregate_by: nil, group_by: nil, consistency: nil, namespace_override: nil)
|
|
28
29
|
@op = op
|
|
29
30
|
@namespace = namespace
|
|
30
31
|
@filters = filters
|
|
@@ -35,6 +36,7 @@ module Arel::Visitors
|
|
|
35
36
|
@group_by = group_by
|
|
36
37
|
@upsert_rows = upsert_rows
|
|
37
38
|
@consistency = consistency
|
|
39
|
+
@namespace_override = namespace_override
|
|
38
40
|
end
|
|
39
41
|
end
|
|
40
42
|
|
|
@@ -124,10 +126,19 @@ module Arel::Visitors
|
|
|
124
126
|
include_attributes: attributes.flat_map { |p| visit(p) },
|
|
125
127
|
group_by: core.groups.map { |g| visit(g) },
|
|
126
128
|
aggregate_by: aggregate && visit(aggregate),
|
|
127
|
-
consistency: consistency_hint(core.optimizer_hints)
|
|
129
|
+
consistency: consistency_hint(core.optimizer_hints),
|
|
130
|
+
namespace_override: namespace_annotation(core.comment)
|
|
128
131
|
)
|
|
129
132
|
end
|
|
130
133
|
|
|
134
|
+
def namespace_annotation(comment)
|
|
135
|
+
return unless comment
|
|
136
|
+
|
|
137
|
+
prefix = ::Turbopuffer::ActiveRecord::NAMESPACE_ANNOTATION
|
|
138
|
+
annotation = comment.values.reverse.find { |value| value.start_with?(prefix) }
|
|
139
|
+
annotation&.delete_prefix(prefix)
|
|
140
|
+
end
|
|
141
|
+
|
|
131
142
|
def consistency_hint(hints)
|
|
132
143
|
return unless hints
|
|
133
144
|
|
|
@@ -152,18 +163,20 @@ module Arel::Visitors
|
|
|
152
163
|
rows = o.values ? visit(o.values) : [ [] ]
|
|
153
164
|
|
|
154
165
|
TurbopufferQuery.new(
|
|
155
|
-
op:
|
|
156
|
-
namespace:
|
|
157
|
-
filters:
|
|
158
|
-
upsert_rows:
|
|
166
|
+
op: :update,
|
|
167
|
+
namespace: visit(o.relation),
|
|
168
|
+
filters: conjoin(o.wheres.map { |w| visit(w) }),
|
|
169
|
+
upsert_rows: rows,
|
|
170
|
+
namespace_override: namespace_annotation(o.comment)
|
|
159
171
|
)
|
|
160
172
|
end
|
|
161
173
|
|
|
162
174
|
def visit_Arel_Nodes_DeleteStatement(o)
|
|
163
175
|
TurbopufferQuery.new(
|
|
164
|
-
op:
|
|
165
|
-
namespace:
|
|
166
|
-
filters:
|
|
176
|
+
op: :delete,
|
|
177
|
+
namespace: visit(o.relation),
|
|
178
|
+
filters: conjoin(o.wheres.map { |w| visit(w) }),
|
|
179
|
+
namespace_override: namespace_annotation(o.comment)
|
|
167
180
|
)
|
|
168
181
|
end
|
|
169
182
|
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
module Turbopuffer
|
|
2
|
+
module ActiveRecord
|
|
3
|
+
module NamespaceScope
|
|
4
|
+
def turbopuffer_namespace
|
|
5
|
+
annotation = annotate_values.reverse.find { |value| value.start_with?(NAMESPACE_ANNOTATION) }
|
|
6
|
+
annotation&.delete_prefix(NAMESPACE_ANNOTATION)
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
def exec_queries(...) = ::Turbopuffer::ActiveRecord.with_namespace(turbopuffer_namespace) { super }
|
|
10
|
+
def insert_all(...) = ::Turbopuffer::ActiveRecord.with_namespace(turbopuffer_namespace) { super }
|
|
11
|
+
def insert_all!(...) = ::Turbopuffer::ActiveRecord.with_namespace(turbopuffer_namespace) { super }
|
|
12
|
+
def upsert_all(...) = ::Turbopuffer::ActiveRecord.with_namespace(turbopuffer_namespace) { super }
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
end
|
|
@@ -5,16 +5,20 @@ module Turbopuffer
|
|
|
5
5
|
TYPES = %r{\A(?:\[\])?(?:string|uuid|int|uint|float|bool|datetime)\z|\Abytes\z|\A\[\d+\](?:f16|f32|i8)\z|\A\{\}f16\z}
|
|
6
6
|
VECTOR_TYPES = %r{\A\[\d+\]|\A\{\}}
|
|
7
7
|
|
|
8
|
-
attr_reader :name, :type, :filterable, :full_text_search, :ann, :glob, :regex, :notnull
|
|
8
|
+
attr_reader :name, :type, :filterable, :full_text_search, :ann, :glob, :regex, :embed, :notnull
|
|
9
9
|
|
|
10
10
|
def initialize(name, type, not_null: 0, filterable: false, full_text_search: false,
|
|
11
|
-
ann: false, glob: false, regex: false)
|
|
11
|
+
ann: false, glob: false, regex: false, embed: nil)
|
|
12
12
|
type = type.to_s
|
|
13
13
|
|
|
14
14
|
unless TYPES.match?(type)
|
|
15
15
|
raise ArgumentError, "unknown turbopuffer type #{type.inspect} for attribute #{name.inspect}"
|
|
16
16
|
end
|
|
17
17
|
|
|
18
|
+
if embed && type != "string"
|
|
19
|
+
raise ArgumentError, "embed is only supported on string attributes, #{name.inspect} is #{type.inspect}"
|
|
20
|
+
end
|
|
21
|
+
|
|
18
22
|
@name = name
|
|
19
23
|
@type = type
|
|
20
24
|
@filterable = filterable
|
|
@@ -22,6 +26,7 @@ module Turbopuffer
|
|
|
22
26
|
@ann = ann
|
|
23
27
|
@glob = glob
|
|
24
28
|
@regex = regex
|
|
29
|
+
@embed = embed
|
|
25
30
|
|
|
26
31
|
@notnull = not_null
|
|
27
32
|
end
|
|
@@ -13,6 +13,27 @@ module Turbopuffer
|
|
|
13
13
|
class_attribute :_turbopuffer_consistency, instance_accessor: false, default: nil
|
|
14
14
|
end
|
|
15
15
|
|
|
16
|
+
def turbopuffer_namespace = @turbopuffer_namespace
|
|
17
|
+
|
|
18
|
+
def update_columns(...) = with_turbopuffer_namespace { super }
|
|
19
|
+
|
|
20
|
+
private
|
|
21
|
+
|
|
22
|
+
def init_internals
|
|
23
|
+
@turbopuffer_namespace = ::Turbopuffer::ActiveRecord.namespace_override ||
|
|
24
|
+
self.class.current_scope.try(:turbopuffer_namespace)
|
|
25
|
+
super
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def _create_record(...) = with_turbopuffer_namespace { super }
|
|
29
|
+
def _update_row(...) = with_turbopuffer_namespace { super }
|
|
30
|
+
def _delete_row(...) = with_turbopuffer_namespace { super }
|
|
31
|
+
def _find_record(...) = with_turbopuffer_namespace { super }
|
|
32
|
+
|
|
33
|
+
def with_turbopuffer_namespace(&block)
|
|
34
|
+
::Turbopuffer::ActiveRecord.with_namespace(@turbopuffer_namespace, &block)
|
|
35
|
+
end
|
|
36
|
+
|
|
16
37
|
class << self
|
|
17
38
|
def namespaces
|
|
18
39
|
::ActiveRecord::Base.descendants.select do |model|
|
|
@@ -48,10 +69,10 @@ module Turbopuffer
|
|
|
48
69
|
end
|
|
49
70
|
|
|
50
71
|
class_methods do
|
|
51
|
-
def turbopuffer_attribute(name, type, not_null: 0, filterable: false,
|
|
52
|
-
|
|
72
|
+
def turbopuffer_attribute(name, type, not_null: 0, filterable: false, full_text_search: false,
|
|
73
|
+
ann: false, glob: false, regex: false, embed: nil)
|
|
53
74
|
attribute = ::Turbopuffer::ActiveRecord::Schema::Attribute.new(
|
|
54
|
-
name.to_s, type, not_null:, filterable:, full_text_search:, ann:, glob:, regex:
|
|
75
|
+
name.to_s, type, not_null:, filterable:, full_text_search:, ann:, glob:, regex:, embed:
|
|
55
76
|
)
|
|
56
77
|
|
|
57
78
|
self.turbopuffer_attributes =
|
|
@@ -82,6 +103,10 @@ module Turbopuffer
|
|
|
82
103
|
select(*turbopuffer_attributes.map(&:name))
|
|
83
104
|
end
|
|
84
105
|
|
|
106
|
+
def namespace(name)
|
|
107
|
+
annotate("#{NAMESPACE_ANNOTATION}#{name}").extending(::Turbopuffer::ActiveRecord::NamespaceScope)
|
|
108
|
+
end
|
|
109
|
+
|
|
85
110
|
def glob(pattern, case_sensitive: true)
|
|
86
111
|
::Turbopuffer::ActiveRecord::Glob.new(pattern, case_sensitive:)
|
|
87
112
|
end
|
|
@@ -119,6 +144,7 @@ module Turbopuffer
|
|
|
119
144
|
attrs[:ann] = true if attribute.ann
|
|
120
145
|
attrs[:glob] = true if attribute.glob
|
|
121
146
|
attrs[:regex] = true if attribute.regex
|
|
147
|
+
attrs[:embed] = attribute.embed if attribute.embed
|
|
122
148
|
attrs[:sparse_knn] = { distance_metric: "dot_product" } if attribute.type == "{}f16"
|
|
123
149
|
|
|
124
150
|
schema[attribute.name] = attrs
|
|
@@ -4,6 +4,20 @@ module Turbopuffer
|
|
|
4
4
|
module ActiveRecord
|
|
5
5
|
class Error < StandardError; end
|
|
6
6
|
|
|
7
|
+
NAMESPACE_ANNOTATION = "turbopuffer_namespace="
|
|
8
|
+
|
|
9
|
+
def self.namespace_override
|
|
10
|
+
ActiveSupport::IsolatedExecutionState[:turbopuffer_namespace]
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def self.with_namespace(name)
|
|
14
|
+
previous = namespace_override
|
|
15
|
+
ActiveSupport::IsolatedExecutionState[:turbopuffer_namespace] = name
|
|
16
|
+
yield
|
|
17
|
+
ensure
|
|
18
|
+
ActiveSupport::IsolatedExecutionState[:turbopuffer_namespace] = previous
|
|
19
|
+
end
|
|
20
|
+
|
|
7
21
|
# Teaches Active Record that `adapter: turbopuffer` in database.yml maps to
|
|
8
22
|
# our adapter. The railtie calls this during boot; test harnesses and
|
|
9
23
|
# non-Rails consumers call it themselves.
|
metadata
CHANGED
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: activerecord-turbopuffer-adapter
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.1.
|
|
4
|
+
version: 0.1.8
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Richard Monette
|
|
8
8
|
bindir: bin
|
|
9
9
|
cert_chain: []
|
|
10
|
-
date: 2026-
|
|
10
|
+
date: 2026-10-07 00:00:00.000000000 Z
|
|
11
11
|
dependencies:
|
|
12
12
|
- !ruby/object:Gem::Dependency
|
|
13
13
|
name: activerecord
|
|
@@ -27,16 +27,16 @@ dependencies:
|
|
|
27
27
|
name: base64
|
|
28
28
|
requirement: !ruby/object:Gem::Requirement
|
|
29
29
|
requirements:
|
|
30
|
-
- - "
|
|
30
|
+
- - "~>"
|
|
31
31
|
- !ruby/object:Gem::Version
|
|
32
|
-
version: '0'
|
|
32
|
+
version: '0.2'
|
|
33
33
|
type: :runtime
|
|
34
34
|
prerelease: false
|
|
35
35
|
version_requirements: !ruby/object:Gem::Requirement
|
|
36
36
|
requirements:
|
|
37
|
-
- - "
|
|
37
|
+
- - "~>"
|
|
38
38
|
- !ruby/object:Gem::Version
|
|
39
|
-
version: '0'
|
|
39
|
+
version: '0.2'
|
|
40
40
|
- !ruby/object:Gem::Dependency
|
|
41
41
|
name: railties
|
|
42
42
|
requirement: !ruby/object:Gem::Requirement
|
|
@@ -89,6 +89,7 @@ files:
|
|
|
89
89
|
- lib/arel/visitors/turbopuffer.rb
|
|
90
90
|
- lib/turbopuffer/active_record.rb
|
|
91
91
|
- lib/turbopuffer/active_record/glob.rb
|
|
92
|
+
- lib/turbopuffer/active_record/namespace_scope.rb
|
|
92
93
|
- lib/turbopuffer/active_record/railtie.rb
|
|
93
94
|
- lib/turbopuffer/active_record/schema.rb
|
|
94
95
|
- lib/turbopuffer/active_record/schema/attribute.rb
|