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.
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
+ ```