Embeddings
Generate vector embeddings for semantic search and RAG — pgvector storage, similarity queries, and batch processing.
What are embeddings?
Embeddings are numerical vector representations of text. Two pieces of text with similar meaning will have vectors that are close together in vector space, enabling:
- Semantic search — find documents by meaning, not just keywords
- RAG (Retrieval-Augmented Generation) — inject relevant context into LLM prompts
- Duplicate detection — find near-identical content
- Recommendation — suggest similar items based on content
Nebutra stores embeddings in PostgreSQL using the pgvector extension, so they live alongside your relational data with no additional infrastructure.
Setup
OPENAI_API_KEY=""
# Optional: override default embedding model
AI_EMBEDDING_MODEL="text-embedding-3-small"import { setFeatureFlag } from "@nebutra/preset";
await setFeatureFlag("org_123", "ai.embeddings", true);The Nebutra database schema already includes an embeddings table with a vector column. If you are setting up from scratch, ensure pgvector is enabled:
CREATE EXTENSION IF NOT EXISTS vector;Then run the Prisma migration:
pnpm --filter @nebutra/db migrate deployGenerating embeddings
Via the API endpoint
POST /api/v1/ai/embeddings
curl -X POST https://api.yourdomain.com/api/v1/ai/embeddings \
-H "Authorization: Bearer nbk_live_org123_xxx" \
-H "Content-Type: application/json" \
-d '{
"input": "How do I reset my password?",
"model": "text-embedding-3-small"
}'Response:
{
"object": "list",
"data": [
{
"object": "embedding",
"index": 0,
"embedding": [0.0023064255, -0.009327292, ...]
}
],
"model": "text-embedding-3-small",
"usage": {
"prompt_tokens": 8,
"total_tokens": 8
}
}Via @nebutra/agents (server-side)
import { embed } from "@nebutra/agents";
const { embedding } = await embed({
model: "text-embedding-3-small",
value: "How do I reset my password?",
tenantId,
});
// embedding is a Float32Array / number[]
console.log(embedding.length); // 1536Storing embeddings in pgvector
After generating an embedding, store it alongside the source content:
import { embed } from "@nebutra/agents";
import { prisma } from "@/lib/db";
async function indexDocument(content: string, tenantId: string) {
const { embedding } = await embed({
model: "text-embedding-3-small",
value: content,
tenantId,
});
await prisma.embedding.create({
data: {
content,
embedding, // pgvector stores this as a vector column
tenantId,
createdAt: new Date(),
},
});
}The embedding dimension must match when querying. If you index with text-embedding-3-small (1536 dims), you must query with the same model. Switching models requires re-indexing all documents.
Similarity search
Find the most semantically similar documents to a query:
import { embed } from "@nebutra/agents";
import { prisma } from "@/lib/db";
async function semanticSearch(query: string, tenantId: string, topK = 5) {
// 1. Embed the query
const { embedding } = await embed({
model: "text-embedding-3-small",
value: query,
tenantId,
});
// 2. Search using cosine similarity (<=> operator in pgvector)
const results = await prisma.$queryRaw<Array<{ id: string; content: string; similarity: number }>>`
SELECT id, content, 1 - (embedding <=> ${embedding}::vector) AS similarity
FROM embeddings
WHERE tenant_id = ${tenantId}
ORDER BY embedding <=> ${embedding}::vector
LIMIT ${topK}
`;
return results;
}pgvector distance operators
| Operator | Distance metric | Use for |
|---|---|---|
<=> | Cosine distance | Text similarity (recommended) |
<-> | Euclidean (L2) | Geometry, image features |
<#> | Inner product (negative) | Normalized vectors |
For most text use cases, cosine distance (<=>) gives the best results.
Batch embedding
Embed multiple texts in a single API call to reduce latency and token overhead:
import { embedMany } from "@nebutra/agents";
const texts = [
"How do I reset my password?",
"Where can I update my billing info?",
"How do I invite team members?",
];
const { embeddings } = await embedMany({
model: "text-embedding-3-small",
values: texts,
tenantId,
});
// embeddings is an array, one vector per input text
for (let i = 0; i < texts.length; i++) {
await prisma.embedding.create({
data: { content: texts[i], embedding: embeddings[i], tenantId },
});
}Batch embedding is significantly more efficient than calling embed() in a loop. Use embedMany() whenever indexing more than one document at a time.
RAG pattern
Use embeddings to retrieve relevant context before calling the chat API:
import { embed, generateText } from "@nebutra/agents";
async function ragQuery(userQuestion: string, tenantId: string) {
// 1. Retrieve relevant documents
const { embedding } = await embed({
model: "text-embedding-3-small",
value: userQuestion,
tenantId,
});
const docs = await prisma.$queryRaw<Array<{ content: string }>>`
SELECT content
FROM embeddings
WHERE tenant_id = ${tenantId}
ORDER BY embedding <=> ${embedding}::vector
LIMIT 3
`;
// 2. Build context string
const context = docs.map((d) => d.content).join("\n\n---\n\n");
// 3. Generate answer with context injected
const { text } = await generateText({
model: "gpt-5.4-mini",
prompt: `Answer the question using ONLY the context below.
Context:
${context}
Question: ${userQuestion}`,
tenantId,
});
return text;
}Creating a vector index
For large embedding tables, add an IVFFlat or HNSW index to speed up similarity searches:
-- HNSW index (recommended for most cases — fast build, excellent recall)
CREATE INDEX ON embeddings USING hnsw (embedding vector_cosine_ops);
-- IVFFlat index (good for very large datasets, tune lists to sqrt(row count))
CREATE INDEX ON embeddings USING ivfflat (embedding vector_cosine_ops) WITH (lists = 100);Recommended index configuration for Nebutra's managed tiers (Neon / Supabase):
-- HNSW (recommended for ≤5M rows — best query speed, ~2% build overhead)
CREATE INDEX CONCURRENTLY ON embeddings
USING hnsw (embedding vector_cosine_ops)
WITH (m = 16, ef_construction = 64);
-- IVFFlat (better for >5M rows — faster build, tune lists ≈ sqrt(row_count))
CREATE INDEX CONCURRENTLY ON embeddings
USING ivfflat (embedding vector_cosine_ops)
WITH (lists = 200);Set SET hnsw.ef_search = 100 at query time to balance recall vs. speed. On Neon free tier, keep m ≤ 16 to stay within the shared memory limit.
Model dimensions reference
| Model | Dimensions | Relative cost | Notes |
|---|---|---|---|
text-embedding-3-small | 1536 | Low | Default, good balance |
text-embedding-3-large | 3072 | Medium | Higher accuracy |
text-embedding-ada-002 | 1536 | Low | Legacy, use 3-small instead |
Related
How is this guide?
Last updated on