vectra-client 0.1.3 → 0.2.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/.rubocop.yml +23 -3
- data/CHANGELOG.md +23 -0
- data/IMPLEMENTATION_GUIDE.md +686 -0
- data/NEW_FEATURES_v0.2.0.md +459 -0
- data/RELEASE_CHECKLIST_v0.2.0.md +383 -0
- data/Rakefile +12 -0
- data/USAGE_EXAMPLES.md +787 -0
- data/benchmarks/batch_operations_benchmark.rb +117 -0
- data/benchmarks/connection_pooling_benchmark.rb +93 -0
- data/examples/active_record_demo.rb +227 -0
- data/examples/instrumentation_demo.rb +157 -0
- data/lib/generators/vectra/install_generator.rb +115 -0
- data/lib/generators/vectra/templates/enable_pgvector_extension.rb +11 -0
- data/lib/generators/vectra/templates/vectra.rb +79 -0
- data/lib/vectra/active_record.rb +195 -0
- data/lib/vectra/client.rb +60 -22
- data/lib/vectra/configuration.rb +6 -1
- data/lib/vectra/instrumentation/datadog.rb +82 -0
- data/lib/vectra/instrumentation/new_relic.rb +70 -0
- data/lib/vectra/instrumentation.rb +143 -0
- data/lib/vectra/providers/pgvector/connection.rb +5 -1
- data/lib/vectra/retry.rb +156 -0
- data/lib/vectra/version.rb +1 -1
- data/lib/vectra.rb +11 -0
- metadata +45 -1
data/USAGE_EXAMPLES.md
ADDED
|
@@ -0,0 +1,787 @@
|
|
|
1
|
+
# 💡 PRACTICAL USAGE EXAMPLES
|
|
2
|
+
|
|
3
|
+
Real-world examples of using Vectra in production applications.
|
|
4
|
+
|
|
5
|
+
## Table of Contents
|
|
6
|
+
|
|
7
|
+
- [Setup & Installation](#setup--installation)
|
|
8
|
+
- [E-Commerce Semantic Search](#e-commerce-semantic-search)
|
|
9
|
+
- [RAG Chatbot](#rag-chatbot-retrieval-augmented-generation)
|
|
10
|
+
- [Duplicate Detection](#duplicate-detection)
|
|
11
|
+
- [Rails ActiveRecord Integration](#rails-activerecord-integration)
|
|
12
|
+
- [Instrumentation & Monitoring](#instrumentation--monitoring)
|
|
13
|
+
|
|
14
|
+
---
|
|
15
|
+
|
|
16
|
+
## Setup & Installation
|
|
17
|
+
|
|
18
|
+
### Rails Application
|
|
19
|
+
|
|
20
|
+
```bash
|
|
21
|
+
# Add to Gemfile
|
|
22
|
+
gem 'vectra'
|
|
23
|
+
gem 'pg' # For pgvector support
|
|
24
|
+
|
|
25
|
+
# Install
|
|
26
|
+
bundle install
|
|
27
|
+
|
|
28
|
+
# Run generator
|
|
29
|
+
rails generate vectra:install --provider=pgvector --instrumentation=true
|
|
30
|
+
|
|
31
|
+
# Run migrations
|
|
32
|
+
rails db:migrate
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
### Standalone Ruby
|
|
36
|
+
|
|
37
|
+
```bash
|
|
38
|
+
gem install vectra
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
---
|
|
42
|
+
|
|
43
|
+
## E-Commerce Semantic Search
|
|
44
|
+
|
|
45
|
+
Build intelligent product search that understands user intent beyond keywords.
|
|
46
|
+
|
|
47
|
+
### 1. Setup Service
|
|
48
|
+
|
|
49
|
+
```ruby
|
|
50
|
+
# app/services/product_search_service.rb
|
|
51
|
+
class ProductSearchService
|
|
52
|
+
def initialize
|
|
53
|
+
@vectra = Vectra.pgvector(
|
|
54
|
+
connection_url: ENV['DATABASE_URL'],
|
|
55
|
+
pool_size: 10,
|
|
56
|
+
batch_size: 500
|
|
57
|
+
)
|
|
58
|
+
@embedding_client = OpenAI::Client.new(access_token: ENV['OPENAI_API_KEY'])
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Index all products (run once, or in background job)
|
|
62
|
+
def index_all_products
|
|
63
|
+
Product.find_in_batches(batch_size: 500) do |batch|
|
|
64
|
+
index_products(batch)
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# Index batch of products
|
|
69
|
+
def index_products(products)
|
|
70
|
+
vectors = products.map do |product|
|
|
71
|
+
# Combine multiple fields for better search
|
|
72
|
+
search_text = [
|
|
73
|
+
product.name,
|
|
74
|
+
product.description,
|
|
75
|
+
product.category,
|
|
76
|
+
product.tags.join(', ')
|
|
77
|
+
].compact.join(' ')
|
|
78
|
+
|
|
79
|
+
{
|
|
80
|
+
id: "product_#{product.id}",
|
|
81
|
+
values: generate_embedding(search_text),
|
|
82
|
+
metadata: {
|
|
83
|
+
name: product.name,
|
|
84
|
+
price: product.price.to_f,
|
|
85
|
+
category: product.category,
|
|
86
|
+
in_stock: product.in_stock?,
|
|
87
|
+
rating: product.average_rating,
|
|
88
|
+
image_url: product.image_url
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
@vectra.upsert(index: 'products', vectors: vectors)
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# Semantic search with filters
|
|
97
|
+
def search(query, category: nil, min_price: nil, max_price: nil, in_stock_only: true, limit: 20)
|
|
98
|
+
embedding = generate_embedding(query)
|
|
99
|
+
|
|
100
|
+
# Build metadata filter
|
|
101
|
+
filter = {}
|
|
102
|
+
filter[:category] = category if category
|
|
103
|
+
filter[:in_stock] = true if in_stock_only
|
|
104
|
+
|
|
105
|
+
# Query vectors
|
|
106
|
+
results = @vectra.query(
|
|
107
|
+
index: 'products',
|
|
108
|
+
vector: embedding,
|
|
109
|
+
top_k: limit * 2, # Fetch more for post-filtering
|
|
110
|
+
filter: filter
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
# Post-filter by price (or add to SQL in future)
|
|
114
|
+
results = results.select { |r| r.metadata['price'] >= min_price } if min_price
|
|
115
|
+
results = results.select { |r| r.metadata['price'] <= max_price } if max_price
|
|
116
|
+
|
|
117
|
+
# Transform to product objects
|
|
118
|
+
results.first(limit).map do |match|
|
|
119
|
+
{
|
|
120
|
+
product_id: match.id.gsub('product_', '').to_i,
|
|
121
|
+
similarity_score: match.score,
|
|
122
|
+
name: match.metadata['name'],
|
|
123
|
+
price: match.metadata['price'],
|
|
124
|
+
category: match.metadata['category'],
|
|
125
|
+
rating: match.metadata['rating'],
|
|
126
|
+
image_url: match.metadata['image_url']
|
|
127
|
+
}
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
# Recommend similar products
|
|
132
|
+
def similar_products(product_id, limit: 10)
|
|
133
|
+
product = Product.find(product_id)
|
|
134
|
+
search_text = "#{product.name} #{product.description}"
|
|
135
|
+
|
|
136
|
+
search(search_text, category: product.category, limit: limit + 1)
|
|
137
|
+
.reject { |p| p[:product_id] == product_id }
|
|
138
|
+
.first(limit)
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
private
|
|
142
|
+
|
|
143
|
+
def generate_embedding(text)
|
|
144
|
+
response = @embedding_client.embeddings(
|
|
145
|
+
parameters: {
|
|
146
|
+
model: 'text-embedding-3-small',
|
|
147
|
+
input: text.truncate(8000) # OpenAI limit
|
|
148
|
+
}
|
|
149
|
+
)
|
|
150
|
+
response.dig('data', 0, 'embedding')
|
|
151
|
+
rescue => e
|
|
152
|
+
Rails.logger.error("Embedding generation failed: #{e.message}")
|
|
153
|
+
raise
|
|
154
|
+
end
|
|
155
|
+
end
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
### 2. Controller
|
|
159
|
+
|
|
160
|
+
```ruby
|
|
161
|
+
# app/controllers/search_controller.rb
|
|
162
|
+
class SearchController < ApplicationController
|
|
163
|
+
def index
|
|
164
|
+
@query = params[:q]
|
|
165
|
+
|
|
166
|
+
if @query.present?
|
|
167
|
+
service = ProductSearchService.new
|
|
168
|
+
@results = service.search(
|
|
169
|
+
@query,
|
|
170
|
+
category: params[:category],
|
|
171
|
+
min_price: params[:min_price]&.to_f,
|
|
172
|
+
max_price: params[:max_price]&.to_f,
|
|
173
|
+
in_stock_only: params[:in_stock] != 'false',
|
|
174
|
+
limit: 50
|
|
175
|
+
)
|
|
176
|
+
else
|
|
177
|
+
@results = []
|
|
178
|
+
end
|
|
179
|
+
end
|
|
180
|
+
end
|
|
181
|
+
```
|
|
182
|
+
|
|
183
|
+
### 3. Background Job (Index Products)
|
|
184
|
+
|
|
185
|
+
```ruby
|
|
186
|
+
# app/jobs/index_products_job.rb
|
|
187
|
+
class IndexProductsJob < ApplicationJob
|
|
188
|
+
queue_as :default
|
|
189
|
+
|
|
190
|
+
def perform(*product_ids)
|
|
191
|
+
products = Product.where(id: product_ids)
|
|
192
|
+
ProductSearchService.new.index_products(products)
|
|
193
|
+
end
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
# In Product model
|
|
197
|
+
class Product < ApplicationRecord
|
|
198
|
+
after_commit :reindex_in_vectra, on: [:create, :update]
|
|
199
|
+
|
|
200
|
+
private
|
|
201
|
+
|
|
202
|
+
def reindex_in_vectra
|
|
203
|
+
IndexProductsJob.perform_later(id)
|
|
204
|
+
end
|
|
205
|
+
end
|
|
206
|
+
```
|
|
207
|
+
|
|
208
|
+
---
|
|
209
|
+
|
|
210
|
+
## RAG Chatbot (Retrieval Augmented Generation)
|
|
211
|
+
|
|
212
|
+
Build a chatbot that answers questions using your documentation.
|
|
213
|
+
|
|
214
|
+
### 1. Setup Service
|
|
215
|
+
|
|
216
|
+
```ruby
|
|
217
|
+
# app/services/rag_service.rb
|
|
218
|
+
class RagService
|
|
219
|
+
CHUNK_SIZE = 512 # tokens per chunk
|
|
220
|
+
OVERLAP = 50 # token overlap between chunks
|
|
221
|
+
|
|
222
|
+
def initialize
|
|
223
|
+
@vectra = Vectra.pgvector(connection_url: ENV['DATABASE_URL'])
|
|
224
|
+
@openai = OpenAI::Client.new(access_token: ENV['OPENAI_API_KEY'])
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
# Index documentation (run once or when docs update)
|
|
228
|
+
def index_documentation
|
|
229
|
+
Documentation.find_each do |doc|
|
|
230
|
+
index_document(doc)
|
|
231
|
+
end
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
# Index single document
|
|
235
|
+
def index_document(doc)
|
|
236
|
+
chunks = chunk_text(doc.content, max_tokens: CHUNK_SIZE)
|
|
237
|
+
|
|
238
|
+
vectors = chunks.map.with_index do |chunk, idx|
|
|
239
|
+
{
|
|
240
|
+
id: "doc_#{doc.id}_chunk_#{idx}",
|
|
241
|
+
values: generate_embedding(chunk),
|
|
242
|
+
metadata: {
|
|
243
|
+
doc_id: doc.id,
|
|
244
|
+
title: doc.title,
|
|
245
|
+
chunk_index: idx,
|
|
246
|
+
total_chunks: chunks.size,
|
|
247
|
+
text: chunk,
|
|
248
|
+
url: doc.url,
|
|
249
|
+
category: doc.category,
|
|
250
|
+
last_updated: doc.updated_at.iso8601
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
end
|
|
254
|
+
|
|
255
|
+
@vectra.upsert(index: 'documentation', vectors: vectors)
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
# Answer question using RAG
|
|
259
|
+
def answer_question(question, max_context_chunks: 5)
|
|
260
|
+
# 1. Find relevant context
|
|
261
|
+
question_embedding = generate_embedding(question)
|
|
262
|
+
|
|
263
|
+
results = @vectra.query(
|
|
264
|
+
index: 'documentation',
|
|
265
|
+
vector: question_embedding,
|
|
266
|
+
top_k: max_context_chunks,
|
|
267
|
+
include_metadata: true
|
|
268
|
+
)
|
|
269
|
+
|
|
270
|
+
return no_context_response if results.empty?
|
|
271
|
+
|
|
272
|
+
# 2. Build context from chunks
|
|
273
|
+
context = results.map { |r| r.metadata['text'] }.join("\n\n---\n\n")
|
|
274
|
+
sources = results.map do |r|
|
|
275
|
+
{
|
|
276
|
+
title: r.metadata['title'],
|
|
277
|
+
url: r.metadata['url'],
|
|
278
|
+
relevance: r.score
|
|
279
|
+
}
|
|
280
|
+
end.uniq { |s| s[:url] }
|
|
281
|
+
|
|
282
|
+
# 3. Generate answer with GPT
|
|
283
|
+
prompt = build_prompt(question, context)
|
|
284
|
+
|
|
285
|
+
answer = @openai.chat(
|
|
286
|
+
parameters: {
|
|
287
|
+
model: 'gpt-4-turbo-preview',
|
|
288
|
+
messages: [
|
|
289
|
+
{ role: 'system', content: system_message },
|
|
290
|
+
{ role: 'user', content: prompt }
|
|
291
|
+
],
|
|
292
|
+
temperature: 0.3 # Lower = more deterministic
|
|
293
|
+
}
|
|
294
|
+
).dig('choices', 0, 'message', 'content')
|
|
295
|
+
|
|
296
|
+
{
|
|
297
|
+
answer: answer,
|
|
298
|
+
sources: sources,
|
|
299
|
+
context_used: results.size
|
|
300
|
+
}
|
|
301
|
+
rescue => e
|
|
302
|
+
Rails.logger.error("RAG answer failed: #{e.message}")
|
|
303
|
+
{
|
|
304
|
+
answer: "I'm sorry, I encountered an error while processing your question.",
|
|
305
|
+
sources: [],
|
|
306
|
+
error: e.message
|
|
307
|
+
}
|
|
308
|
+
end
|
|
309
|
+
|
|
310
|
+
# Streaming version for real-time UI
|
|
311
|
+
def answer_question_streaming(question, &block)
|
|
312
|
+
# Similar to above, but use streaming:
|
|
313
|
+
@openai.chat(
|
|
314
|
+
parameters: {
|
|
315
|
+
model: 'gpt-4-turbo-preview',
|
|
316
|
+
messages: [...],
|
|
317
|
+
stream: proc do |chunk, _bytesize|
|
|
318
|
+
content = chunk.dig('choices', 0, 'delta', 'content')
|
|
319
|
+
block.call(content) if content
|
|
320
|
+
end
|
|
321
|
+
}
|
|
322
|
+
)
|
|
323
|
+
end
|
|
324
|
+
|
|
325
|
+
private
|
|
326
|
+
|
|
327
|
+
def chunk_text(text, max_tokens: 512)
|
|
328
|
+
# Simple sentence-based chunking
|
|
329
|
+
# Production: use tiktoken for accurate token counting
|
|
330
|
+
sentences = text.split(/(?<=[.!?])\s+/)
|
|
331
|
+
chunks = []
|
|
332
|
+
current_chunk = []
|
|
333
|
+
current_length = 0
|
|
334
|
+
|
|
335
|
+
sentences.each do |sentence|
|
|
336
|
+
# Rough approximation: 1 token ≈ 4 characters
|
|
337
|
+
sentence_tokens = sentence.length / 4
|
|
338
|
+
|
|
339
|
+
if current_length + sentence_tokens > max_tokens && current_chunk.any?
|
|
340
|
+
chunks << current_chunk.join(' ')
|
|
341
|
+
# Keep last sentence for overlap
|
|
342
|
+
current_chunk = [current_chunk.last]
|
|
343
|
+
current_length = current_chunk.last.length / 4
|
|
344
|
+
end
|
|
345
|
+
|
|
346
|
+
current_chunk << sentence
|
|
347
|
+
current_length += sentence_tokens
|
|
348
|
+
end
|
|
349
|
+
|
|
350
|
+
chunks << current_chunk.join(' ') if current_chunk.any?
|
|
351
|
+
chunks
|
|
352
|
+
end
|
|
353
|
+
|
|
354
|
+
def build_prompt(question, context)
|
|
355
|
+
<<~PROMPT
|
|
356
|
+
Context from documentation:
|
|
357
|
+
#{context}
|
|
358
|
+
|
|
359
|
+
Question: #{question}
|
|
360
|
+
|
|
361
|
+
Please answer the question based on the context provided above. If the context doesn't contain enough information to answer the question, say so clearly.
|
|
362
|
+
PROMPT
|
|
363
|
+
end
|
|
364
|
+
|
|
365
|
+
def system_message
|
|
366
|
+
<<~SYSTEM
|
|
367
|
+
You are a helpful AI assistant that answers questions based on documentation.
|
|
368
|
+
Use the provided context to answer questions accurately.
|
|
369
|
+
If you're not sure or the context doesn't contain the answer, say so.
|
|
370
|
+
Format your answers clearly with markdown when appropriate.
|
|
371
|
+
SYSTEM
|
|
372
|
+
end
|
|
373
|
+
|
|
374
|
+
def no_context_response
|
|
375
|
+
{
|
|
376
|
+
answer: "I couldn't find relevant information in the documentation to answer your question.",
|
|
377
|
+
sources: [],
|
|
378
|
+
context_used: 0
|
|
379
|
+
}
|
|
380
|
+
end
|
|
381
|
+
|
|
382
|
+
def generate_embedding(text)
|
|
383
|
+
response = @openai.embeddings(
|
|
384
|
+
parameters: {
|
|
385
|
+
model: 'text-embedding-3-small',
|
|
386
|
+
input: text.truncate(8000)
|
|
387
|
+
}
|
|
388
|
+
)
|
|
389
|
+
response.dig('data', 0, 'embedding')
|
|
390
|
+
end
|
|
391
|
+
end
|
|
392
|
+
```
|
|
393
|
+
|
|
394
|
+
### 2. Controller
|
|
395
|
+
|
|
396
|
+
```ruby
|
|
397
|
+
# app/controllers/chat_controller.rb
|
|
398
|
+
class ChatController < ApplicationController
|
|
399
|
+
def ask
|
|
400
|
+
question = params[:question]
|
|
401
|
+
rag = RagService.new
|
|
402
|
+
@result = rag.answer_question(question)
|
|
403
|
+
|
|
404
|
+
render json: @result
|
|
405
|
+
end
|
|
406
|
+
|
|
407
|
+
# Streaming version
|
|
408
|
+
def ask_streaming
|
|
409
|
+
response.headers['Content-Type'] = 'text/event-stream'
|
|
410
|
+
response.headers['X-Accel-Buffering'] = 'no'
|
|
411
|
+
|
|
412
|
+
question = params[:question]
|
|
413
|
+
rag = RagService.new
|
|
414
|
+
|
|
415
|
+
rag.answer_question_streaming(question) do |chunk|
|
|
416
|
+
response.stream.write("data: #{chunk}\n\n")
|
|
417
|
+
end
|
|
418
|
+
ensure
|
|
419
|
+
response.stream.close
|
|
420
|
+
end
|
|
421
|
+
end
|
|
422
|
+
```
|
|
423
|
+
|
|
424
|
+
---
|
|
425
|
+
|
|
426
|
+
## Duplicate Detection
|
|
427
|
+
|
|
428
|
+
Automatically detect duplicate support tickets, articles, or user submissions.
|
|
429
|
+
|
|
430
|
+
### Service
|
|
431
|
+
|
|
432
|
+
```ruby
|
|
433
|
+
# app/services/duplicate_detector.rb
|
|
434
|
+
class DuplicateDetector
|
|
435
|
+
SIMILARITY_THRESHOLD = 0.92 # 92% similar = likely duplicate
|
|
436
|
+
|
|
437
|
+
def initialize
|
|
438
|
+
@vectra = Vectra.pgvector(connection_url: ENV['DATABASE_URL'])
|
|
439
|
+
@openai = OpenAI::Client.new(access_token: ENV['OPENAI_API_KEY'])
|
|
440
|
+
end
|
|
441
|
+
|
|
442
|
+
# Find potential duplicates
|
|
443
|
+
def find_duplicates(ticket)
|
|
444
|
+
embedding = generate_embedding(ticket.description)
|
|
445
|
+
|
|
446
|
+
results = @vectra.query(
|
|
447
|
+
index: 'support_tickets',
|
|
448
|
+
vector: embedding,
|
|
449
|
+
top_k: 20,
|
|
450
|
+
filter: { status: ['open', 'in_progress'] } # Only open tickets
|
|
451
|
+
)
|
|
452
|
+
|
|
453
|
+
# Filter by similarity threshold
|
|
454
|
+
results
|
|
455
|
+
.above_score(SIMILARITY_THRESHOLD)
|
|
456
|
+
.reject { |match| match.id == "ticket_#{ticket.id}" } # Exclude self
|
|
457
|
+
.map do |match|
|
|
458
|
+
{
|
|
459
|
+
ticket_id: match.id.gsub('ticket_', '').to_i,
|
|
460
|
+
similarity: (match.score * 100).round(1),
|
|
461
|
+
title: match.metadata['title'],
|
|
462
|
+
created_at: match.metadata['created_at'],
|
|
463
|
+
status: match.metadata['status']
|
|
464
|
+
}
|
|
465
|
+
end
|
|
466
|
+
end
|
|
467
|
+
|
|
468
|
+
# Index ticket for future duplicate detection
|
|
469
|
+
def index_ticket(ticket)
|
|
470
|
+
@vectra.upsert(
|
|
471
|
+
index: 'support_tickets',
|
|
472
|
+
vectors: [{
|
|
473
|
+
id: "ticket_#{ticket.id}",
|
|
474
|
+
values: generate_embedding(ticket.description),
|
|
475
|
+
metadata: {
|
|
476
|
+
title: ticket.title,
|
|
477
|
+
status: ticket.status,
|
|
478
|
+
category: ticket.category,
|
|
479
|
+
priority: ticket.priority,
|
|
480
|
+
created_at: ticket.created_at.iso8601
|
|
481
|
+
}
|
|
482
|
+
}]
|
|
483
|
+
)
|
|
484
|
+
end
|
|
485
|
+
|
|
486
|
+
# Update ticket status in index
|
|
487
|
+
def update_ticket_status(ticket)
|
|
488
|
+
@vectra.update(
|
|
489
|
+
index: 'support_tickets',
|
|
490
|
+
id: "ticket_#{ticket.id}",
|
|
491
|
+
metadata: {
|
|
492
|
+
status: ticket.status
|
|
493
|
+
}
|
|
494
|
+
)
|
|
495
|
+
end
|
|
496
|
+
|
|
497
|
+
private
|
|
498
|
+
|
|
499
|
+
def generate_embedding(text)
|
|
500
|
+
response = @openai.embeddings(
|
|
501
|
+
parameters: {
|
|
502
|
+
model: 'text-embedding-3-small',
|
|
503
|
+
input: text.truncate(8000)
|
|
504
|
+
}
|
|
505
|
+
)
|
|
506
|
+
response.dig('data', 0, 'embedding')
|
|
507
|
+
end
|
|
508
|
+
end
|
|
509
|
+
```
|
|
510
|
+
|
|
511
|
+
### Model Integration
|
|
512
|
+
|
|
513
|
+
```ruby
|
|
514
|
+
# app/models/support_ticket.rb
|
|
515
|
+
class SupportTicket < ApplicationRecord
|
|
516
|
+
after_create :index_in_vectra
|
|
517
|
+
after_update :update_vectra_status, if: :saved_change_to_status?
|
|
518
|
+
|
|
519
|
+
def check_duplicates
|
|
520
|
+
DuplicateDetector.new.find_duplicates(self)
|
|
521
|
+
end
|
|
522
|
+
|
|
523
|
+
private
|
|
524
|
+
|
|
525
|
+
def index_in_vectra
|
|
526
|
+
DuplicateDetectorJob.perform_later(id, :index)
|
|
527
|
+
end
|
|
528
|
+
|
|
529
|
+
def update_vectra_status
|
|
530
|
+
DuplicateDetectorJob.perform_later(id, :update_status)
|
|
531
|
+
end
|
|
532
|
+
end
|
|
533
|
+
```
|
|
534
|
+
|
|
535
|
+
---
|
|
536
|
+
|
|
537
|
+
## Rails ActiveRecord Integration
|
|
538
|
+
|
|
539
|
+
Simplest way to add vector search to your Rails models.
|
|
540
|
+
|
|
541
|
+
### 1. Setup Model
|
|
542
|
+
|
|
543
|
+
```ruby
|
|
544
|
+
# app/models/document.rb
|
|
545
|
+
class Document < ApplicationRecord
|
|
546
|
+
include Vectra::ActiveRecord
|
|
547
|
+
|
|
548
|
+
has_vector :embedding,
|
|
549
|
+
dimension: 384,
|
|
550
|
+
provider: :pgvector,
|
|
551
|
+
index: 'documents',
|
|
552
|
+
auto_index: true, # Auto-index on save
|
|
553
|
+
metadata_fields: [:title, :category, :status, :user_id]
|
|
554
|
+
|
|
555
|
+
# Generate embedding before validation
|
|
556
|
+
before_validation :generate_embedding, if: :content_changed?
|
|
557
|
+
|
|
558
|
+
private
|
|
559
|
+
|
|
560
|
+
def generate_embedding
|
|
561
|
+
self.embedding = EmbeddingService.generate(content)
|
|
562
|
+
end
|
|
563
|
+
end
|
|
564
|
+
```
|
|
565
|
+
|
|
566
|
+
### 2. Usage
|
|
567
|
+
|
|
568
|
+
```ruby
|
|
569
|
+
# Create document (automatically indexed)
|
|
570
|
+
doc = Document.create!(
|
|
571
|
+
title: 'Getting Started Guide',
|
|
572
|
+
content: 'This guide will help you...',
|
|
573
|
+
category: 'tutorial',
|
|
574
|
+
status: 'published'
|
|
575
|
+
)
|
|
576
|
+
|
|
577
|
+
# Search similar documents
|
|
578
|
+
query_vector = EmbeddingService.generate('how to get started')
|
|
579
|
+
results = Document.vector_search(query_vector, limit: 10)
|
|
580
|
+
|
|
581
|
+
# Each result has vector_score
|
|
582
|
+
results.each do |doc|
|
|
583
|
+
puts "#{doc.title} - Score: #{doc.vector_score}"
|
|
584
|
+
end
|
|
585
|
+
|
|
586
|
+
# Find similar to a specific document
|
|
587
|
+
similar = doc.similar(limit: 5)
|
|
588
|
+
|
|
589
|
+
# Search with filters
|
|
590
|
+
results = Document.vector_search(
|
|
591
|
+
query_vector,
|
|
592
|
+
limit: 10,
|
|
593
|
+
filter: { category: 'tutorial', status: 'published' }
|
|
594
|
+
)
|
|
595
|
+
|
|
596
|
+
# Manual control
|
|
597
|
+
doc.index_vector! # Manually index
|
|
598
|
+
doc.delete_vector! # Remove from index
|
|
599
|
+
```
|
|
600
|
+
|
|
601
|
+
---
|
|
602
|
+
|
|
603
|
+
## Instrumentation & Monitoring
|
|
604
|
+
|
|
605
|
+
Track performance and errors in production.
|
|
606
|
+
|
|
607
|
+
### New Relic Integration
|
|
608
|
+
|
|
609
|
+
```ruby
|
|
610
|
+
# config/initializers/vectra.rb
|
|
611
|
+
require 'vectra/instrumentation/new_relic'
|
|
612
|
+
|
|
613
|
+
Vectra.configure do |config|
|
|
614
|
+
config.instrumentation = true
|
|
615
|
+
end
|
|
616
|
+
|
|
617
|
+
Vectra::Instrumentation::NewRelic.setup!
|
|
618
|
+
```
|
|
619
|
+
|
|
620
|
+
This automatically tracks:
|
|
621
|
+
- `Custom/Vectra/:provider/:operation/duration` - Operation latency
|
|
622
|
+
- `Custom/Vectra/:provider/:operation/calls` - Call counts
|
|
623
|
+
- `Custom/Vectra/:provider/:operation/success` - Success count
|
|
624
|
+
- `Custom/Vectra/:provider/:operation/error` - Error count
|
|
625
|
+
- `Custom/Vectra/:provider/:operation/results` - Result counts
|
|
626
|
+
|
|
627
|
+
### Datadog Integration
|
|
628
|
+
|
|
629
|
+
```ruby
|
|
630
|
+
# config/initializers/vectra.rb
|
|
631
|
+
require 'vectra/instrumentation/datadog'
|
|
632
|
+
|
|
633
|
+
Vectra.configure do |config|
|
|
634
|
+
config.instrumentation = true
|
|
635
|
+
end
|
|
636
|
+
|
|
637
|
+
Vectra::Instrumentation::Datadog.setup!(
|
|
638
|
+
host: ENV['DD_AGENT_HOST'] || 'localhost',
|
|
639
|
+
port: ENV['DD_DOGSTATSD_PORT']&.to_i || 8125
|
|
640
|
+
)
|
|
641
|
+
```
|
|
642
|
+
|
|
643
|
+
Metrics sent to Datadog:
|
|
644
|
+
- `vectra.operation.duration` (timing)
|
|
645
|
+
- `vectra.operation.count` (counter)
|
|
646
|
+
- `vectra.operation.results` (gauge)
|
|
647
|
+
- `vectra.operation.error` (counter)
|
|
648
|
+
|
|
649
|
+
Tags: `provider`, `operation`, `index`, `status`, `error_type`
|
|
650
|
+
|
|
651
|
+
### Custom Instrumentation
|
|
652
|
+
|
|
653
|
+
```ruby
|
|
654
|
+
# config/initializers/vectra.rb
|
|
655
|
+
Vectra.configure do |config|
|
|
656
|
+
config.instrumentation = true
|
|
657
|
+
end
|
|
658
|
+
|
|
659
|
+
# Log to Rails logger
|
|
660
|
+
Vectra.on_operation do |event|
|
|
661
|
+
Rails.logger.info(
|
|
662
|
+
"Vectra: #{event.operation} on #{event.provider}/#{event.index} " \
|
|
663
|
+
"took #{event.duration}ms (#{event.success? ? 'success' : 'error'})"
|
|
664
|
+
)
|
|
665
|
+
|
|
666
|
+
if event.failure?
|
|
667
|
+
Rails.logger.error("Vectra error: #{event.error.class} - #{event.error.message}")
|
|
668
|
+
end
|
|
669
|
+
|
|
670
|
+
# Send to custom metrics service
|
|
671
|
+
MetricsService.timing("vectra.#{event.operation}", event.duration)
|
|
672
|
+
MetricsService.increment("vectra.#{event.success? ? 'success' : 'error'}")
|
|
673
|
+
end
|
|
674
|
+
|
|
675
|
+
# Track slow operations
|
|
676
|
+
Vectra.on_operation do |event|
|
|
677
|
+
if event.duration > 1000 # > 1 second
|
|
678
|
+
SlackNotifier.notify(
|
|
679
|
+
"Slow Vectra operation: #{event.operation} took #{event.duration}ms",
|
|
680
|
+
channel: '#performance-alerts'
|
|
681
|
+
)
|
|
682
|
+
end
|
|
683
|
+
end
|
|
684
|
+
```
|
|
685
|
+
|
|
686
|
+
---
|
|
687
|
+
|
|
688
|
+
## Performance Tips
|
|
689
|
+
|
|
690
|
+
### 1. Connection Pooling (pgvector)
|
|
691
|
+
|
|
692
|
+
```ruby
|
|
693
|
+
Vectra.configure do |config|
|
|
694
|
+
config.pool_size = 20 # Match your app server threads
|
|
695
|
+
config.pool_timeout = 5 # Seconds to wait for connection
|
|
696
|
+
config.batch_size = 500 # Larger batches = fewer DB calls
|
|
697
|
+
end
|
|
698
|
+
```
|
|
699
|
+
|
|
700
|
+
### 2. Batch Operations
|
|
701
|
+
|
|
702
|
+
```ruby
|
|
703
|
+
# ❌ Bad: Individual inserts
|
|
704
|
+
products.each do |product|
|
|
705
|
+
service.index_products([product])
|
|
706
|
+
end
|
|
707
|
+
|
|
708
|
+
# ✅ Good: Batch inserts
|
|
709
|
+
products.in_batches(of: 500) do |batch|
|
|
710
|
+
service.index_products(batch)
|
|
711
|
+
end
|
|
712
|
+
```
|
|
713
|
+
|
|
714
|
+
### 3. Background Jobs
|
|
715
|
+
|
|
716
|
+
```ruby
|
|
717
|
+
# ❌ Bad: Synchronous indexing in request
|
|
718
|
+
def create
|
|
719
|
+
@product = Product.create!(product_params)
|
|
720
|
+
ProductSearchService.new.index_products([@product]) # Blocks request!
|
|
721
|
+
redirect_to @product
|
|
722
|
+
end
|
|
723
|
+
|
|
724
|
+
# ✅ Good: Async indexing
|
|
725
|
+
def create
|
|
726
|
+
@product = Product.create!(product_params)
|
|
727
|
+
IndexProductJob.perform_later(@product.id)
|
|
728
|
+
redirect_to @product
|
|
729
|
+
end
|
|
730
|
+
```
|
|
731
|
+
|
|
732
|
+
### 4. Caching Embeddings
|
|
733
|
+
|
|
734
|
+
```ruby
|
|
735
|
+
# Cache expensive embedding generations
|
|
736
|
+
def generate_embedding(text)
|
|
737
|
+
cache_key = "embedding:#{Digest::MD5.hexdigest(text)}"
|
|
738
|
+
|
|
739
|
+
Rails.cache.fetch(cache_key, expires_in: 1.week) do
|
|
740
|
+
@openai.embeddings(parameters: { model: 'text-embedding-3-small', input: text })
|
|
741
|
+
.dig('data', 0, 'embedding')
|
|
742
|
+
end
|
|
743
|
+
end
|
|
744
|
+
```
|
|
745
|
+
|
|
746
|
+
---
|
|
747
|
+
|
|
748
|
+
## Troubleshooting
|
|
749
|
+
|
|
750
|
+
### High Memory Usage
|
|
751
|
+
|
|
752
|
+
```ruby
|
|
753
|
+
# Use find_in_batches for large datasets
|
|
754
|
+
Product.find_in_batches(batch_size: 100) do |batch|
|
|
755
|
+
service.index_products(batch)
|
|
756
|
+
GC.start # Force garbage collection between batches
|
|
757
|
+
end
|
|
758
|
+
```
|
|
759
|
+
|
|
760
|
+
### Connection Pool Exhausted
|
|
761
|
+
|
|
762
|
+
```ruby
|
|
763
|
+
# Increase pool size or reduce parallelism
|
|
764
|
+
Vectra.configure do |config|
|
|
765
|
+
config.pool_size = 50 # Increase if needed
|
|
766
|
+
config.pool_timeout = 10 # Wait longer
|
|
767
|
+
end
|
|
768
|
+
|
|
769
|
+
# Check pool stats
|
|
770
|
+
client.provider.pool_stats
|
|
771
|
+
# => { size: 50, available: 45, pending: 0 }
|
|
772
|
+
```
|
|
773
|
+
|
|
774
|
+
### Slow Queries
|
|
775
|
+
|
|
776
|
+
```ruby
|
|
777
|
+
# Add indexes in PostgreSQL
|
|
778
|
+
CREATE INDEX ON documents USING ivfflat (embedding vector_cosine_ops)
|
|
779
|
+
WITH (lists = 100); # Tune based on data size
|
|
780
|
+
|
|
781
|
+
# Monitor with instrumentation
|
|
782
|
+
Vectra.on_operation do |event|
|
|
783
|
+
if event.operation == :query && event.duration > 500
|
|
784
|
+
Rails.logger.warn("Slow query: #{event.duration}ms on #{event.index}")
|
|
785
|
+
end
|
|
786
|
+
end
|
|
787
|
+
```
|