Semantic Search with Vector Embeddings
Overview
Section titled “Overview”This feature enables AI-powered semantic search across all content types using OpenAI embeddings and pgvector. Users can search by meaning rather than exact keywords, enabling queries like “find showcases about snow melting under pavers” or “videos showing bathroom floor heating installation”.
Architecture
Section titled “Architecture”┌─────────────────────────────────────────────────────────────────┐│ Content Sources ││ Posts, Showcases, Videos, Images, Pages, Products ││ │ ││ Models::Embeddable (Concern) ││ │ ││ ▼ ││ ┌────────────────────────────────────────────────────────────┐ ││ │ content_embeddings (polymorphic) │ ││ │ embedding: vector(1536) with HNSW cosine index │ ││ └────────────────────────────────────────────────────────────┘ ││ │ ││ RubyLLM.embed (text-embedding-3-small) ││ │ ││ SemanticSearchService │└─────────────────────────────────────────────────────────────────┘Components
Section titled “Components”1. Database Tables
Section titled “1. Database Tables”- content_embeddings - Polymorphic table storing vector embeddings
- page_contents - Stores extracted content from static ERB pages
2. Models
Section titled “2. Models”- ContentEmbedding - Core model for storing and querying embeddings
- PageContent - Model for static page content with extraction
3. Concern
Section titled “3. Concern”- Models::Embeddable - Include in any model to enable embedding generation
4. Services
Section titled “4. Services”- SemanticSearchService - High-level search interface
- EmbeddingWorker - Background job for embedding generation
1. Install Dependencies
Section titled “1. Install Dependencies”bundle install # Adds 'neighbor' gem2. Run Migrations
Section titled “2. Run Migrations”bundle exec rails db:migrateThis will:
- Enable the pgvector extension
- Create the content_embeddings table with HNSW index
- Create the page_contents table
3. Generate Initial Embeddings
Section titled “3. Generate Initial Embeddings”# Generate all embeddings (may take 10-30 minutes depending on content volume)bundle exec rake embeddings:all
# Or generate by content typebundle exec rake embeddings:postsbundle exec rake embeddings:showcasesbundle exec rake embeddings:videosbundle exec rake embeddings:imagesbundle exec rake embeddings:pagesBasic Search
Section titled “Basic Search”# Search across all content typesresults = SemanticSearchService.search("snow melting under pavers")
# Search specific typesresults = SemanticSearchService.new("heated driveway", types: ['showcases', 'posts']).search
# Results include similarity scoresresults.each do |r| puts "#{r[:type]}: #{r[:record].name} (#{(r[:similarity] * 100).round}% match)"endConvenience Methods
Section titled “Convenience Methods”# Find showcasesshowcases = SemanticSearchService.find_showcases("bathroom floor heating")
# Find videosvideos = SemanticSearchService.find_videos("installation guide")
# Find blog postsposts = SemanticSearchService.find_posts("heated driveway cost")
# Find productsproducts = SemanticSearchService.find_products("snow melting mat")
# Find pagespages = SemanticSearchService.find_pages("warranty information")Model-Level Search
Section titled “Model-Level Search”# Search within a modelShowcase.semantic_search("modern bathroom radiant heat")
# Find similar contentshowcase = Showcase.find(123)similar = showcase.find_similar(limit: 5)
# Cross-type similarityshowcase.find_similar(same_type_only: false)Manual Embedding Generation
Section titled “Manual Embedding Generation”# Generate embedding for a recordpost.generate_embedding!(:primary)
# Force regenerationpost.generate_embedding!(:primary, force: true)
# Generate all content typesvideo.generate_all_embeddings!
# Check if stalepost.embedding_stale? # => true/falseAdding Embeddable to New Models
Section titled “Adding Embeddable to New Models”class MyModel < ApplicationRecord include Models::Embeddable
# Define content types to embed def self.embeddable_content_types [:primary, :summary] end
# Provide content for embedding def content_for_embedding(content_type = :primary) case content_type.to_sym when :primary [title, description, body].compact.join("\n\n") when :summary short_description end end
private
# Trigger re-embedding when content changes def embedding_content_changed? saved_change_to_title? || saved_change_to_body? endendRake Tasks
Section titled “Rake Tasks”# Generate embeddingsbundle exec rake embeddings:all # All content typesbundle exec rake embeddings:posts # Blog posts onlybundle exec rake embeddings:showcases # Showcases onlybundle exec rake embeddings:videos # Videos onlybundle exec rake embeddings:images # Images onlybundle exec rake embeddings:pages # Static pages only
# Maintenancebundle exec rake embeddings:stats # Show embedding countsbundle exec rake embeddings:refresh_stale # Regenerate stale embeddingsbundle exec rake embeddings:clear # Delete all embeddings (careful!)
# Testingbundle exec rake "embeddings:search[snow melting heated driveway]"Cost Estimation
Section titled “Cost Estimation”Using OpenAI’s text-embedding-3-small at $0.02 per 1M tokens:
| Content Type | Est. Count | Avg Tokens | Est. Cost |
|---|---|---|---|
| Posts | ~500 | 2,000 | ~$0.02 |
| Showcases | ~200 | 500 | ~$0.002 |
| Videos | ~300 | 1,500 | ~$0.009 |
| Images | ~2,000 | 200 | ~$0.008 |
| Pages | ~100 | 3,000 | ~$0.006 |
| Total | ~$0.05 |
Ongoing costs are minimal as embeddings only regenerate when content changes.
Technical Details
Section titled “Technical Details”Embedding Model
Section titled “Embedding Model”- Model: text-embedding-3-small
- Dimensions: 1536
- Max input: ~8,000 tokens (~30,000 characters)
Index Strategy
Section titled “Index Strategy”- Index type: HNSW (Hierarchical Navigable Small World)
- Distance metric: Cosine similarity
- Benefits: Fast queries (~1-5ms), good recall
Content Types
Section titled “Content Types”| Type | Description |
|---|---|
| primary | Main text content (title, description, body) |
| visual | Image/video descriptions for visual search |
| transcript | Full video transcripts |
| specifications | Product specifications |
Troubleshooting
Section titled “Troubleshooting”No results returned
Section titled “No results returned”- Check if embeddings exist:
ContentEmbedding.count - Run embedding generation:
bundle exec rake embeddings:all - Verify OpenAI API key is configured
Slow queries
Section titled “Slow queries”- Ensure HNSW index exists: Check
idx_embeddings_hnsw_cosine - Consider reducing result limit
- Check for missing indexes on polymorphic columns
Stale embeddings
Section titled “Stale embeddings”- Run
bundle exec rake embeddings:statsto check counts - Run
bundle exec rake embeddings:refresh_staleto update
API errors
Section titled “API errors”- Check
log/sidekiq.logfor worker errors - Verify OpenAI API quota
- EmbeddingWorker includes retry logic with exponential backoff
Related Files
Section titled “Related Files”app/models/content_embedding.rbapp/models/page_content.rbapp/concerns/models/embeddable.rbapp/workers/embedding_worker.rbapp/services/semantic_search_service.rblib/tasks/embeddings.rakedb/migrate/20251214200000_enable_pgvector_extension.rbdb/migrate/20251214200001_create_content_embeddings.rbdb/migrate/20251214200002_create_page_contents.rb