
This is a Servoy Tutorial on QBVectorColumn, the feature that takes semantic search in Servoy from “interesting” to “production-ready.” This is the fourth and final article in the AI Runtime Plugin series. If you have not read the earlier tutorials on chat completions and embeddings, tool calling, and PgVector stores with FoundSet embedding, go read those first. This article builds directly on the PgVector store and embedAll() patterns from Article 3.
In Article 3, I showed you how to embed product records into a PgVector store and search them with oStore.search(). That works, but it has a limitation: the search is purely semantic. If you want products similar to “lightweight laptop for travel” that are also under $2,000, in stock, and active, you have to do two passes. First the semantic search, then a second query to filter the results by price and stock. Two round-trips, two sort orders fighting each other, and you have to cap the semantic pass absurdly high to make sure you do not filter out all the good matches.
QBVectorColumn fixes this. It adds vector search directly to Servoy’s Query Builder, so you can combine semantic similarity with traditional WHERE clauses in a single query. One database round-trip. One sort order. PostgreSQL does all the work.
Two Functions, Two Jobs
QBVectorColumn has two methods. Understanding which one to use where is the whole game.
vector_distance(embedding) returns a QBNumberColumn. It gives you a raw distance value where lower is better. Sort ascending to get the best matches first. This is the most efficient option for ranking and is what you should reach for when ordering results.
vector_score(embedding) returns a QBScoreColumn. It gives you a normalized 0-to-1 similarity score where higher is better. This is the one you add to the result set when you want to display the score to the user, and it has a special min_score() method for filtering.
Bottom-line: use vector_distance for sorting, use vector_score for display and filtering.
Getting the Query Vector
Before you can use either function, you need the raw embedding vector for the user’s search query. This is the synchronous, single-text form of the embedding model’s embedding() method:
/** * Demonstrates the synchronous single-string form of embedding(). * Returns a raw Array<Number> immediately (no Promise). * @author Gary Dotzlaw * @since 2026-04-17 * @public */function demoSyncEmbedding() { 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 {Array<Number>}*/ const aVector = oModel.embedding('test phrase for embedding');
application.output('type = ' + typeof aVector); application.output('isArray = ' + Array.isArray(aVector)); application.output('length = ' + aVector.length); application.output('first value = ' + aVector[0]); application.output('getDimension = ' + oModel.getDimension()); } catch (e) { application.output('Error in demoSyncEmbedding: ' + e.message, LOGGINGLEVEL.ERROR); }}Expected Output:
type = objectisArray = truelength = 1536first value = 0.0167999267578125getDimension = 1536Notice: embedding() with a single string argument is synchronous. It returns the raw vector array immediately. Do not confuse this with the array form, which is asynchronous and returns a Promise. The single-string form is what you use for QBVectorColumn queries because you need the vector before you can build the query. The vector length always matches oModel.getDimension() — 1536 for text-embedding-3-small.
Sorting by Distance: vector_distance().asc
Start with the simplest hybrid pattern: sort the foundset by semantic relevance to a query. The following example joins the base table to the embeddings table on the auto-captured PK, and sorts ascending by vector distance so the best matches come first:
/** * Sort a product foundset by semantic relevance to a query. * Demonstrates vector_distance() in query.sort. * @author Gary Dotzlaw * @since 2026-04-17 * @public */function sortBySemanticDistance() { 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 {Array<Number>}*/ const aVec = oModel.embedding('spicy sauce condiment');
/**@type {QBSelect}*/ const query = datasources.db.example_data.products.createSelect(); query.result.addPk();
/**@type {QBJoin}*/ const joinEmb = query.joins.add( 'db:/example_data/product_embeddings_test', QBJoin.INNER_JOIN, 'emb' ); joinEmb.on.add(joinEmb.columns.productid.eq(query.columns.productid));
// Sort by vector_distance ascending (best matches first) query.sort.add( joinEmb.columns.embedding.vector_distance(aVec).asc );
/**@type {JSFoundSet}*/ const fs = datasources.db.example_data.products.getFoundSet(); fs.loadRecords(query);
application.output('loaded ' + fs.getSize() + ' records'); for (let i = 1; i <= Math.min(5, fs.getSize()); i++) { application.output(' #' + i + ': ' + fs.getRecord(i).productname); } } catch (e) { application.output('Error in sortBySemanticDistance: ' + e.message, LOGGINGLEVEL.ERROR); }}Expected Output:
loaded 77 records #1: Louisiana Fiery Hot Pepper Sauce #2: Original Frankfurter grüne Soße #3: Louisiana Hot Spiced Okra #4: Northwoods Cranberry Sauce #5: Chef Anton's Cajun SeasoningThe top five results all relate to spicy sauces and condiments, even though only one of them contains the word “sauce” and none contain the word “spicy.” The database ranked them by semantic similarity, not keyword match.
Including the Score in the Result Set: vector_score()
When you want to display the similarity score alongside product data, add vector_score() as a result column with an alias:
/** * Fetch a ranked product dataset with the similarity score as a column. * Demonstrates vector_score() in query.result with an alias. * @author Gary Dotzlaw * @since 2026-04-17 * @public */function fetchRankedProducts() { 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 {Array<Number>}*/ const aVec = oModel.embedding('chocolate candy sweet');
/**@type {QBSelect}*/ const query = datasources.db.example_data.products.createSelect();
/**@type {QBJoin}*/ const joinEmb = query.joins.add( 'db:/example_data/product_embeddings_test', QBJoin.INNER_JOIN, 'emb' ); joinEmb.on.add(joinEmb.columns.productid.eq(query.columns.productid));
query.result.add(query.columns.productid); query.result.add(query.columns.productname); // Include the score in the result, aliased as 'relevance' query.result.add( joinEmb.columns.embedding.vector_score(aVec), 'relevance' );
query.sort.add( joinEmb.columns.embedding.vector_distance(aVec).asc );
/**@type {JSDataSet}*/ const ds = databaseManager.getDataSetByQuery(query, 10);
application.output('got ' + ds.getMaxRowIndex() + ' rows'); for (let i = 1; i <= Math.min(5, ds.getMaxRowIndex()); i++) { application.output(' ' + ds.getValue(i, 2) + ' — score: ' + ds.getValue(i, 3)); } } catch (e) { application.output('Error in fetchRankedProducts: ' + e.message, LOGGINGLEVEL.ERROR); }}Expected Output:
got 10 rows Chocolade — score: 0.7723287378081095 Schoggi Schokolade — score: 0.739770648571818 Teatime Chocolate Biscuits — score: 0.723661901327705 Valkoinen suklaa — score: 0.7145571819702352 Tarte au sucre — score: 0.7018570402395428The score is a numeric value between 0 and 1 where higher means more similar. For text-embedding-3-small, real-world scores on clearly-related pairs typically land in the 0.6–0.8 range. Scores above 0.9 are unusual and usually mean the text is near-identical to the query.
Filtering by Score: min_score()
The min_score() method filters rows whose similarity is below a threshold. It is chained on the result of vector_score():
/** * Load only products with similarity above a score threshold. * Demonstrates vector_score().min_score() as a WHERE filter. * @author Gary Dotzlaw * @since 2026-04-17 * @public */function filterByMinScore() { 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 {Array<Number>}*/ const aVec = oModel.embedding('dairy cheese milk');
/**@type {QBSelect}*/ const query = datasources.db.example_data.products.createSelect(); query.result.addPk();
/**@type {QBJoin}*/ const joinEmb = query.joins.add( 'db:/example_data/product_embeddings_test', QBJoin.INNER_JOIN, 'emb' ); joinEmb.on.add(joinEmb.columns.productid.eq(query.columns.productid));
// min_score: only rows scoring at or above 0.7 query.where.add( joinEmb.columns.embedding.vector_score(aVec).min_score(0.7) );
query.sort.add( joinEmb.columns.embedding.vector_distance(aVec).asc );
/**@type {JSFoundSet}*/ const fs = datasources.db.example_data.products.getFoundSet(); fs.loadRecords(query);
application.output('min_score(0.7) returned ' + fs.getSize() + ' products'); for (let i = 1; i <= fs.getSize(); i++) { application.output(' #' + i + ': ' + fs.getRecord(i).productname); } } catch (e) { application.output('Error in filterByMinScore: ' + e.message, LOGGINGLEVEL.ERROR); }}Expected Output:
min_score(0.7) returned 2 of 77 total products #1: Queso Cabrales #2: Queso Manchego La PastoraA couple of important details:
min_score()is chained on the result ofvector_score(). It only exists onQBScoreColumn(the return type ofvector_score). You cannot chain it onvector_distance(). If you try, you will get a method-not-found error.- The sort still uses
vector_distance().asc. Even though we are filtering by score, sorting by distance is more efficient at the database level. They produce the same ordering (best matches first), just expressed differently. - The threshold matters. 0.7 is a reasonable starting point for “clearly relevant” matches with
text-embedding-3-small. At 0.5, you get everything (most text pairs in the same domain score above 0.5). At 0.8+, you get only near-perfect semantic matches.
For the opposite approach, filtering by distance instead of score, use the standard numeric operators on QBNumberColumn:
/** * Filter products by vector distance using the standard .le() operator. * Demonstrates vector_distance().le() as a WHERE filter. * @author Gary Dotzlaw * @since 2026-04-17 * @public */function filterByMaxDistance() { 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 {Array<Number>}*/ const aVec = oModel.embedding('tea green herbal');
/**@type {QBSelect}*/ const query = datasources.db.example_data.products.createSelect(); query.result.addPk();
/**@type {QBJoin}*/ const joinEmb = query.joins.add( 'db:/example_data/product_embeddings_test', QBJoin.INNER_JOIN, 'emb' ); joinEmb.on.add(joinEmb.columns.productid.eq(query.columns.productid));
// Distance filter: only rows within distance 0.7 of the query vector query.where.add( joinEmb.columns.embedding.vector_distance(aVec).le(0.7) );
query.sort.add( joinEmb.columns.embedding.vector_distance(aVec).asc );
/**@type {JSFoundSet}*/ const fs = datasources.db.example_data.products.getFoundSet(); fs.loadRecords(query);
application.output('distance <= 0.7 returned ' + fs.getSize() + ' products'); for (let i = 1; i <= Math.min(5, fs.getSize()); i++) { application.output(' #' + i + ': ' + fs.getRecord(i).productname); } } catch (e) { application.output('Error in filterByMaxDistance: ' + e.message, LOGGINGLEVEL.ERROR); }}Expected Output:
distance <= 0.7 returned 6 products #1: Chartreuse verte #2: Chai #3: Teatime Chocolate Biscuits #4: Original Frankfurter grüne Soße #5: Aniseed SyrupUse min_score(0.7) when you think in terms of “how similar is this?” Use vector_distance(...).le(n) when your domain expresses a specific distance tolerance.
The Full Hybrid Query
Okay, here is where all four articles come together. Let’s take a real scenario. Your users are searching a product catalog. They type “lightweight laptop for travel.” A semantic search happily returns laptops that mention “portable,” “ultra-thin,” “commuter-friendly,” and similar ideas, even if the word “lightweight” appears nowhere. So far, nothing new. But the user also needs to filter to products under $2,000 that are currently in stock, ranked by relevance. That filter is boring traditional SQL. The ranking is semantic.
Before QBVectorColumn, you had to do two passes: semantic search first, then filter the PKs in a second query. Two round-trips, two sort orders fighting each other, and you had to cap the semantic pass absurdly high to make sure you did not filter out all the good matches. With QBVectorColumn, it is one query.
The embeddings produced by embedAll() in Article 3 live in the separate product_embeddings_test table we configured earlier. That table has a productid column (from the declared metaDataColumn) and an embedding vector column. To do a hybrid query, we join the embeddings table back to products and use vector_distance on the embedding column:
/** * Hybrid semantic + SQL search over the product catalog. * Combines vector_distance sort, vector_score in result, and SQL filters. * @author Gary Dotzlaw * @since 2026-04-17 * @public */function hybridProductSearch() { 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 {Array<Number>}*/ const aVec = oModel.embedding('seafood fish ocean');
/**@type {QBSelect}*/ const query = datasources.db.example_data.products.createSelect();
/**@type {QBJoin}*/ const joinEmb = query.joins.add( 'db:/example_data/product_embeddings_test', QBJoin.INNER_JOIN, 'emb' ); joinEmb.on.add(joinEmb.columns.productid.eq(query.columns.productid));
// Result columns for display query.result.add(query.columns.productid); query.result.add(query.columns.productname); query.result.add(query.columns.unitprice); query.result.add( joinEmb.columns.embedding.vector_score(aVec), 'relevance' );
// Traditional SQL filter: only products that are not discontinued query.where.add(query.columns.discontinued.eq(0));
// Semantic sort: best matches first query.sort.add( joinEmb.columns.embedding.vector_distance(aVec).asc );
/**@type {JSDataSet}*/ const ds = databaseManager.getDataSetByQuery(query, 10);
application.output('hybrid query returned ' + ds.getMaxRowIndex() + ' rows'); for (let i = 1; i <= Math.min(5, ds.getMaxRowIndex()); i++) { application.output(' ' + ds.getValue(i, 2) + ' — $' + ds.getValue(i, 3) + ' — relevance: ' + (ds.getValue(i, 4) ? ds.getValue(i, 4).toFixed(3) : 'null')); } } catch (e) { application.output('Error in hybridProductSearch: ' + e.message, LOGGINGLEVEL.ERROR); }}Expected Output:
hybrid query returned 10 rows Boston Crab Meat — $18.4 — relevance: 0.726 Ikura — $31 — relevance: 0.676 Longlife Tofu — $10 — relevance: 0.655 Nord-Ost Matjeshering — $25.89 — relevance: 0.654 Chef Anton's Cajun Seasoning — $22 — relevance: 0.654Let’s walk through what makes this special:
oModel.embedding(sQuery)returns the raw vector. This is the synchronous, single-text version. It gives you back the array of floats so you can pass the vector into the query builder.- The JOIN connects
productstoproduct_embeddings_testonproductid. The embeddings table was created byembedAll()in Article 3, with the PK declared as ametaDataColumnon the store builder. Soemb.productidis a real column on the embeddings table, and it matches the sourceproductidone-to-one. vector_score(aVec)goes into the result set with an alias. Readingrelevancefrom each result row lets you display the score to the user if you want. The score is a number between 0 and 1.- The WHERE clause is pure QBSelect. The
discontinued = 0filter executes in the database alongside the vector scoring. In your own queries, add stock, price, tenant isolation, and any other traditional conditions — all in the same query. - The sort uses
vector_distance().asc. Lower distance means a better match, and sorting on distance is more efficient than sorting on the derived score. This is the pattern you should reach for when you want the top-N most similar records.
PostgreSQL ranks by relevance, applies the filters, and returns 25 rows. One query. One round-trip. This is the query you could not write cleanly before. A LIKE search over product names would miss half of what the user wants. A semantic-only search would return dream-laptops priced at $9,000 that are out of stock. QBVectorColumn lets you combine both, and the database does the work.
Loading a Foundset from a Hybrid Query
The hybridProductSearch function above returns a JSDataSet with multiple columns for display. But if you want to load the results into a foundset for a standard Servoy form, use the PK-only pattern:
/** * Hybrid semantic search that loads results into a foundset. * @author Gary Dotzlaw * @since 2026-04-17 * @public * * @param {String} sQuery the user's natural-language query * @param {Number} nMaxPrice the maximum unit price filter */function hybridSearchToFoundset(sQuery, nMaxPrice) { 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 {Array<Number>}*/ const aQueryVector = oModel.embedding(sQuery);
/**@type {QBSelect}*/ const query = datasources.db.example_data.products.createSelect(); query.result.addPk();
/**@type {QBJoin}*/ const joinEmb = query.joins.add( 'db:/example_data/product_embeddings_test', QBJoin.INNER_JOIN, 'emb' ); joinEmb.on.add(joinEmb.columns.productid.eq(query.columns.productid));
query.where.add(query.columns.discontinued.eq(0)); query.where.add(query.columns.unitsinstock.gt(0)); query.where.add(query.columns.unitprice.le(nMaxPrice));
query.sort.add( joinEmb.columns.embedding.vector_distance(aQueryVector).asc );
foundset.loadRecords(query); application.output('Loaded ' + foundset.getSize() + ' products'); } catch (e) { application.output('Error in hybridSearchToFoundset: ' + e.message, LOGGINGLEVEL.ERROR); plugins.dialogs.showErrorDialog('Error', 'Search failed: ' + e.message, 'OK'); }}The key difference: query.result.addPk() instead of explicit columns. This is the standard QBSelect pattern for foundset loading. The foundset comes back sorted by semantic relevance, filtered by your business rules, and ready to display in any standard Servoy form. No special components, no custom rendering. It is just a foundset.
Combining Score Filtering with SQL Filtering
You can use both min_score() and traditional WHERE clauses in the same query. This gives you the tightest possible result set: semantically relevant AND matching your business criteria AND above a quality threshold:
// Only products with relevance above 0.6, under $20, in stock, not discontinuedquery.where.add( joinEmb.columns.embedding.vector_score(aQueryVector).min_score(0.6));query.where.add(query.columns.unitprice.le(20));query.where.add(query.columns.unitsinstock.gt(0));query.where.add(query.columns.discontinued.eq(0));
query.sort.add( joinEmb.columns.embedding.vector_distance(aQueryVector).asc);The min_score filter runs at the database level, which means PostgreSQL eliminates low-relevance rows before sorting and returning. This is more efficient than fetching a large result set and filtering client-side.
When to Use Each Approach
You now have three different ways to do semantic search in Servoy. Here is when to use each one:
oStore.search() from Articles 1 and 3: Use this when you want a quick similarity search and do not need SQL filters. It is the simplest approach. Good for search-as-you-type features, tool function fallbacks, and any case where the embedding store has everything you need.
QBVectorColumn with foundset.loadRecords(): Use this when you need semantic search combined with SQL filters and want the results in a standard foundset. This is the production pattern for search features embedded in existing Servoy forms.
QBVectorColumn with getDataSetByQuery(): Use this when you need the similarity score in the result set for display, or when you are building a custom search results view that shows relevance alongside product data.
The Complete Picture
If you have been following along through all four articles, you now have the full AI Runtime Plugin toolkit:
- Article 1: Chat completions with Promises and streaming, conversation memory, provider switching between OpenAI and Gemini, basic embeddings with in-memory stores, token tracking, and the chat-with-your-data SQL generation pattern.
- Article 2: Tool calling with the double-build pattern, multi-tool agents, the vector-fallback pattern for smart tool functions, built-in tools, and security practices for agentic workflows.
- Article 3: PgVector persistent stores that survive server restarts,
embedAll()for foundset embedding with automatic PK metadata, custom metadata columns, PDF document chunking and RAG, and batch strategies for keeping embeddings in sync. - Article 4: QBVectorColumn for hybrid queries that combine semantic similarity with traditional SQL filters in a single database round-trip, score-based filtering with
min_score(), distance-based filtering, and the foundset loading pattern for production search features.
That covers every feature of the plugins.ai plugin. The plugin is the foundation, and these patterns show you how to build real features on top of it. From here, the interesting work is in combining these building blocks for your specific application: a help assistant that uses RAG over your documentation, a smart search that replaces LIKE queries across your entire application, an agent that automates multi-step workflows your users currently do by hand.
That concludes this Servoy tutorial series on the AI Runtime Plugin. I hope you enjoyed it, and I look forward to bringing you more Servoy tutorials in the future.
The Series
This is Part 4 of a four-part series on the Servoy AI Runtime Plugin:
- Getting Started with the Servoy AI Runtime Plugin. Chat completions, streaming, conversation memory, embeddings, and your first semantic search.
- Tool Calling with the AI Runtime Plugin: Agentic Servoy. Register Servoy methods as tools and let the LLM decide when to call them.
- Embedding Your Servoy Data for Semantic Search. PgVector production stores, FoundSet
embedAll(), and PDF document chunking. - Hybrid Queries with QBVectorColumn: Semantic Meets SQL (this article). Combine semantic similarity with traditional WHERE clauses in a single database round-trip.