Generating Embeddings
This tutorial shows how to use the SOAT Embeddings endpoint to convert text into numeric vectors and compute cosine similarity between them. You will:
- Authenticate and call the endpoint with a single text input.
- Embed a batch of texts in one request.
- Implement a cosine similarity function in TypeScript.
- Find the most semantically similar text in a collection.
By the end you will know how to generate embeddings via SOAT and wire them into any similarity-based feature — semantic search, recommendation, clustering, or a dedicated search engine like Meilisearch.
Prerequisites
- SOAT running locally. Follow the Quick Start guide to bring the stack up with Docker Compose.
- New to SOAT? Read Key Concepts first.
- For production hardening (env vars, secrets), see Advanced Configuration.
- Ollama running locally with an embedding model pulled, for example:
ollama pull qwen3-embedding:0.6b
- The server must have
EMBEDDING_PROVIDER=ollamaandEMBEDDING_MODEL=qwen3-embedding:0.6bset. curl,jq, andnodeavailable in your shell.
- CLI
- SDK
- curl
export SOAT_BASE_URL=http://localhost:5047
import { Embeddings, createClient, createConfig } from '@soat/sdk';
export SOAT_BASE_URL=http://localhost:5047
Step 1 — Log in
Authenticate as admin to obtain a token. See Users for full authentication details.
- CLI
- SDK
- curl
ADMIN_TOKEN=$(soat login-user --username admin --password Admin1234! | jq -r '.token')
export SOAT_TOKEN=$ADMIN_TOKEN
import { SoatClient, Embeddings, createClient, createConfig } from '@soat/sdk';
const soat = new SoatClient({ baseUrl: 'http://localhost:5047' });
const { data: login } = await soat.users.loginUser({
body: { username: 'admin', password: 'Admin1234!' },
});
const TOKEN = login.token;
const client = createClient(
createConfig({
baseUrl: 'http://localhost:5047',
headers: { Authorization: `Bearer ${TOKEN}` },
})
);
ADMIN_TOKEN=$(curl -s -X POST "$SOAT_BASE_URL/api/v1/users/login" \
-H "Content-Type: application/json" \
-d '{"username":"admin","password":"Admin1234!"}' | jq -r '.token')
Step 2 — Embed a single text
Pass input to embed one piece of text. The server returns embedding — a floating-point array whose length equals EMBEDDING_DIMENSIONS (default 1024).
See Embeddings — Single vs batch for when to choose single vs batch mode.
- CLI
- SDK
- curl
soat create-embeddings \
--input "SOAT is an open-source platform for building AI-powered applications." \
| jq '{dimensions: (.embedding | length), first_values: .embedding[:3]}'
Expected output:
{
"dimensions": 1024,
"first_values": [0.032, -0.041, 0.018]
}
const { data } = await Embeddings.createEmbeddings({
client,
body: {
input: 'SOAT is an open-source platform for building AI-powered applications.',
},
});
console.log(data.embedding.length); // 1024
console.log(data.embedding.slice(0, 3)); // first three values
curl -s -X POST "$SOAT_BASE_URL/api/v1/embeddings" \
-H "Authorization: Bearer $ADMIN_TOKEN" \
-H "Content-Type: application/json" \
-d '{"input":"SOAT is an open-source platform for building AI-powered applications."}' \
| jq '{dimensions: (.embedding | length), first_values: .embedding[:3]}'
Step 3 — Embed a batch of texts
Pass inputs (an array) to generate multiple vectors in a single request. The Embeddings module returns embeddings — an array of vectors in the same order as the inputs.
- CLI
- SDK
- curl
soat create-embeddings \
--inputs '["Wooden cutting board", "Cast iron skillet", "Silicone spatula set", "Stainless steel mixing bowls"]' \
| jq '{count: (.embeddings | length), dims: (.embeddings[0] | length)}'
Expected output:
{
"count": 4,
"dims": 1024
}
const { data } = await Embeddings.createEmbeddings({
client,
body: {
inputs: [
'Wooden cutting board',
'Cast iron skillet',
'Silicone spatula set',
'Stainless steel mixing bowls',
],
},
});
console.log(data.embeddings.length); // 4
console.log(data.embeddings[0].length); // 1024
curl -s -X POST "$SOAT_BASE_URL/api/v1/embeddings" \
-H "Authorization: Bearer $ADMIN_TOKEN" \
-H "Content-Type: application/json" \
-d '{"inputs":["Wooden cutting board","Cast iron skillet","Silicone spatula set","Stainless steel mixing bowls"]}' \
| jq '{count: (.embeddings | length), dims: (.embeddings[0] | length)}'
Step 4 — Compute cosine similarity
Cosine similarity measures how alike two vectors are, regardless of their magnitude. It returns a value between -1 (opposite) and 1 (identical). Because all embeddings from the same SOAT deployment share the same vector space (see Shared vector space), cosine similarity is meaningful across any two texts.
- CLI
- SDK
- curl
VECS=$(soat create-embeddings \
--inputs '["I love cooking pasta", "My favourite dish is spaghetti"]' \
| jq '.embeddings')
node << EOF
const vecs = $VECS;
const [a, b] = vecs;
const dot = a.reduce((s, v, i) => s + v * b[i], 0);
const magA = Math.sqrt(a.reduce((s, v) => s + v * v, 0));
const magB = Math.sqrt(b.reduce((s, v) => s + v * v, 0));
console.log('similarity:', (dot / (magA * magB)).toFixed(4));
EOF
const cosineSimilarity = (a: number[], b: number[]): number => {
const dot = a.reduce((sum, v, i) => sum + v * b[i], 0);
const magA = Math.sqrt(a.reduce((sum, v) => sum + v * v, 0));
const magB = Math.sqrt(b.reduce((sum, v) => sum + v * v, 0));
return dot / (magA * magB);
};
// Embed both texts in one batch request
const { data } = await Embeddings.createEmbeddings({
client,
body: {
inputs: [
'I love cooking pasta',
'My favourite dish is spaghetti',
],
},
});
const [vecA, vecB] = data.embeddings;
console.log('similarity:', cosineSimilarity(vecA, vecB).toFixed(4));
// → ~0.93 (highly similar)
VECS=$(curl -s -X POST "$SOAT_BASE_URL/api/v1/embeddings" \
-H "Authorization: Bearer $ADMIN_TOKEN" \
-H "Content-Type: application/json" \
-d '{"inputs":["I love cooking pasta","My favourite dish is spaghetti"]}' \
| jq '.embeddings')
node << EOF
const vecs = $VECS;
const [a, b] = vecs;
const dot = a.reduce((s, v, i) => s + v * b[i], 0);
const magA = Math.sqrt(a.reduce((s, v) => s + v * v, 0));
const magB = Math.sqrt(b.reduce((s, v) => s + v * v, 0));
console.log('similarity:', (dot / (magA * magB)).toFixed(4));
EOF
Step 5 — Find the most similar text
Use cosine similarity to rank a collection of texts against a query. Embed everything in one batch call, then sort by similarity score.
- CLI
- SDK
- curl
ALL=$(soat create-embeddings \
--inputs '["something to cook with on the stove","Wooden cutting board","Cast iron skillet","Silicone spatula set","Stainless steel mixing bowls"]' \
| jq '.embeddings')
node << EOF
const vecs = $ALL;
const items = ['Wooden cutting board', 'Cast iron skillet', 'Silicone spatula set', 'Stainless steel mixing bowls'];
const query = vecs[0];
const rest = vecs.slice(1);
const cos = (a, b) => {
const dot = a.reduce((s, v, i) => s + v * b[i], 0);
const magA = Math.sqrt(a.reduce((s, v) => s + v * v, 0));
const magB = Math.sqrt(b.reduce((s, v) => s + v * v, 0));
return dot / (magA * magB);
};
items
.map((name, i) => ({ name, score: cos(query, rest[i]) }))
.sort((a, b) => b.score - a.score)
.forEach(r => console.log(r.score.toFixed(4), r.name));
EOF
Expected output — the cast iron skillet ranks first because it is semantically closest to stovetop cooking:
0.8412 Cast iron skillet
0.7931 Silicone spatula set
0.7204 Stainless steel mixing bowls
0.6981 Wooden cutting board
const cosineSimilarity = (a: number[], b: number[]): number => {
const dot = a.reduce((sum, v, i) => sum + v * b[i], 0);
const magA = Math.sqrt(a.reduce((sum, v) => sum + v * v, 0));
const magB = Math.sqrt(b.reduce((sum, v) => sum + v * v, 0));
return dot / (magA * magB);
};
const query = 'something to cook with on the stove';
const items = [
'Wooden cutting board',
'Cast iron skillet',
'Silicone spatula set',
'Stainless steel mixing bowls',
];
// Embed query + all items in one batch call
const { data } = await Embeddings.createEmbeddings({
client,
body: { inputs: [query, ...items] },
});
const [queryVec, ...itemVecs] = data.embeddings;
const ranked = items
.map((name, i) => ({ name, score: cosineSimilarity(queryVec, itemVecs[i]) }))
.sort((a, b) => b.score - a.score);
ranked.forEach((r) => console.log(r.score.toFixed(4), r.name));
// 0.8412 Cast iron skillet ← most similar to "cook on the stove"
// 0.7931 Silicone spatula set
// 0.7204 Stainless steel mixing bowls
// 0.6981 Wooden cutting board
ALL=$(curl -s -X POST "$SOAT_BASE_URL/api/v1/embeddings" \
-H "Authorization: Bearer $ADMIN_TOKEN" \
-H "Content-Type: application/json" \
-d '{"inputs":["something to cook with on the stove","Wooden cutting board","Cast iron skillet","Silicone spatula set","Stainless steel mixing bowls"]}' \
| jq '.embeddings')
node << EOF
const vecs = $ALL;
const items = ['Wooden cutting board', 'Cast iron skillet', 'Silicone spatula set', 'Stainless steel mixing bowls'];
const query = vecs[0];
const rest = vecs.slice(1);
const cos = (a, b) => {
const dot = a.reduce((s, v, i) => s + v * b[i], 0);
const magA = Math.sqrt(a.reduce((s, v) => s + v * v, 0));
const magB = Math.sqrt(b.reduce((s, v) => s + v * v, 0));
return dot / (magA * magB);
};
items
.map((name, i) => ({ name, score: cos(query, rest[i]) }))
.sort((a, b) => b.score - a.score)
.forEach(r => console.log(r.score.toFixed(4), r.name));
EOF
What's next
- Production-scale search — for large document collections, pass SOAT-generated vectors to a dedicated vector search engine. Meilisearch supports a
userProvidedembedder source that accepts your own vectors for hybrid keyword + semantic search. Qdrant and pgvector are good alternatives. - Re-embed on update — when a document's text changes, call SOAT's embeddings endpoint again and update the stored vector. Only changed documents need reprocessing.
- Agents with knowledge — see Agent with Persistent Memory to learn how SOAT uses embeddings automatically to inject relevant context before every agent generation, without any external search engine.
- Knowledge search — use
POST /api/v1/knowledge/searchto query across SOAT Documents and Memories using the same embedding model. See Knowledge.