Traditional search systems usually look for matching words or phrases. This works well when users know exactly what to search for, but it can produce poor results when they describe a problem using different words from those stored in the documents.
For example, a developer might search for “how to recover an application after a server crash.” A document titled “Implementing fault recovery in distributed services” may contain the right answer, even though the two texts share few exact words.
Semantic search addresses this problem by comparing the meaning of text rather than relying only on keyword matches. It uses embeddings, which represent text as numerical vectors, and retrieves documents whose vectors are close to the vector of the user's query.
Go is a practical choice for building this type of service because it provides straightforward concurrency, efficient HTTP handling, and a simple way to integrate external embedding APIs and vector databases.
In this article, we will build a small semantic search engine in Go, implement vector similarity ranking, and examine how to extend the design for production applications.
What Is Semantic Search?
Semantic search retrieves information based on the meaning of a query. Instead of searching only for matching terms, it represents the query and documents as vectors generated by an embedding model.
An embedding model converts text into a list of floating-point numbers. Texts with related meanings may produce vectors that are close together in the embedding space.
For example, an application might store these documents:
- How to restore a database after a failure.
- Configuring authentication in a Go web service.
- Recovering a distributed application after a server outage.
A query about recovering an application after a failure should rank the first and third documents above the authentication document, provided the embedding model represents their meanings appropriately.
Embeddings do not guarantee that every relevant document will be found. Results depend on the embedding model, document quality, chunking strategy, similarity metric, and retrieval configuration.
Understand the Semantic Search Pipeline
A semantic search system typically has two workflows: indexing documents and retrieving results.
During indexing, the application cleans and prepares documents, divides long content into manageable sections, generates embeddings, and stores the vectors alongside document metadata.
During retrieval, the application embeds the user's query, searches for nearby vectors, ranks the matching documents, and returns useful results.
The overall workflow looks like this:
Document Collection
|
v
Text Preparation and Chunking
|
v
Embedding Model
|
v
Vector Storage and Indexing
|
v
User Query
|
v
Query Embedding
|
v
Vector Similarity Search
|
v
Ranked Search ResultsThe same embedding model and compatible configuration should normally be used for documents and queries. If the model changes, existing document vectors may need to be regenerated before they can be compared meaningfully with new query vectors.
Choose an Embedding Model
An embedding model determines how text is represented in the vector space. Some models are designed for general text retrieval, while others target specific languages, domains, or multilingual workloads.
Before selecting a model, evaluate:
- Whether it supports the languages in your documents and queries.
- The number of dimensions in its output vectors.
- The maximum input length it accepts.
- The quality of retrieval on your actual dataset.
- Its latency, operating cost, and deployment requirements.
The model's vector dimension matters because all vectors stored in the same vector index must follow the expected dimension. A model that produces 768-dimensional vectors cannot be mixed directly with a model that produces 1,024-dimensional vectors in the same fixed-dimension index.
The following Go interface keeps the application independent of a particular embedding provider.
package search
import "context"
type Embedder interface {
Embed(
ctx context.Context,
text string,
) ([]float32, error)
}A provider-specific implementation can call a hosted embedding API or a locally running embedding model. The interface also makes testing easier because a fake embedder can be supplied without making network requests.
Create a Document Model in Go
Each indexed document needs an identifier, its original text, an embedding vector, and any metadata required by the application.
package search
type Document struct {
ID string
Title string
Content string
Category string
Vector []float32
}The ID identifies the document or document chunk. The title and content provide the information that will be returned to the user, while the category supports filtering.
The Vector field stores the embedding generated from the content.
For a small demonstration, these records can be held in memory. In a production service, use durable storage so the application does not have to recreate every embedding whenever it restarts.
For long documents, store separate chunks rather than embedding an entire book or manual as one vector. A chunk should contain enough context to make sense independently while remaining within the embedding model's input limits.
Implement Vector Similarity
Once the application has embeddings for documents and a query, it needs a way to measure similarity.
Cosine similarity is a common choice. It compares the angle between two vectors and is calculated as:
[
\text{cosine}(A,B)=
\frac{A \cdot B}{|A||B|}
]
Here, (A \cdot B) is the dot product, while the denominator is the product of the vectors' magnitudes.
For nonzero vectors, the result ranges from -1 to 1. Higher values indicate greater directional similarity, although the meaning of a particular score depends on the embedding model and data.
The following implementation checks that the vectors have matching dimensions and rejects zero-length vectors.
package search
import (
"errors"
"math"
)
func CosineSimilarity(
a, b []float32,
) (float64, error) {
if len(a) == 0 || len(a) != len(b) {
return 0, errors.New(
"vectors must have matching nonzero dimensions",
)
}
var dot float64
var normA float64
var normB float64
for i := range a {
x := float64(a[i])
y := float64(b[i])
dot += x * y
normA += x * x
normB += y * y
}
if normA == 0 || normB == 0 {
return 0, errors.New(
"cosine similarity is undefined for zero vectors",
)
}
return dot / (math.Sqrt(normA) * math.Sqrt(normB)), nil
}Using float64 for the intermediate calculations reduces rounding error in the similarity calculation, even though the stored vectors use float32.
This implementation compares two vectors directly. It is suitable for a small search collection, but it does not provide an efficient way to search millions of documents.
Build a Simple Retrieval Function
The next step is to embed the query, compare it with stored document vectors, and return the highest-ranked results.
For this demonstration, we will use a linear scan. The function calculates a similarity score for every document and sorts the results from highest to lowest.
Join the conversation! Your thoughts help the community grow.