🔥 0
0
Lesson 5 of 8 25 min +250 XP

Embeddings & Vector Stores

A shopper searches ShopKart for "something to keep my coffee hot on the way to office." Your SQL LIKE '%coffee%' finds nothing useful — the perfect product is a travel mug, and its description never says "coffee." Keyword search fails because it matches letters, not meaning. The shopper wants insulation and portability. To search by meaning you need embeddings and a vector store. This lesson is the foundation for RAG in the next.

What is an embedding?

An embedding is a list of numbers (a vector) that represents the meaning of a piece of text. An embedding model reads text and outputs, say, 1536 floating-point numbers. The crucial property: texts with similar meaning get vectors that are close together, and unrelated texts get vectors that are far apart. "Travel mug", "insulated flask", and "keep coffee hot" all land near each other; "cricket bat" lands far away.

You never interpret the individual numbers. You only care about distances between vectors. Searching becomes geometry: to find products relevant to a query, embed the query and look for the nearest product vectors.

Meaning-space (a 2D sketch of a 1536-D space) travel mug insulated flask keep coffee hot query lands here cricket bat (far away) running shoes (far away) Similar meaning to close points. Distance is what similarity search measures.

The EmbeddingModel

When you added the OpenAI starter, Spring AI auto-configured an EmbeddingModel bean alongside the chat model. You rarely call it directly (the vector store uses it for you), but seeing it once demystifies the whole thing.

package com.shopkart.search;

import org.springframework.ai.embedding.EmbeddingModel;
import org.springframework.stereotype.Service;

@Service
public class EmbeddingDemo {

    private final EmbeddingModel embeddingModel;

    public EmbeddingDemo(EmbeddingModel embeddingModel) {
        this.embeddingModel = embeddingModel;
    }

    public void show() {
        float[] vector = embeddingModel.embed("insulated travel mug");
        System.out.println("dimensions = " + vector.length);   // e.g. 1536
        System.out.println("first few  = " + vector[0] + ", " + vector[1]);
    }
}

Configure which embedding model to use in properties (the dimension count depends on this choice — it matters when you set up pgvector):

# application.properties
spring.ai.openai.embedding.options.model=text-embedding-3-small

The VectorStore abstraction

A vector store stores embeddings and answers the question "which stored vectors are closest to this query vector?" efficiently. Spring AI's VectorStore interface hides the implementation. Two you will meet:

  • SimpleVectorStore — an in-memory store. Perfect for learning, tests, and small demos. Not for production (it holds everything in RAM and is not shared across instances).
  • PgVectorStore — backed by PostgreSQL with the pgvector extension. Production-ready, and ShopKart likely already runs Postgres.

We will start in memory, then show the one-config-block switch to pgvector.

Wiring a SimpleVectorStore

package com.shopkart.search;

import org.springframework.ai.embedding.EmbeddingModel;
import org.springframework.ai.vectorstore.SimpleVectorStore;
import org.springframework.ai.vectorstore.VectorStore;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

@Configuration
public class VectorStoreConfig {

    @Bean
    public VectorStore vectorStore(EmbeddingModel embeddingModel) {
        return SimpleVectorStore.builder(embeddingModel).build();
    }
}

That is the whole setup for in-memory search. The store now knows how to embed text (via the injected EmbeddingModel) and how to compare vectors.

Loading the ShopKart catalog

The unit a vector store works with is a Document: some text content plus a map of metadata. For each product, we build a Document whose text captures what a shopper might search for, and whose metadata carries structured fields (id, price, category) we can filter and display later.

package com.shopkart.search;

import org.springframework.ai.document.Document;
import org.springframework.ai.vectorstore.VectorStore;
import org.springframework.stereotype.Service;

import java.util.List;
import java.util.Map;

@Service
public class CatalogIndexer {

    private final VectorStore vectorStore;

    public CatalogIndexer(VectorStore vectorStore) {
        this.vectorStore = vectorStore;
    }

    public void index(List<Product> products) {
        List<Document> docs = products.stream()
                .map(this::toDocument)
                .toList();
        vectorStore.add(docs);   // embeds each doc, then stores the vector
    }

    private Document toDocument(Product p) {
        // The text is what gets embedded, so pack it with searchable meaning.
        String text = """
                %s by %s. Category: %s. %s. Tags: %s.
                """.formatted(
                    p.name(), p.brand(), p.category(),
                    p.description(), String.join(", ", p.tags()));

        Map<String, Object> metadata = Map.of(
                "productId", p.id(),
                "name", p.name(),
                "category", p.category(),
                "brand", p.brand(),
                "price", p.price(),
                "rating", p.rating());

        return new Document(text, metadata);
    }
}
vectorStore.add(docs) does the heavy lifting: for each Document it calls the embedding model to compute a vector, then stores the vector together with the text and metadata. Run this once at startup (or whenever the catalog changes) to index ShopKart's products.
// e.g. from an ApplicationRunner at startup, or an admin endpoint
catalogIndexer.index(List.of(
    new Product(101L, "Asics Gel-Contend 8 Running Shoes", "Footwear", "Asics",
        new java.math.BigDecimal("2799"), 4.3, 24,
        "Lightweight daily trainer with cushioned midsole, good for flat feet.",
        List.of("running", "shoes", "flat-feet")),
    new Product(202L, "Milton Thermosteel Flip Lid Flask 500ml", "Kitchen", "Milton",
        new java.math.BigDecimal("899"), 4.5, 60,
        "Double-wall vacuum insulated bottle keeps drinks hot or cold for hours.",
        List.of("flask", "insulated", "travel", "coffee")),
    new Product(303L, "SG Kashmir Willow Cricket Bat", "Sports", "SG",
        new java.math.BigDecimal("1499"), 4.1, 15,
        "Full-size Kashmir willow bat for tennis-ball and leather-ball cricket.",
        List.of("cricket", "bat", "sports"))
));

Similarity search

Now the payoff. To search, build a SearchRequest describing the query and how many results you want, and call similaritySearch. Spring AI embeds your query string and returns the nearest documents.

package com.shopkart.search;

import org.springframework.ai.document.Document;
import org.springframework.ai.vectorstore.SearchRequest;
import org.springframework.ai.vectorstore.VectorStore;
import org.springframework.stereotype.Service;

import java.util.List;

@Service
public class SemanticSearchService {

    private final VectorStore vectorStore;

    public SemanticSearchService(VectorStore vectorStore) {
        this.vectorStore = vectorStore;
    }

    public List<Document> search(String query) {
        SearchRequest request = SearchRequest.builder()
                .query(query)
                .topK(5)                    // return up to 5 nearest documents
                .similarityThreshold(0.5)   // ignore weak matches below 0.5
                .build();

        return vectorStore.similaritySearch(request);
    }
}

Search with the tricky query from the intro:

List<Document> hits = searchService.search(
        "something to keep my coffee hot on the way to office");

hits.forEach(d ->
    System.out.println(d.getMetadata().get("name")
        + " (Rs " + d.getMetadata().get("price") + ")"));

// Milton Thermosteel Flip Lid Flask 500ml (Rs 899)

The flask surfaces first even though the query never said "flask" or "insulated" — its meaning is closest. The cricket bat and running shoes stay out of the results. That is semantic search, and it is impossible with LIKE.

topK caps how many results come back. similarityThreshold (0.0 to 1.0) drops matches that are not close enough, so an irrelevant query returns nothing rather than the least-bad product. Tune both to your catalog.

Switching to pgvector for production

The beauty of the VectorStore abstraction is that your indexing and search code above does not change when you move to a real database. You swap the dependency and add config. Add the starter:

<dependency>
  <groupId>org.springframework.ai</groupId>
  <artifactId>spring-ai-starter-vector-store-pgvector</artifactId>
</dependency>

Then configure Postgres and the vector store. dimensions must match your embedding model (text-embedding-3-small outputs 1536), and initialize-schema=true creates the table and index on startup.

# application.properties
spring.datasource.url=jdbc:postgresql://localhost:5432/shopkart
spring.datasource.username=shopkart
spring.datasource.password=${DB_PASSWORD}

spring.ai.vectorstore.pgvector.initialize-schema=true
spring.ai.vectorstore.pgvector.index-type=HNSW
spring.ai.vectorstore.pgvector.distance-type=COSINE_DISTANCE
spring.ai.vectorstore.pgvector.dimensions=1536

With the pgvector starter present, Spring AI auto-configures a PgVectorStore bean. Delete your SimpleVectorStore @Bean so the auto-configured one is used. Your CatalogIndexer and SemanticSearchService compile and run unchanged — same add(), same similaritySearch(). That portability is the whole point of programming to the abstraction.

On the job

Two things bite teams. First, embedding-model consistency: index and query must use the same embedding model, and if you change models you must re-index everything, because vectors from different models are not comparable. Second, keep useful fields in Document metadata (price, category, stock, id) so you can filter and render results without a second database round trip. And remember embeddings cost money and latency per call — batch your indexing and cache aggressively.

Key takeaways

  • An embedding is a vector capturing meaning; similar texts get nearby vectors, so search becomes finding nearest neighbours.
  • The auto-configured EmbeddingModel turns text into vectors; you rarely call it directly.
  • A VectorStore stores Documents (text + metadata) and answers similarity queries; SimpleVectorStore for learning, PgVectorStore for production.
  • Build searchable Document text from your product fields, keep structured data in metadata, and load with vectorStore.add(...).
  • Query with SearchRequest.builder().query(...).topK(...).similarityThreshold(...).build() and similaritySearch(...).
  • Index and query must share the same embedding model; changing it means re-indexing.

Next up: RAG over the Product Catalog — using these search results to ground the assistant's answers in real inventory.

Structured Output to Java POJOs