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 deploy

Generating 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); // 1536

Storing 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.

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

OperatorDistance metricUse for
<=>Cosine distanceText 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

ModelDimensionsRelative costNotes
text-embedding-3-small1536LowDefault, good balance
text-embedding-3-large3072MediumHigher accuracy
text-embedding-ada-0021536LowLegacy, use 3-small instead

How is this guide?

Edit on GitHub

Last updated on

On this page