DEV Community

Etop  - Essien Emmanuella Ubokabasi
Etop - Essien Emmanuella Ubokabasi

Posted on

Keywords are not enough: Why Your Next.js App Needs Vector Search

Keywords are not enough thumbnail

What You’ll Learn in This Post

  • BM25 fundamentals and limitations
  • Semantic search with ELSER
  • Code examples for keyword and semantic search
  • Hybrid search implementation
  • Performance considerations
  • Real-world comparisons
  • When to use each approach

When I built my YouTube Search Library, I started with traditional keyword search using Elasticsearch's BM25 algorithm. It worked well—handling typos, highlighting matches, and delivering fast results. But as I tested it with real queries, I noticed a fundamental limitation: keyword search doesn't understand meaning.

The Keyword Search Problem

Here's are scenarios that could happen:

Query: "How do I build a blog?"

BM25 Results: Videos with exact matches for "build" and "blog"

Missed: A video titled "Creating a Content Management System with Next.js" (no keyword overlap, but semantically relevant)

Query: "React Native mobile development"

BM25 Results: Videos containing those exact words

Missed: A video about "Building iOS and Android apps with React Native" (different phrasing, same intent)

Traditional search is lexical—it matches words, not concepts. This is where semantic search changes everything.

Understanding BM25: The Foundation

BM25 (Best Matching 25) is Elasticsearch's default ranking algorithm. It's what powers your multi_match queries:

// Your current implementation (BM25-based)
const response = await client.search({
  index: 'youtube-videos',
  body: {
    query: {
      multi_match: {
        query: "React Native tutorial",
        fields: ['title^3', 'description^2', 'tags^2'],
        fuzziness: 'AUTO',
        type: 'best_fields'
      }
    }
  }
});
Enter fullscreen mode Exit fullscreen mode

How BM25 Works

  1. Term Frequency (TF): More occurrences of a query term = higher score
  2. Inverse Document Frequency (IDF): Rare terms are more valuable than common ones
  3. Field Length Normalization: Prevents longer documents from dominating

Strengths:

  • ✅ Fast and efficient
  • ✅ Great for exact keyword matches
  • ✅ Handles typos with fuzziness
  • ✅ Works out of the box

Limitations:

  • ❌ No understanding of synonyms ("car" ≠ "automobile")
  • ❌ No concept matching ("mobile app" ≠ "iOS development")
  • ❌ Requires exact or similar word forms
  • ❌ Struggles with intent vs. literal terms

Enter Semantic Search: Understanding Meaning

Semantic search uses vector embeddings—mathematical representations of meaning. Instead of matching words, it matches concepts.

What Are Vector Embeddings?

Think of embeddings as coordinates in a high-dimensional space (often 768 or 1536 dimensions). Semantically similar phrases are close together:

"mobile app development" → [0.23, -0.45, 0.67, ...]
"iOS and Android apps"   → [0.25, -0.43, 0.65, ...]  ← Very close!
"cooking recipes"        → [-0.12, 0.89, -0.34, ...]  ← Far away
Enter fullscreen mode Exit fullscreen mode

Elastic's ELSER Model

ELSER (Elastic Learned Sparse Encoder) is Elastic's pre-trained model for semantic search. It's:

  • Sparse: Only activates relevant dimensions (efficient)
  • Learned: Trained on millions of text pairs
  • Zero-shot: Works without fine-tuning on your data
  • Production-ready: Optimized for Elasticsearch

Implementing ELSER in Your Next.js App

Here's how:

Step 1: Deploy ELSER Model

First, deploy the ELSER model to your Elasticsearch cluster:

// Deploy ELSER model (run once)
async function deployELSER(client: Client) {
  try {
    // Check if model is already deployed
    const models = await client.ml.getTrainedModels({ model_id: '.elser_model_2' });
    console.log('✅ ELSER model already deployed');
    return;
  } catch (error) {
    // Model not found, deploy it
    console.log('📦 Deploying ELSER model...');
    await client.ml.putTrainedModel({
      model_id: '.elser_model_2',
      input: {
        field_names: ['text_field']
      }
    });

    // Start the model deployment
    await client.ml.startTrainedModelDeployment({
      model_id: '.elser_model_2',
      wait_for: 'fully_allocated'
    });

    console.log('✅ ELSER model deployed successfully');
  }
}
Enter fullscreen mode Exit fullscreen mode

Step 2: Update Index Mapping

Add an inference pipeline to generate embeddings:

// Update your index creation script
const indexBody = {
  mappings: {
    properties: {
      id: { type: 'keyword' },
      title: {
        type: 'text',
        analyzer: 'standard',
        fields: {
          keyword: { type: 'keyword' },
          // Add semantic search field
          semantic: {
            type: 'sparse_vector'
          }
        }
      },
      description: {
        type: 'text',
        analyzer: 'standard',
        fields: {
          semantic: {
            type: 'sparse_vector'
          }
        }
      },
      // ... other fields
    }
  },
  settings: {
    index: {
      default_pipeline: 'elser-inference-pipeline'
    }
  }
};

// Create inference pipeline
await client.ingest.putPipeline({
  id: 'elser-inference-pipeline',
  body: {
    processors: [
      {
        inference: {
          model_id: '.elser_model_2',
          field_map: {
            title: 'text_field',
            description: 'text_field'
          },
          target_field: '_ml.tokens'
        }
      }
    ]
  }
});
Enter fullscreen mode Exit fullscreen mode

Step 3: Semantic Search Query

Now you can perform semantic search:

// Semantic search with ELSER
const response = await client.search({
  index: 'youtube-videos',
  body: {
    query: {
      text_expansion: {
        'title.semantic': {
          model_id: '.elser_model_2',
          model_text: query  // User's search query
        }
      }
    },
    size: 20
  }
});
Enter fullscreen mode Exit fullscreen mode

Step 4: Hybrid Search (Best of Both Worlds)

Combine BM25 and semantic search for optimal results:

// Hybrid search: BM25 + Semantic
const response = await client.search({
  index: 'youtube-videos',
  body: {
    query: {
      bool: {
        should: [
          // BM25 (keyword matching)
          {
            multi_match: {
              query: query,
              fields: ['title^3', 'description^2', 'tags^2'],
              fuzziness: 'AUTO',
              boost: 1.0
            }
          },
          // Semantic (meaning matching)
          {
            text_expansion: {
              'title.semantic': {
                model_id: '.elser_model_2',
                model_text: query
              },
              boost: 0.5  // Lower boost for semantic
            }
          },
          {
            text_expansion: {
              'description.semantic': {
                model_id: '.elser_model_2',
                model_text: query
              },
              boost: 0.3
            }
          }
        ]
      }
    },
    highlight: {
      fields: {
        title: {},
        description: {}
      }
    }
  }
});
Enter fullscreen mode Exit fullscreen mode

Real-World Comparison

Let's see how BM25 vs. Semantic search handles different queries:

Query: "mobile app development"

BM25 Results:

  1. ✅ "Mobile App Development Tutorial" (exact match)
  2. ✅ "Building Mobile Apps with React Native" (contains "mobile")
  3. ❌ "iOS and Android Development Guide" (no "mobile" keyword)

Semantic Search Results:

  1. ✅ "Mobile App Development Tutorial" (exact match)
  2. ✅ "Building Mobile Apps with React Native" (semantic match)
  3. ✅ "iOS and Android Development Guide" (conceptually related)

Query: "learn React"

BM25 Results:

  1. ✅ "Learn React from Scratch" (exact match)
  2. ❌ "React Tutorial for Beginners" (no "learn" keyword)

Semantic Search Results:

  1. ✅ "Learn React from Scratch" (exact + semantic)
  2. ✅ "React Tutorial for Beginners" (semantic match for "learn")

When to Use Each Approach

Use BM25 When:

  • ✅ Users search with exact technical terms
  • ✅ Speed is critical (BM25 is faster)
  • ✅ You need exact keyword matching
  • ✅ Your content uses consistent terminology
  • ✅ You're searching structured data (tags, categories)

Use Semantic Search When:

  • ✅ Users describe concepts, not keywords
  • ✅ Content uses varied terminology
  • ✅ You want to match intent, not just words
  • ✅ You're searching unstructured text (descriptions, articles)
  • ✅ You need to handle synonyms and related concepts

Use Hybrid Search When:

  • ✅ You want the best of both worlds (recommended)
  • ✅ You have diverse query patterns
  • ✅ You need to balance precision and recall
  • ✅ You're building a production search system

Performance Considerations

BM25:

  • Query time: ~5-20ms
  • Index size: Small (just text)
  • Memory: Minimal

Semantic Search (ELSER):

  • Query time: ~50-150ms (model inference)
  • Index size: Larger (sparse vectors)
  • Memory: Model needs ~2GB RAM

Hybrid:

  • Query time: ~60-170ms (both queries)
  • Best relevance: Combines strengths of both

Implementation in Your API Route

Here's how to update your existing search API to support both:

// app/api/search/route.ts
export async function GET(request: NextRequest) {
  const query = request.nextUrl.searchParams.get('q') || '';
  const searchType = request.nextUrl.searchParams.get('type') || 'hybrid'; // 'bm25', 'semantic', or 'hybrid'

  let searchQuery;

  if (searchType === 'bm25') {
    // Your existing BM25 query
    searchQuery = {
      multi_match: {
        query: query,
        fields: ['title^3', 'description^2', 'tags^2'],
        fuzziness: 'AUTO'
      }
    };
  } else if (searchType === 'semantic') {
    // Pure semantic search
    searchQuery = {
      bool: {
        should: [
          {
            text_expansion: {
              'title.semantic': {
                model_id: '.elser_model_2',
                model_text: query
              }
            }
          },
          {
            text_expansion: {
              'description.semantic': {
                model_id: '.elser_model_2',
                model_text: query
              }
            }
          }
        ]
      }
    };
  } else {
    // Hybrid (recommended)
    searchQuery = {
      bool: {
        should: [
          {
            multi_match: {
              query: query,
              fields: ['title^3', 'description^2', 'tags^2'],
              fuzziness: 'AUTO',
              boost: 1.0
            }
          },
          {
            text_expansion: {
              'title.semantic': {
                model_id: '.elser_model_2',
                model_text: query
              },
              boost: 0.5
            }
          },
          {
            text_expansion: {
              'description.semantic': {
                model_id: '.elser_model_2',
                model_text: query
              },
              boost: 0.3
            }
          }
        ]
      }
    };
  }

  const response = await client.search({
    index: INDEX_NAME,
    body: {
      query: searchQuery,
      highlight: {
        fields: {
          title: {},
          description: {}
        }
      },
      size: 20
    }
  });

  // ... rest of your code
}
Enter fullscreen mode Exit fullscreen mode

The Future of Search

As AI models improve, semantic search is becoming the standard for:

  • E-commerce: "comfortable running shoes" finds relevant products
  • Documentation: "how to handle errors" finds related guides
  • Content platforms: Understanding user intent beyond keywords
  • Enterprise search: Finding information across varied terminology

Conclusion

BM25 is powerful and fast, but semantic search understands meaning. For production apps, hybrid search often provides the best results by combining keyword precision with semantic understanding.

Your search library can evolve from keyword matching to intent understanding. Start with BM25, add semantic search for better relevance, and use hybrid search for the best user experience.


Resources:


Top comments (0)