prescient 0.3.0 → 0.5.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.
@@ -1,330 +1,94 @@
1
1
  #!/usr/bin/env ruby
2
2
  # frozen_string_literal: true
3
3
 
4
- # Example: Vector similarity search with Prescient gem and PostgreSQL pgvector
5
- # This example demonstrates how to store embeddings and perform similarity search
4
+ # Example: Vector similarity search with Prescient and PostgreSQL pgvector.
5
+ # The Store owns only its embedding table; applications own their documents.
6
6
 
7
7
  require_relative '../lib/prescient'
8
8
  require 'pg'
9
- require 'json'
10
9
 
11
- puts "=== Vector Similarity Search Example ==="
12
- puts "This example shows how to use Prescient with PostgreSQL pgvector for semantic search."
10
+ puts '=== Vector Similarity Search Example ==='
13
11
 
14
- # Database connection configuration
15
12
  DB_CONFIG = {
16
- host: ENV.fetch('DB_HOST', 'localhost'),
17
- port: ENV.fetch('DB_PORT', '5432'),
18
- dbname: ENV.fetch('DB_NAME', 'prescient_development'),
19
- user: ENV.fetch('DB_USER', 'prescient'),
20
- password: ENV.fetch('DB_PASSWORD', 'prescient_password')
13
+ host: ENV.fetch('DB_HOST', 'localhost'),
14
+ port: ENV.fetch('DB_PORT', '5432'),
15
+ dbname: ENV.fetch('DB_NAME', 'prescient_development'),
16
+ user: ENV.fetch('DB_USER', 'prescient'),
17
+ password: ENV.fetch('DB_PASSWORD', 'prescient_password'),
21
18
  }.freeze
22
19
 
23
20
  class VectorSearchExample
24
- def initialize
25
- @db = PG.connect(DB_CONFIG)
26
- @client = Prescient.client(:ollama)
27
- end
28
-
29
- def run_example
30
- puts "\n--- Setting up vector search example ---"
31
-
32
- # Check if services are available
33
- unless check_services_available
34
- puts "āŒ Required services not available. Please start with: docker compose up -d"
35
- return
36
- end
37
-
38
- # 1. Generate and store embeddings for existing documents
39
- puts "\nšŸ“Š Generating embeddings for sample documents..."
40
- generate_document_embeddings
41
-
42
- # 2. Perform similarity search
43
- puts "\nšŸ” Performing similarity searches..."
44
- search_examples
45
-
46
- # 3. Advanced search with filtering
47
- puts "\nšŸŽÆ Advanced search with metadata filtering..."
48
- advanced_search_examples
49
-
50
- # 4. Demonstrate different distance functions
51
- puts "\nšŸ“ Comparing different distance functions..."
52
- compare_distance_functions
53
-
54
- puts "\nšŸŽ‰ Vector search example completed!"
55
- end
56
-
57
- private
21
+ EMBEDDING_DIMENSIONS = 768
22
+ EMBEDDING_MODEL = 'nomic-embed-text'
58
23
 
59
- def check_services_available
60
- # Check database connection
61
- begin
62
- result = @db.exec("SELECT 1")
63
- puts "āœ… PostgreSQL connected"
64
- rescue PG::Error => e
65
- puts "āŒ PostgreSQL connection failed: #{e.message}"
66
- return false
67
- end
68
-
69
- # Check pgvector extension
70
- begin
71
- result = @db.exec("SELECT * FROM pg_extension WHERE extname = 'vector'")
72
- if result.ntuples > 0
73
- puts "āœ… pgvector extension available"
74
- else
75
- puts "āŒ pgvector extension not found"
76
- return false
77
- end
78
- rescue PG::Error => e
79
- puts "āŒ pgvector check failed: #{e.message}"
80
- return false
81
- end
82
-
83
- # Check Ollama connection
84
- if @client.available?
85
- puts "āœ… Ollama connected"
86
- else
87
- puts "āŒ Ollama not available"
88
- return false
89
- end
90
-
91
- true
24
+ def initialize
25
+ @connection = PG.connect(DB_CONFIG)
26
+ @client = Prescient.client(:ollama, enable_fallback: false)
27
+ @store = Prescient::Pgvector::Store.new(
28
+ connection: @connection,
29
+ dimensions: EMBEDDING_DIMENSIONS,
30
+ )
92
31
  end
93
32
 
94
- def generate_document_embeddings
95
- # Get documents that don't have embeddings yet
96
- query = <<~SQL
97
- SELECT d.id, d.title, d.content
98
- FROM documents d
99
- LEFT JOIN document_embeddings de ON d.id = de.document_id
100
- AND de.embedding_provider = 'ollama'
101
- AND de.embedding_model = 'nomic-embed-text'
102
- WHERE de.id IS NULL
103
- LIMIT 10
104
- SQL
105
-
106
- result = @db.exec(query)
107
-
108
- if result.ntuples == 0
109
- puts " All documents already have embeddings"
110
- return
33
+ def run
34
+ @store.install!
35
+ %i[cosine euclidean inner_product].each { |metric| @store.create_index!(metric:) }
36
+
37
+ documents.each do |document|
38
+ embedding = @client.generate_embedding(document[:content], model: EMBEDDING_MODEL)
39
+ @store.upsert(
40
+ id: document[:id],
41
+ embedding:,
42
+ provider: 'ollama',
43
+ model: EMBEDDING_MODEL,
44
+ content: document[:content],
45
+ metadata: { title: document[:title] },
46
+ )
111
47
  end
112
48
 
113
- result.each do |row|
114
- document_id = row['id']
115
- title = row['title']
116
- content = row['content']
117
-
118
- puts " Generating embedding for: #{title}"
119
-
120
- begin
121
- # Generate embedding using Prescient
122
- embedding = @client.generate_embedding(content)
123
-
124
- # Store in database
125
- insert_embedding(document_id, embedding, content, 'ollama', 'nomic-embed-text', 768)
126
-
127
- puts " āœ… Stored embedding (#{embedding.length} dimensions)"
128
-
129
- rescue Prescient::Error => e
130
- puts " āŒ Failed to generate embedding: #{e.message}"
131
- end
49
+ query = 'How do I improve database performance?'
50
+ embedding = @client.generate_embedding(query, model: EMBEDDING_MODEL)
51
+ results = @store.search(
52
+ embedding:,
53
+ limit: 3,
54
+ metric: :cosine,
55
+ provider: 'ollama',
56
+ model: EMBEDDING_MODEL,
57
+ )
58
+
59
+ puts "\nQuery: #{query}"
60
+ results.each_with_index do |result, index|
61
+ title = result[:metadata].fetch('title')
62
+ puts "#{index + 1}. #{title} (distance: #{result[:distance].round(4)})"
63
+ puts " #{result[:content]}"
132
64
  end
65
+ rescue Prescient::Error, PG::Error => e
66
+ warn "Vector search failed: #{e.message}"
67
+ ensure
68
+ @connection&.close
133
69
  end
134
70
 
135
- def insert_embedding(document_id, embedding, text, provider, model, dimensions)
136
- # Convert Ruby array to PostgreSQL vector format
137
- vector_str = "[#{embedding.join(',')}]"
138
-
139
- query = <<~SQL
140
- INSERT INTO document_embeddings
141
- (document_id, embedding_provider, embedding_model, embedding_dimensions, embedding, embedding_text)
142
- VALUES ($1, $2, $3, $4, $5, $6)
143
- SQL
144
-
145
- @db.exec_params(query, [document_id, provider, model, dimensions, vector_str, text])
146
- end
71
+ private
147
72
 
148
- def search_examples
149
- search_queries = [
150
- "How to learn programming?",
151
- "What is machine learning?",
152
- "Database optimization techniques",
153
- "API security best practices"
73
+ def documents
74
+ [
75
+ {
76
+ id: 'postgres-indexes',
77
+ title: 'PostgreSQL indexes',
78
+ content: 'Indexes can reduce query latency when their columns match common filters and ordering.',
79
+ },
80
+ {
81
+ id: 'ruby-performance',
82
+ title: 'Ruby performance',
83
+ content: 'Measure allocations and repeated work before optimizing a Ruby application.',
84
+ },
85
+ {
86
+ id: 'api-security',
87
+ title: 'API security',
88
+ content: 'Protect API credentials, validate inputs, and avoid exposing provider response bodies.',
89
+ },
154
90
  ]
155
-
156
- search_queries.each do |query_text|
157
- puts "\nšŸ” Searching for: '#{query_text}'"
158
- perform_similarity_search(query_text, limit: 3)
159
- end
160
- end
161
-
162
- def perform_similarity_search(query_text, limit: 5, distance_function: 'cosine')
163
- begin
164
- # Generate embedding for query
165
- query_embedding = @client.generate_embedding(query_text)
166
- query_vector = "[#{query_embedding.join(',')}]"
167
-
168
- # Choose distance operator based on function
169
- distance_op = case distance_function
170
- when 'cosine' then '<=>'
171
- when 'l2' then '<->'
172
- when 'inner_product' then '<#>'
173
- else '<=>'
174
- end
175
-
176
- # Perform similarity search
177
- search_query = <<~SQL
178
- SELECT
179
- d.title,
180
- d.content,
181
- d.metadata,
182
- de.embedding #{distance_op} $1::vector AS distance,
183
- 1 - (de.embedding <=> $1::vector) AS cosine_similarity
184
- FROM documents d
185
- JOIN document_embeddings de ON d.id = de.document_id
186
- WHERE de.embedding_provider = 'ollama'
187
- AND de.embedding_model = 'nomic-embed-text'
188
- ORDER BY de.embedding #{distance_op} $1::vector
189
- LIMIT $2
190
- SQL
191
-
192
- result = @db.exec_params(search_query, [query_vector, limit])
193
-
194
- if result.ntuples == 0
195
- puts " No results found"
196
- return
197
- end
198
-
199
- result.each_with_index do |row, index|
200
- similarity = (row['cosine_similarity'].to_f * 100).round(1)
201
- puts " #{index + 1}. #{row['title']} (#{similarity}% similar)"
202
- puts " #{row['content'][0..100]}..."
203
-
204
- # Show metadata if available
205
- if row['metadata'] && !row['metadata'].empty?
206
- metadata = JSON.parse(row['metadata'])
207
- tags = metadata['tags']&.join(', ')
208
- puts " Tags: #{tags}" if tags
209
- end
210
- puts
211
- end
212
-
213
- rescue Prescient::Error => e
214
- puts " āŒ Search failed: #{e.message}"
215
- rescue PG::Error => e
216
- puts " āŒ Database error: #{e.message}"
217
- end
218
- end
219
-
220
- def advanced_search_examples
221
- # Search with metadata filtering
222
- puts "\nšŸŽÆ Search for programming content with beginner difficulty:"
223
- advanced_search("programming basics", tags: ["programming"], difficulty: "beginner")
224
-
225
- puts "\nšŸŽÆ Search for AI/ML content:"
226
- advanced_search("artificial intelligence", tags: ["ai", "machine-learning"])
227
91
  end
228
-
229
- def advanced_search(query_text, filters = {})
230
- begin
231
- query_embedding = @client.generate_embedding(query_text)
232
- query_vector = "[#{query_embedding.join(',')}]"
233
-
234
- # Build WHERE clause for metadata filtering
235
- where_conditions = ["de.embedding_provider = 'ollama'", "de.embedding_model = 'nomic-embed-text'"]
236
- params = [query_vector]
237
- param_index = 2
238
-
239
- filters.each do |key, value|
240
- case key
241
- when :tags
242
- # Filter by tags array overlap
243
- where_conditions << "d.metadata->'tags' ?| $#{param_index}::text[]"
244
- params << value
245
- param_index += 1
246
- when :difficulty
247
- # Filter by exact difficulty match
248
- where_conditions << "d.metadata->>'difficulty' = $#{param_index}"
249
- params << value
250
- param_index += 1
251
- when :source_type
252
- # Filter by source type
253
- where_conditions << "d.source_type = $#{param_index}"
254
- params << value
255
- param_index += 1
256
- end
257
- end
258
-
259
- search_query = <<~SQL
260
- SELECT
261
- d.title,
262
- d.content,
263
- d.metadata,
264
- de.embedding <=> $1::vector AS cosine_distance,
265
- 1 - (de.embedding <=> $1::vector) AS cosine_similarity
266
- FROM documents d
267
- JOIN document_embeddings de ON d.id = de.document_id
268
- WHERE #{where_conditions.join(' AND ')}
269
- ORDER BY de.embedding <=> $1::vector
270
- LIMIT 3
271
- SQL
272
-
273
- result = @db.exec_params(search_query, params)
274
-
275
- if result.ntuples == 0
276
- puts " No results found with the specified filters"
277
- return
278
- end
279
-
280
- result.each_with_index do |row, index|
281
- similarity = (row['cosine_similarity'].to_f * 100).round(1)
282
- puts " #{index + 1}. #{row['title']} (#{similarity}% similar)"
283
-
284
- metadata = JSON.parse(row['metadata'])
285
- puts " Difficulty: #{metadata['difficulty']}"
286
- puts " Tags: #{metadata['tags']&.join(', ')}"
287
- puts " #{row['content'][0..80]}..."
288
- puts
289
- end
290
-
291
- rescue Prescient::Error => e
292
- puts " āŒ Search failed: #{e.message}"
293
- rescue PG::Error => e
294
- puts " āŒ Database error: #{e.message}"
295
- end
296
- end
297
-
298
- def compare_distance_functions
299
- query_text = "programming languages and development"
300
-
301
- puts "\nšŸ“ Comparing distance functions for: '#{query_text}'"
302
-
303
- %w[cosine l2 inner_product].each do |distance_func|
304
- puts "\n #{distance_func.upcase} Distance:"
305
- perform_similarity_search(query_text, limit: 2, distance_function: distance_func)
306
- end
307
- end
308
-
309
- def cleanup
310
- @db.close if @db
311
- end
312
- end
313
-
314
- # Run the example
315
- begin
316
- example = VectorSearchExample.new
317
- example.run_example
318
- rescue StandardError => e
319
- puts "āŒ Example failed: #{e.message}"
320
- puts e.backtrace.first(5).join("\n")
321
- ensure
322
- example&.cleanup
323
92
  end
324
93
 
325
- puts "\nšŸ’” Next steps:"
326
- puts " - Try different embedding models (OpenAI, HuggingFace)"
327
- puts " - Implement hybrid search (vector + keyword)"
328
- puts " - Add document chunking for large texts"
329
- puts " - Experiment with different similarity thresholds"
330
- puts " - Add result re-ranking and filtering"
94
+ VectorSearchExample.new.run
data/exe/prescient ADDED
@@ -0,0 +1,7 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require 'prescient'
5
+ require 'prescient/cli'
6
+
7
+ exit Prescient::CLI.run(ARGV)