2916 words
15 minutes
Embedding Your Servoy Data for Semantic Search
Part 3 of 4 Servoy AI Runtime Plugin

Servoy AI Runtime Plugin tutorial series hero image

This is a Servoy Tutorial on taking semantic search from the development sandbox into a real production Servoy application using PgVector stores, FoundSet embedding, and PDF document chunking. This is the third article in the AI Runtime Plugin series. If you have not yet read the Getting Started tutorial on chat completions and embeddings, or the Tool Calling tutorial on agentic workflows, go read those first. I am going to assume you know what an embedding is, you have seen an in-memory store return similarity scores, and you understand that embed() requires both arguments.

In the first tutorial, I showed you how to embed a handful of customer notes into an in-memory store and run similarity searches against them. That was fine for a tutorial. It is not fine for production. In this article, we fix that, and we also close the loop back to actual Servoy records so that semantic search returns things you can edit, display, and act on.

Why the In-Memory Store Is Not Enough#

Let’s be honest about the in-memory store. It is wonderful for learning, for demos, and for testing. It is a disaster for production. Here is why.

  • It does not survive restarts. Every time you restart the Servoy server, the store is empty. You have to re-embed everything from scratch.
  • Re-embedding costs real money. Every chunk you send to OpenAI’s embedding endpoint costs a fraction of a cent. A fraction of a cent times a hundred thousand customer records times every server restart adds up faster than you think. Your OpenAI bill will notice.
  • It does not scale. An in-memory store holds everything in the Java heap. Fine for ten thousand records. Painful for a hundred thousand. Impossible for a million.
  • It is not shared. Each client builds its own copy, which means either every client re-embeds (expensive) or you centralize the embedding into a scope (fragile).

The fix is a persistent store backed by PgVector. Embeddings go into a PostgreSQL table using the PgVector extension, which ships with Servoy’s embedded PostgreSQL 17.6 out of the box since 2025.12. Embed once, store permanently, search from anywhere. Your embeddings become just another piece of your database, which is exactly where they belong.

Setting Up a PgVector Store#

Let’s walk through creating a persistent store. The builder is attached to the embedding model itself, not to plugins.ai, which trips up people the first time they read the API.

/**
* Builds a persistent PgVector embedding store for product records.
* @author Gary Dotzlaw
* @since 2026-04-17
* @public
*
* @return {plugins.ai.EmbeddingStore} the configured embedding store
*/
function buildProductStore() {
try {
/**@type {String}*/
const sApiKey = application.getServoyProperty('openai_api_key');
/**@type {plugins.ai.EmbeddingModel}*/
const oEmbeddingModel = plugins.ai.createOpenAiEmbeddingModelBuilder()
.apiKey(sApiKey)
.modelName('text-embedding-3-small')
.build();
/**@type {plugins.ai.EmbeddingStore}*/
const oStore = oEmbeddingModel.createServoyEmbeddingStoreBuilder()
.serverName('example_data')
.tableName('product_embeddings_test')
.recreate(false)
.addText(true)
// Declare the PK as a real column. This is required when using serverName(),
// and makes hybrid JOIN queries (Article 4) possible.
.metaDataColumn().name('productid').columnType(JSColumn.INTEGER).add()
.build();
return oStore;
} catch (e) {
application.output('Error in buildProductStore: ' + e.message, LOGGINGLEVEL.ERROR);
plugins.dialogs.showErrorDialog('Error', 'Store build failed: ' + e.message, 'OK');
return null;
}
}

Three things are happening here:

  • Same embedding model as before. The model does not care where the vectors end up. It just produces them.
  • createServoyEmbeddingStoreBuilder() is called on the model. It is not plugins.ai.createPgVectorStore(...) or anything similar. The builder comes off the model instance. This is the part most people get wrong on first read.
  • The table is managed by the plugin. You do not hand-craft the schema. On first use, the plugin creates the table with the appropriate vector columns and metadata fields. Just point it at a server name and a table name and let it work.

The builder options are worth calling out:

  • serverName('example_data') points to the Servoy server connection that has PgVector available. example_data is the sample Northwind-style database that ships with Servoy, so everything in this article is runnable against a default Servoy install. The embedded PostgreSQL that ships with Servoy has PgVector enabled by default.
  • tableName('product_embeddings_test') names the table where the vectors will be stored. The plugin creates it if it does not exist.
  • recreate(false) keeps existing data across rebuilds of the store object. If you set this to true, the store is wiped every time you call .build(), which is occasionally what you want during development but almost never what you want in production.
  • addText(true) stores the original text alongside the vector. That way when search results come back, you can see what matched without joining anywhere. This costs a bit of storage but makes debugging and display much easier.
  • metaDataColumn().name('productid').columnType(JSColumn.INTEGER).add() declares an additional physical column on the embeddings table. This is required when using serverName(). If you call .build() on a serverName() store with no metadata columns defined, the builder throws java.lang.IllegalArgumentException: either a dataSource or serverName (with metaDataColumns) must be specified. Declaring the PK here also makes it a JOINable column, which is what enables the hybrid queries in Article 4.

Adding Custom Metadata Columns#

The basic store builder creates a table with the vector column and an optional text column. But often you need to store additional metadata with each embedding, metadata that you can query or filter on later. The metaDataColumn() sub-builder lets you add named, typed columns to the embeddings table:

/**
* Builds a PgVector store with custom metadata columns for document tracking.
* @author Gary Dotzlaw
* @since 2026-04-17
* @public
*
* @return {plugins.ai.EmbeddingStore} the configured embedding store
*/
function buildDocumentStore() {
/**@type {String}*/
const sApiKey = application.getServoyProperty('openai_api_key');
/**@type {plugins.ai.EmbeddingModel}*/
const oEmbeddingModel = plugins.ai.createOpenAiEmbeddingModelBuilder()
.apiKey(sApiKey)
.modelName('text-embedding-3-small')
.build();
/**@type {plugins.ai.EmbeddingStore}*/
const oStore = oEmbeddingModel.createServoyEmbeddingStoreBuilder()
.serverName('example_data')
.tableName('book_text_embeddings')
.recreate(false)
.addText(true)
.metaDataColumn().name('filename').columnType(JSColumn.TEXT).add()
.metaDataColumn().name('doc_type').columnType(JSColumn.TEXT).add()
.build();
return oStore;
}

The metaDataColumn() call starts a sub-builder. You chain .name() and .columnType() to define the column, then .add() to finish the sub-builder and return control to the store builder. You can chain multiple metadata columns. These columns become real columns in the PostgreSQL table, which means they are available for filtering and querying.

This is the pattern from the Example Solution’s exampleVectorDB.js, and it is how you build stores that track where each chunk came from.

Make sense? Let’s embed some real Servoy data.

FoundSet Embedding: Closing the Loop Back to Records#

Here is where the in-memory example from the first tutorial falls short. We embedded an array of strings. A search returned text fragments. But text fragments are not useful in a Servoy application. What you actually want is records. You want a search for “lightweight laptop for travel” to give you back a foundset of products you can display in a grid, open in a form, and edit.

embedAll() is the method that makes this work. It takes a JSFoundSet and the names of the text columns to embed, and it preserves the primary keys as metadata automatically. When you search later, the results include the PKs, which means you can load the matching records back into a real foundset.

/**
* Embeds products into an in-memory store using two text columns.
* Demonstrates the varargs form of embedAll.
* @author Gary Dotzlaw
* @since 2026-04-17
* @public
*/
function embedAllProductsMultiColumn() {
try {
/**@type {String}*/
const sApiKey = application.getServoyProperty('openai_api_key');
/**@type {plugins.ai.EmbeddingModel}*/
const oModel = plugins.ai.createOpenAiEmbeddingModelBuilder()
.apiKey(sApiKey)
.modelName('text-embedding-3-small')
.build();
/**@type {plugins.ai.EmbeddingStore}*/
const oStore = oModel.createInMemoryStore();
/**@type {JSFoundSet}*/
const fs = datasources.db.example_data.products.getFoundSet();
fs.loadAllRecords();
// Two text columns as individual arguments (varargs form)
oStore.embedAll(fs, 'productname', 'quantityperunit')
.then(function() {
application.output('embedAll completed with ' + fs.getSize() + ' records');
/**@type {Array}*/
const aResults = oStore.search('beverage drinks', 3);
application.output('search returned ' + aResults.length + ' results');
if (aResults.length > 0) {
application.output('top score=' + aResults[0].getScore().toFixed(3) +
' text=' + aResults[0].getText().substring(0, 80));
}
})
.catch(function(oError) {
application.output('embedAll failed: ' + oError.message, LOGGINGLEVEL.ERROR);
});
} catch (e) {
application.output('Error in embedAllProductsMultiColumn: ' + e.message, LOGGINGLEVEL.ERROR);
}
}

Expected Output:

embedAll completed with 77 records
search returned 3 results
top score=0.721 text=24 - 12 oz bottles

A few things worth pointing out:

  • embedAll() takes the foundset plus the text column names as individual arguments. The Java signature uses varargs (String... textColumns), so you can pass them individually: embedAll(fs, 'productname', 'quantityperunit'). You can also pass a single column: embedAll(fs, 'productname'). Both forms work. The plugin concatenates the values from each listed column before embedding. Pick the columns that actually describe the record. Embedding a code or SKU column would be useless for semantic search.
  • embedAll() returns a Promise. This is a lot of work happening on the embedding provider’s side. Do not treat it as synchronous. Chain with .then() and handle failures with .catch().
  • The PKs are captured as metadata automatically. You do not tell the plugin which columns are PKs. It figures that out from the datasource and stores them under the PK column name. So for the products table whose PK is productid, the metadata for each embedded record will have a productid property.
  • This is a batch job, not a UI operation. Run it from a headless client, a batch processor, or a scheduled task. Do not hang a form event handler off of it unless you enjoy watching spinners.

Now the search side, where the PK metadata pays off:

/**
* Searches products semantically and loads matching records into a foundset.
* @author Gary Dotzlaw
* @since 2026-04-17
* @public
*
* @param {plugins.ai.EmbeddingStore} oStore the PgVector embedding store
* @param {String} sQuery the natural-language query
* @return {JSFoundSet} foundset of matching product records
*/
function searchProductsSemantic(oStore, sQuery) {
/**@type {Array<plugins.ai.SearchResult>}*/
const aResults = oStore.search(sQuery, 1000);
/**@type {Array}*/
const aPks = [];
aResults.forEach(function(oResult) {
if (oResult.getScore() > 0.7) {
aPks.push(oResult.getMetadata().productid);
}
});
/**@type {JSDataSet}*/
const dsPks = databaseManager.convertToDataSet(aPks);
/**@type {JSFoundSet}*/
const fsMatches = datasources.db.example_data.products.getFoundSet();
fsMatches.loadRecords(dsPks);
return fsMatches;
}

Walking through this:

  • oStore.search(sQuery, 1000) runs the similarity search and returns an array. We request a large batch and then filter client-side by score. This is the “get many, filter by quality” pattern from the Example Solution.
  • The 0.7 score threshold filters out weak matches. A score of 0.7 is a reasonable starting point. Tune it based on your data. Lower means more results but more noise. Higher means fewer results but higher quality.
  • getMetadata() returns a plain JavaScript object. You access the PK by the column name from the source table. Here the PK is productid, so we pull oResult.getMetadata().productid. Bracket notation (getMetadata()['productid']) works too.
  • databaseManager.convertToDataSet(aPks) wraps the PK array into a JSDataSet. This is the idiomatic way to hand a list of PKs to foundset.loadRecords().
  • The result is a real foundset. You can bind it to a form, display it in a list view, open records for edit. All the standard Servoy machinery works, because at the end of the day it is just a filtered product foundset.

No special UI component. No awkward custom rendering. It is just a foundset. This is the bridge between semantic search and standard Servoy UI, and it is the pattern you will use over and over again.

PDF Document Embedding#

Not all the content you want to search is in database tables. Product manuals, policy documents, contracts, help articles. These typically live as PDF files, and the plugin has a built-in chunker specifically for them.

The embed() method has a PDF-specific overload that accepts a file source, a chunk size, and an overlap size, all in characters:

/**
* Embeds a PDF document into the store with metadata.
* @author Gary Dotzlaw
* @since 2026-04-17
* @public
*
* @param {plugins.ai.EmbeddingStore} oStore the embedding store
* @param {plugins.file.JSFile} oFile the PDF file to embed
* @return {Promise<plugins.ai.EmbeddingStore>} resolves when embedding completes
*/
function embedPDFDocument(oStore, oFile) {
return oStore.embed(oFile, 1500, 150, { filename: oFile.getName() })
.then(function() {
application.output('Embedded PDF: ' + oFile.getName());
return oStore;
})
.catch(function(oError) {
application.output('PDF embed failed: ' + oError.message, LOGGINGLEVEL.ERROR);
throw oError;
});
}

Let’s walk through the parameters:

  • oFile is the PDF source. It accepts a JSFile, a String file path on the server, or a raw byte array. This is the only built-in document chunker in the plugin, and it is PDF-specific. For Word documents, HTML, or markdown, you need to extract the text first and use the text-array form of embed().
  • 1500 is maxSegmentSizeInChars. Characters, not tokens. For English prose, roughly 4 characters per token, so 1500 characters is about 375 tokens. The Example Solution uses 500 characters for short demo documents; for production documents with longer paragraphs, 1200 to 2000 characters works better.
  • 150 is maxOverlapSizeInChars. The overlap between consecutive chunks. This ensures that a sentence straddling a chunk boundary does not get cut in half and lost to search. 10-15% overlap is a good rule of thumb.
  • { filename: oFile.getName() } is optional metadata. Unlike the text-array form of embed() where metadata is a required parallel array, the PDF form accepts a single metadata object that applies to all chunks from that file. This is convenient for tracking which file each chunk came from.

The PDF chunker can also accept raw bytes, which is useful when the document is stored in a database BLOB or pulled from a Servoy media:

/**@type {Array<byte>}*/
const aBytes = solutionModel.getMedia('product_manual.pdf').bytes;
oStore.embed(aBytes, 1500, 150, { filename: 'product_manual.pdf' });

Once the PDF is embedded, you can combine it with a chat completion for a basic RAG (Retrieval-Augmented Generation) pattern:

/**
* Asks a question about a PDF document using RAG.
* @author Gary Dotzlaw
* @since 2026-04-17
* @public
*
* @param {plugins.ai.EmbeddingStore} oStore the embedding store containing the PDF
* @param {String} sQuestion the user's question
*/
function askAboutDocument(oStore, sQuestion) {
try {
/**@type {Array<plugins.ai.SearchResult>}*/
const aResults = oStore.search(sQuestion, 3);
if (aResults.length === 0) {
application.output('No relevant content found.');
return;
}
/**@type {String}*/
const sContext = aResults.map(function(oResult) {
return oResult.getText();
}).join('\n\n');
/**@type {plugins.ai.ChatClient}*/
const oClient = plugins.ai.createOpenAiChatBuilder()
.apiKey(application.getServoyProperty('openai_api_key'))
.modelName('gpt-4o')
.addSystemMessage('Answer questions using only the provided context. ' +
'If the answer is not in the context, say so.')
.build();
/**@type {String}*/
const sPrompt = 'Context:\n' + sContext + '\n\nQuestion: ' + sQuestion;
plugins.svyBlockUI.show('Searching...');
oClient.chat(sPrompt).then(function(oResponse) {
application.output('Answer: ' + oResponse.getResponse());
application.output('Best match score: ' + aResults[0].getScore().toFixed(3));
}).catch(function(oError) {
application.output('RAG failed: ' + oError.message, LOGGINGLEVEL.ERROR);
}).finally(function() {
plugins.svyBlockUI.stop();
});
} catch (e) {
application.output('Error in askAboutDocument: ' + e.message, LOGGINGLEVEL.ERROR);
plugins.svyBlockUI.stop();
}
}

This is the classic RAG pattern: retrieve the most relevant chunks from the embedding store, inject them into the prompt as context, and let the LLM generate an answer grounded in that context. The system message tells the model to only answer from the provided context, which reduces hallucination.

Keeping Embeddings in Sync#

There is one uncomfortable truth about embeddings: they go stale. If a product name changes, the embedding does not automatically update. If a new product is added, it does not show up in searches until you embed it. This is not a Servoy problem; it is a fact of life with vector stores. You have to plan for it.

The pattern I use is a batch processor that runs on a schedule (nightly is usually fine) and re-embeds anything that has changed since the last run. Add a last_embedded timestamp column to the source table, compare it to last_modified, and re-embed the delta. For high-change tables, you can also add a database trigger or a record event that marks a row as “needs re-embedding” and let the batch pick it up.

Do not try to re-embed on every edit. You will burn API calls for no reason, and the user will not notice the difference between “indexed immediately” and “indexed within the hour” for most applications. Batch it.

Advantages of the PgVector Approach#

Moving semantic search into PgVector gives you several concrete wins:

  • Embeddings persist across restarts. Embed once, search forever. Your OpenAI bill sees one big spike and then nothing until your data changes.
  • Search scales with your database. PostgreSQL is a well-understood system for handling large datasets. It has indexes, query planning, and decades of tuning knowledge baked in. PgVector adds vector operations to all of that.
  • Results map back to real records. FoundSet embedding preserves PKs, so every search result can become a loaded foundset. Your existing forms and list views work without modification.
  • One place for your data. Product data, product embeddings, and product search all live in the same database, managed by the same backup and the same replication. Your ops team will appreciate this more than you think.
  • PDF and document search in the same store. Product descriptions and product manuals in one searchable index. A search for “battery replacement” returns both the product records and the relevant manual pages.

Keep in mind that PgVector is not free in terms of storage. A 1536-dimensional vector (the size of text-embedding-3-small) is about 6 KB per row. Multiply by your row count. For a hundred thousand rows, that is about 600 MB of vector data. Manageable, but not invisible. Size accordingly.

What Comes Next#

PgVector and FoundSet embedding cover the persistence and retrieval story. The next tutorial takes it further with QBVectorColumn in the Query Builder, which lets you combine semantic similarity with traditional SQL WHERE clauses in a single database query. Products semantically similar to “lightweight laptop for travel” filtered to under $2,000 and in stock, all in one database round-trip. No more two-pass searches. No more glue code between the vector store and the foundset.

That concludes this Servoy tutorial on embedding your Servoy data for semantic search. I hope you enjoyed it, and I look forward to bringing you more Servoy tutorials on AI integration in the future.


The Series#

This is Part 3 of a four-part series on the Servoy AI Runtime Plugin:

  1. Getting Started with the Servoy AI Runtime Plugin. Chat completions, streaming, conversation memory, embeddings, and your first semantic search.
  2. Tool Calling with the AI Runtime Plugin: Agentic Servoy. Register Servoy methods as tools and let the LLM decide when to call them.
  3. Embedding Your Servoy Data for Semantic Search (this article). PgVector production stores, FoundSet embedAll(), and PDF document chunking.
  4. Hybrid Queries with QBVectorColumn: Semantic Meets SQL. Combine semantic similarity with traditional WHERE clauses in a single database round-trip.
Embedding Your Servoy Data for Semantic Search
https://dotzlaw.com/insights/servoy-tutorial-03-embedding-data/
Author
Gary Dotzlaw
Published at
2026-04-23
License
CC BY-NC-SA 4.0
← Back to Insights