The Problem: Traditional Search is Broken
Picture this: You're building a recipe website. You've spent months curating hundreds of amazing recipes. A user visits your site and searches for "comfort food for cold days." Your search engine returns nothing. Not because you don't have the perfect recipes, but because none of your content contains those exact words.
Meanwhile, buried in your database is "Hearty Beef Stew - Perfect for Winter Evenings," "Grandma's Chicken Soup That Warms the Soul," and "Ultimate Mac and Cheese for Cozy Nights." These are exactly what the user wants, but your search can't connect the dots.
Why This Keeps Getting Worse
This isn't just frustrating for users. It's costing you real business. Every failed search is a potential customer who leaves empty-handed. You try to fix it by adding synonyms, building keyword lists, and tweaking your fuzzy matching algorithms. But the problem keeps growing.
Here's why traditional search falls apart:
The exact word matching trap. Traditional search engines, even sophisticated ones with fuzzy matching, fundamentally work by looking for words that appear in both the query and your content. Fuzzy search algorithms like Levenshtein distance can catch typos and slight misspellings. If someone searches for "comfrot food," it can figure out they meant "comfort food." But that's where the magic ends. These algorithms measure how many character edits it takes to transform one word into another. They're blind to meaning.
When a user searches for "budget-friendly recipes," your fuzzy search won't find articles titled "Affordable Meal Ideas" or "Cooking on a Shoestring." Even though any human would instantly recognize these as the same concept, to the algorithm, they're completely different strings of characters. You could have the world's most comprehensive guide to cheap cooking, but if it doesn't contain the exact phrase "budget-friendly," the search will miss it.
The vocabulary explosion problem. English speakers can express the same idea in dozens of different ways. "Inexpensive," "cheap," "affordable," "economical," "cost-effective," "won't break the bank," "easy on the wallet," and "thrifty" all mean roughly the same thing. Now multiply this across every possible search term. You'd need to manually map every synonym to every concept in your content. Even major companies with teams of engineers can't keep up with this. Language is simply too rich and creative.
It gets worse when you consider phrases and context. "Quick and easy meals" could also be expressed as "fast recipes," "simple dinners," "weeknight cooking," "minimal effort food," or "no-fuss meals." You'd need an enormous database of synonyms and related terms, and you'd still miss creative phrasings you never thought of.
Context disappears completely. The word "apple" appears in both "apple pie recipe" and "apple software engineer job posting." Traditional search treats these identically because they both contain the word "apple." It has no understanding that one is about fruit-based desserts and the other is about employment at a technology company. The word "light" could mean low-calorie food, illumination, pale colors, or something that weighs very little. Keyword matching can't distinguish between these meanings.
This context problem becomes especially painful in longer queries. When someone searches for "healthy dinner ideas for picky kids who hate vegetables," a traditional search might find articles containing some of those words. But it can't understand that the user is looking for sneaky ways to incorporate nutrition into meals that children will actually eat. It might return a scientific article about childhood nutrition that contains all the right keywords but completely misses the user's actual need.
The Solution: Teaching Computers to Understand Meaning
What if instead of matching words, you could match meaning? What if your search engine could understand that "comfort food" and "hearty meal" represent the same concept? What if it knew that someone searching for "angry movie quote" might want "You can't handle the truth!" even though those exact words don't appear in the quote?
This is exactly what text embeddings make possible.
What Are Embeddings? (The Simple Explanation)
Think about how you'd organize books in a library. Books about similar topics would go on nearby shelves. All the cookbooks cluster together. Mystery novels sit in one section. Science textbooks occupy another area. You're organizing books in physical space based on their content.
Text embeddings do the same thing, but for any piece of text. They convert text into a list of numbers (a mathematical vector) that represents its meaning. Texts with similar meanings get similar numbers.
Here's a concrete example:
"Pizza recipe" → [0.2, 0.8, 0.1, 0.3, ...]
"How to make pizza" → [0.19, 0.81, 0.09, 0.29, ...]
"Buying a car" → [0.7, 0.1, 0.5, 0.8, ...]
These are simplified, but real embeddings typically have 384, 768, or even 1536 numbers. Each number represents some aspect of the text's meaning that the AI model learned. You don't need to understand what each number means individually. What matters is the pattern: similar meanings produce similar patterns of numbers.
Looking at the example above, "Pizza recipe" and "How to make pizza" have very similar numbers because they mean nearly the same thing. "Buying a car" has completely different numbers because it's about an unrelated topic.
The magic happens because you can measure how similar or different these number patterns are. You can calculate the distance between any two embeddings, just like you can measure the physical distance between two houses using their GPS coordinates. Close together means similar meaning. Far apart means unrelated.
How Embeddings Learn Meaning
These number patterns aren't random. They're created by large AI models that have read millions of documents from across the internet. Through this reading, the models learn that:
- "Dog" and "puppy" appear in similar contexts (pet ownership, training, veterinary care)
- "Running" and "jogging" are used interchangeably in articles about exercise
- "Cold winter day" and "cozy weather" both relate to staying warm, comfort food, and indoor activities
- "Expensive" and "cheap" appear in opposite contexts (luxury vs budget, high-end vs affordable)
The model compresses all this learned knowledge into the embedding numbers. When you convert new text into an embedding, it applies everything it learned to generate numbers that capture the text's meaning.
How Search Works with Embeddings
Once you understand that embeddings turn meaning into numbers, search becomes straightforward:
Step 1: Build Your Search Index
Before anyone searches, you convert all your content into embeddings. If you have 1,000 recipes, you generate 1,000 embeddings and store them. This happens once, or whenever you add new content.
Step 2: Convert the Search Query
When a user searches for "comfort food for cold days," you convert their query into an embedding using the same AI model. Now their search is also a list of numbers representing what they're looking for.
Step 3: Find Similar Embeddings
You compare the query embedding against all your stored content embeddings. You calculate how close each one is to the query using a similarity measure like cosine similarity. This gives you a score for each piece of content showing how well it matches the query's meaning.
Step 4: Return the Best Matches
Sort your content by similarity score and show the top results. The recipes with embeddings closest to the query embedding are the ones that mean the most similar thing, even if they use completely different words.
The beautiful part: this works regardless of the specific words used. "Comfort food," "hearty meals," "cozy dinners," and "soul-warming dishes" all generate similar embeddings because they mean similar things.
Let's Build It! 🍿 A Movie Quote Search Engine
Theory is fine, but let's build something real. We'll create a semantic search for iconic movie quotes. Users can search using their own words and find matching quotes based on meaning, not exact word matches.
This is perfect for demonstrating semantic search because movie quotes rarely contain the words people use to describe them. Nobody searches for "May the Force be with you" by typing those exact words. They search for "inspirational space movie quote" or "wishing someone luck."
Our Fun Dataset
// data/movie-quotes.ts export const movieQuotes = [ { id: 1, quote: 'May the Force be with you.', movie: 'Star Wars', character: 'Various' }, { id: 2, quote: "I'm going to make him an offer he can't refuse.", movie: 'The Godfather', character: 'Don Vito Corleone' }, { id: 3, quote: "Here's looking at you, kid.", movie: 'Casablanca', character: 'Rick Blaine' }, { id: 4, quote: "You can't handle the truth!", movie: 'A Few Good Men', character: 'Col. Jessep' }, { id: 5, quote: "I'll be back.", movie: 'The Terminator', character: 'The Terminator' }, { id: 6, quote: 'Life is like a box of chocolates.', movie: 'Forrest Gump', character: 'Forrest Gump' }, { id: 7, quote: 'You talking to me?', movie: 'Taxi Driver', character: 'Travis Bickle' }, { id: 8, quote: 'To infinity and beyond!', movie: 'Toy Story', character: 'Buzz Lightyear' }, { id: 9, quote: 'I see dead people.', movie: 'The Sixth Sense', character: 'Cole Sear' }, { id: 10, quote: 'Houston, we have a problem.', movie: 'Apollo 13', character: 'Jim Lovell' } ]
Step 1: Project Setup
npx create-next-app@latest movie-quote-search cd movie-quote-search npm install ai @ai-sdk/openai # OR for local embeddings: npm install fastembed
Step 2: Generate Embeddings (Cloud Option with Vercel AI SDK)
We need to convert each movie quote into an embedding. We'll do this once and save the results to a file. This way, we don't need to regenerate embeddings every time someone searches.
// scripts/generate-embeddings-cloud.ts import { openai } from '@ai-sdk/openai' import { embed } from 'ai' import fs from 'fs/promises' import { movieQuotes } from '../data/movie-quotes' async function generateEmbeddings() { console.log('Generating embeddings for movie quotes...') const quotesWithEmbeddings = [] for (const quote of movieQuotes) { // Convert the quote text into an embedding const { embedding } = await embed({ model: openai.embedding('text-embedding-3-small'), value: quote.quote }) quotesWithEmbeddings.push({ ...quote, embedding }) console.log( `✓ Generated embedding for: "${quote.quote.substring(0, 50)}..."` ) } // Save to file so we don't need to regenerate every time await fs.writeFile( 'data/quotes-with-embeddings.json', JSON.stringify(quotesWithEmbeddings, null, 2) ) console.log( `\n✅ Saved ${quotesWithEmbeddings.length} quotes with embeddings!` ) } generateEmbeddings()
Step 2 Alternative: Generate Embeddings (Local Option with FastEmbed)
If you want to run everything locally without sending data to OpenAI, you can use FastEmbed. It downloads an open-source embedding model and runs it on your machine.
// scripts/generate-embeddings-local.ts import { EmbeddingModel, FlagEmbedding } from 'fastembed' import fs from 'fs/promises' import { movieQuotes } from '../data/movie-quotes' async function generateEmbeddingsLocal() { console.log('Initializing local embedding model...') // Initialize the model (downloads on first run, ~90MB) const embeddingModel = await FlagEmbedding.init({ model: EmbeddingModel.BGESmallENV15 }) console.log('Generating embeddings for movie quotes...') // Process all quotes at once (more efficient) const texts = movieQuotes.map((q) => q.quote) const embeddings = await embeddingModel.embed(texts) const quotesWithEmbeddings = movieQuotes.map((quote, index) => ({ ...quote, embedding: Array.from(embeddings[index]) })) await fs.writeFile( 'data/quotes-with-embeddings.json', JSON.stringify(quotesWithEmbeddings, null, 2) ) console.log(`✅ Saved ${quotesWithEmbeddings.length} quotes with embeddings!`) } generateEmbeddingsLocal()
Add scripts to package.json:
{ "scripts": { "generate-embeddings": "tsx scripts/generate-embeddings-cloud.ts", "generate-embeddings:local": "tsx scripts/generate-embeddings-local.ts" } }
Run whichever approach you prefer:
npm run generate-embeddings # OR npm run generate-embeddings:local
Step 3: Build the Search Function
Now we need a function that can compare embeddings and find the most similar ones. This is where cosine similarity comes in.
Cosine similarity measures the angle between two vectors (our embeddings). It returns a score from -1 to 1:
- 1 means the vectors point in exactly the same direction (identical meaning)
- 0 means they're perpendicular (unrelated)
- -1 means they point in opposite directions (opposite meaning)
// lib/search.ts import quotesData from '@/data/quotes-with-embeddings.json' interface Quote { id: number quote: string movie: string character: string embedding: number[] } const quotes = quotesData as Quote[] /** * Calculate cosine similarity between two vectors * Returns a score from -1 to 1 (1 = identical, 0 = unrelated, -1 = opposite) */ function cosineSimilarity(vecA: number[], vecB: number[]): number { // Dot product: multiply corresponding elements and sum them const dotProduct = vecA.reduce((sum, a, i) => sum + a * vecB[i], 0) // Magnitude (length) of each vector using Euclidean distance const magnitudeA = Math.sqrt(vecA.reduce((sum, a) => sum + a * a, 0)) const magnitudeB = Math.sqrt(vecB.reduce((sum, b) => sum + b * b, 0)) // Cosine similarity is dot product divided by product of magnitudes return dotProduct / (magnitudeA * magnitudeB) } interface SearchResult { quote: Quote similarity: number } /** * Search for quotes using semantic similarity */ export async function searchQuotes( queryEmbedding: number[], limit: number = 5 ): Promise<SearchResult[]> { // Calculate similarity between query and each quote const results = quotes.map((quote) => ({ quote, similarity: cosineSimilarity(queryEmbedding, quote.embedding) })) // Sort by similarity (highest first) and limit results return results.sort((a, b) => b.similarity - a.similarity).slice(0, limit) }
Step 4: Create the Search API Route
This API endpoint handles search requests. It takes the user's query, converts it to an embedding, and finds similar quotes.
// app/api/search/route.ts import { openai } from '@ai-sdk/openai' import { embed } from 'ai' import { searchQuotes } from '@/lib/search' export async function POST(request: Request) { const { query } = await request.json() if (!query || typeof query !== 'string') { return Response.json({ error: 'Query is required' }, { status: 400 }) } // Convert user's search query into an embedding const { embedding } = await embed({ model: openai.embedding('text-embedding-3-small'), value: query }) // Find quotes with similar embeddings const results = await searchQuotes(embedding) return Response.json({ results }) }
For local embeddings, use this version instead:
// app/api/search/route.ts (local version) import { EmbeddingModel, FlagEmbedding } from 'fastembed' import { searchQuotes } from '@/lib/search' // Initialize model once and reuse it (avoids reloading for each request) let embeddingModel: FlagEmbedding | null = null async function getEmbeddingModel() { if (!embeddingModel) { embeddingModel = await FlagEmbedding.init({ model: EmbeddingModel.BGESmallENV15 }) } return embeddingModel } export async function POST(request: Request) { const { query } = await request.json() if (!query || typeof query !== 'string') { return Response.json({ error: 'Query is required' }, { status: 400 }) } const model = await getEmbeddingModel() const embeddings = await model.embed([query]) const embedding = Array.from(embeddings[0]) const results = await searchQuotes(embedding) return Response.json({ results }) }
Step 5: Build the Search UI
Finally, we need a user interface where people can type their searches and see results.
// app/page.tsx 'use client' import { useState } from 'react' interface SearchResult { quote: { id: number quote: string movie: string character: string } similarity: number } export default function Home() { const [query, setQuery] = useState('') const [results, setResults] = useState<SearchResult[]>([]) const [loading, setLoading] = useState(false) const handleSearch = async (e: React.FormEvent) => { e.preventDefault() if (!query.trim()) return setLoading(true) try { const response = await fetch('/api/search', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ query }) }) const data = await response.json() setResults(data.results) } catch (error) { console.error('Search failed:', error) } finally { setLoading(false) } } return ( <main className="min-h-screen p-8 max-w-4xl mx-auto"> <div className="mb-8"> <h1 className="text-4xl font-bold mb-2">🎬 Movie Quote Search</h1> <p className="text-gray-600"> Search for movie quotes using natural language! Try "encouraging words" or "angry outburst" </p> </div> <form onSubmit={handleSearch} className="mb-8"> <div className="flex gap-2"> <input type="text" value={query} onChange={(e) => setQuery(e.target.value)} placeholder="Describe what you're looking for..." className="flex-1 px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500" /> <button type="submit" disabled={loading} className="px-6 py-2 bg-blue-500 text-white rounded-lg hover:bg-blue-600 disabled:opacity-50" > {loading ? 'Searching...' : 'Search'} </button> </div> </form> {results.length > 0 && ( <div className="space-y-4"> <h2 className="text-2xl font-semibold">Results</h2> {results.map(({ quote, similarity }) => ( <div key={quote.id} className="p-4 border border-gray-200 rounded-lg hover:shadow-lg transition-shadow" > <div className="flex justify-between items-start mb-2"> <blockquote className="text-lg font-medium italic"> "{quote.quote}" </blockquote> <span className="text-sm text-gray-500 ml-4"> {(similarity * 100).toFixed(1)}% match </span> </div> <div className="text-sm text-gray-600"> <span className="font-semibold">{quote.character}</span> {' in '} <span className="italic">{quote.movie}</span> </div> </div> ))} </div> )} {results.length === 0 && !loading && ( <div className="text-center text-gray-500 py-12"> Try searching for "words of wisdom", "threatening someone", or "space adventure"! </div> )} </main> ) }
Step 6: Try It Out! 🎉
Start your development server:
npm run dev
Now try these searches and watch the magic happen:
"words of wisdom" This should find Forrest Gump's "Life is like a box of chocolates." Notice that the search query contains none of the words from the actual quote, yet it finds it because the embedding understands that life advice and wisdom are related concepts.
"threatening someone" You'll get "I'll be back" from The Terminator and "You can't handle the truth!" from A Few Good Men. Both quotes have a threatening quality, even though they express it in completely different ways.
"space adventure" This returns "May the Force be with you" from Star Wars and "To infinity and beyond!" from Toy Story. The embedding model learned that both relate to space-themed stories.
"supernatural experience" Finds "I see dead people" from The Sixth Sense. Again, none of these exact words appear in the quote.
"confident challenge" Returns "You talking to me?" from Taxi Driver. The embedding captures the confrontational, self-assured tone.
The magic here is undeniable. None of your search terms appear in the actual quotes, yet the search finds semantically similar content every time. That's the power of understanding meaning instead of matching keywords.
Scaling Up: Using Pinecone for Production
Our simple file-based approach works great for 10 movie quotes. But what happens when you have 10,000 blog posts? Or 100,000 product descriptions? Loading all those embeddings into memory and comparing them one by one becomes slow and impractical.
This is where vector databases like Pinecone come in. They're built specifically to store and search embeddings efficiently at massive scale.
Why You Need a Vector Database
When you search through embeddings stored in a JSON file, you have to:
- Load all embeddings into memory
- Calculate similarity between your query and every single embedding
- Sort all the results
With 100,000 items and 768-dimensional embeddings, this means loading ~300MB of data and doing 100,000 similarity calculations for every search. It works, but it's slow.
Vector databases use specialized algorithms like Hierarchical Navigable Small World (HNSW) graphs to search through millions of embeddings in milliseconds. Instead of checking every embedding, they use clever indexing to jump straight to the most promising candidates.
Setup Pinecone
First, sign up for Pinecone and create an index. Choose these settings:
- Dimensions: 1536 (for OpenAI's text-embedding-3-small)
- Metric: Cosine
Then install the client:
npm install @pinecone-database/pinecone
Create a Pinecone client:
// lib/pinecone.ts import { Pinecone } from '@pinecone-database/pinecone' export const pinecone = new Pinecone({ apiKey: process.env.PINECONE_API_KEY! }) export const index = pinecone.index('movie-quotes')
Upload Embeddings to Pinecone
Instead of saving embeddings to a JSON file, upload them to Pinecone:
// scripts/upload-to-pinecone.ts import { openai } from '@ai-sdk/openai' import { embed } from 'ai' import { index } from '../lib/pinecone' import { movieQuotes } from '../data/movie-quotes' async function uploadToPinecone() { console.log('Uploading to Pinecone...') for (const quote of movieQuotes) { // Generate embedding const { embedding } = await embed({ model: openai.embedding('text-embedding-3-small'), value: quote.quote }) // Upload to Pinecone await index.upsert([ { id: quote.id.toString(), values: embedding, metadata: { quote: quote.quote, movie: quote.movie, character: quote.character } } ]) console.log(`✓ Uploaded: ${quote.quote}`) } console.log('✅ All quotes uploaded!') } uploadToPinecone()
Search with Pinecone
Update your search API to query Pinecone instead of a local file:
// app/api/search/route.ts (Pinecone version) import { openai } from '@ai-sdk/openai' import { embed } from 'ai' import { index } from '@/lib/pinecone' export async function POST(request: Request) { const { query } = await request.json() // Generate query embedding const { embedding } = await embed({ model: openai.embedding('text-embedding-3-small'), value: query }) // Search Pinecone (much faster than comparing all embeddings) const results = await index.query({ vector: embedding, topK: 5, includeMetadata: true }) // Format results const formattedResults = results.matches.map((match) => ({ quote: match.metadata, similarity: match.score })) return Response.json({ results: formattedResults }) }
The search interface stays exactly the same. But now you can handle millions of embeddings with consistent millisecond response times.
Benefits of Pinecone
Performance at scale. Pinecone can search through millions of embeddings in under 100ms. As your content grows, search speed stays consistent because of efficient indexing algorithms.
Automatic scaling. Your dataset grows from 1,000 items to 10 million? Pinecone handles it automatically. No infrastructure changes needed.
Advanced filtering. You can add metadata filters to narrow searches before semantic matching. For example, only search blog posts from the last month, or only in a specific category.
Managed infrastructure. No servers to maintain, no indexes to optimize manually. Pinecone handles all the complexity.
Built-in optimizations. Features like namespace isolation for multi-tenant apps, hybrid search combining keywords and semantics, and automatic backups.
Key Takeaways
Traditional search is fundamentally limited. Fuzzy matching and keyword search can only catch spelling variations. They can't understand that "budget-friendly" and "affordable" mean the same thing, or that "comfort food" relates to "hearty meals."
Embeddings convert meaning into comparable numbers. Instead of comparing words, you compare the meaning they represent. Similar meanings produce similar numbers, which you can measure mathematically.
Semantic search finds meaning, not keywords. By comparing embeddings, you can find content that matches what users mean, even when they use completely different words than appear in your content.
Cosine similarity measures how close meanings are. This mathematical measure tells you how similar two embeddings are, letting you rank search results by semantic relevance.
Start simple, then scale. For small datasets, storing embeddings in JSON files works fine. When you grow, vector databases like Pinecone provide the infrastructure to search millions of items efficiently.
The implementation is surprisingly straightforward. With modern tools like Vercel's AI SDK and FastEmbed, you can build semantic search in an afternoon. The hard work of training embedding models is already done for you.
Performance and Cost Optimization
Batch embedding generation. When you need to embed multiple pieces of content, batch them together. Most embedding APIs let you process multiple texts in one request, which is much faster and often cheaper than individual requests.
Cache embeddings aggressively. Embeddings for a piece of text never change unless the text changes. Store them permanently and only regenerate when content is edited. Never generate the same embedding twice.
Choose the right model size. Larger embedding models (1536 dimensions) are more accurate but slower and more expensive. Smaller models (384 dimensions) are faster and cheaper. For many use cases, smaller models work great. Test to find the right balance.
Pre-compute everything possible. Generate all your content embeddings at build time or when content is created. Only generate embeddings on-demand for user queries, which you can't know in advance.
Consider local models for sensitive data. If you're working with confidential or private information, local embedding models like those from FastEmbed let you keep everything on your own infrastructure. No data leaves your servers.
Add metadata filtering first. If users can filter by date, category, or other metadata, apply those filters before semantic search. Searching through 1,000 filtered items is faster than searching 100,000, even with efficient vector databases.
When to Use Semantic Search
Semantic search shines in specific scenarios, but it's not always the right tool.
Excellent for content discovery. When users don't know exactly what they're looking for, or can't articulate it precisely, semantic search helps them find relevant content. "Articles about getting better at public speaking" will find content about presentation skills, overcoming stage fright, and communication techniques.
Perfect for question answering systems. Users ask questions in natural language, and you need to find relevant documentation, FAQs, or knowledge base articles. The question and answer might use completely different words, but semantic search finds the connection.
Great for recommendations. "Show me content similar to this article" becomes trivial. Just find items with embeddings close to the article's embedding.
Essential for multi-language search. Modern embedding models can understand multiple languages. A search in English can find relevant content in Spanish, French, or other languages if the embedding model was trained multilingually.
Useful for support ticket routing. Automatically categorize incoming support tickets by finding similar historical tickets, then route them to the team that handled those tickets.
Not ideal for exact matching. If users need to find an exact phrase, email address, product SKU, or other precise value, traditional search works better. "Find transactions with invoice number INV-2024-001" should use exact string matching, not semantic search.
Overkill for structured data queries. Searching a database of products by price range, color, or size? Use traditional database queries (SQL WHERE clauses). They're faster, more precise, and more appropriate for structured data.
Insufficient when explainability matters. With keyword search, you can show users exactly why a result matched (highlighted keywords). With semantic search, the explanation is "the embeddings were mathematically similar," which is less intuitive. In legal or compliance contexts, this lack of transparency can be problematic.
Next Steps and Advanced Techniques
Once you have basic semantic search working, several advanced techniques can improve results.
Hybrid search combines the best of both worlds. Use both keyword matching and semantic search, then merge the results. This catches both exact matches and semantically similar content. Pinecone and other vector databases offer hybrid search built-in.
Re-ranking improves result quality. After finding candidates with semantic search, use a more sophisticated model (called a cross-encoder) to re-rank the top results. Cross-encoders are slower but more accurate because they analyze the query and each result together.
Fine-tuning embeddings on your domain. The pre-trained embedding models are general-purpose. If you have specialized content (medical, legal, scientific), you can fine-tune an embedding model on your domain-specific data for better results.
Multi-modal embeddings combine text and images. Models like CLIP create embeddings that work for both text and images. You can search for images using text queries, or find similar images based on visual content.
Metadata filtering adds precision. Combine semantic search with traditional filters. "Find articles about machine learning (semantic) published in 2024 (filter) by author Jane Smith (filter)."
Query expansion improves recall. Before searching, expand the user's query by generating related terms or reformulating it. "cheap restaurants" could expand to "affordable restaurants budget-friendly dining inexpensive meals."
Wrapping Up
Semantic search transforms how users interact with your content. Instead of forcing them to guess the right keywords, you let them describe what they're looking for in their own words. The technology that makes this possible - text embeddings and similarity search - is more accessible than ever.
You don't need a PhD in machine learning or a massive engineering team. With the example code in this article, you can build semantic search in an afternoon. Start small with a JSON file storing embeddings, then scale to a vector database when you need it.

