Skip to main content

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:

  1. Authenticate and call the endpoint with a single text input.
  2. Embed a batch of texts in one request.
  3. Implement a cosine similarity function in TypeScript.
  4. 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=ollama and EMBEDDING_MODEL=qwen3-embedding:0.6b set.
  • curl, jq, and node available in your shell.
export SOAT_BASE_URL=http://localhost:5047

Step 1 — Log in

Authenticate as admin to obtain a token. See Users for full authentication details.

ADMIN_TOKEN=$(soat login-user --username admin --password Admin1234! | jq -r '.token')
export SOAT_TOKEN=$ADMIN_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.

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]
}

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.

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
}

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.

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

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.

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

What's next

  • Production-scale search — for large document collections, pass SOAT-generated vectors to a dedicated vector search engine. Meilisearch supports a userProvided embedder 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/search to query across SOAT Documents and Memories using the same embedding model. See Knowledge.