Last reviewed: 2026-07-30

Direct answer

Build the pipeline around one immutable embedding contract: model text-embedding-3-large, 1,536 dimensions, a PostgreSQL vector(1536) column, cosine distance, and an HNSW index configured with vector_cosine_ops. The refetched CometAPI model reference documents a 3,072-dimension default and a dimensions request parameter for shorter vectors. The pgvector documentation lists a 2,000-dimension limit for indexed vector values. Requesting 1,536 dimensions therefore keeps this tutorial below that index limit without changing types.

Install the Node.js dependencies:

npm install pg pgvector

Enable pgvector in the database and create a dimension-locked table. Run the extension statement through a controlled migration identity. PostgreSQL must already have the extension support files installed, and the migration identity must have the required privileges.

CREATE EXTENSION IF NOT EXISTS vector;

CREATE TABLE semantic_documents (
  source_key text PRIMARY KEY,
  content text NOT NULL,
  embedding_model text NOT NULL,
  embedding_dimensions integer NOT NULL
    CHECK (embedding_dimensions = 1536),
  embedding vector(1536) NOT NULL,
  updated_at timestamptz NOT NULL DEFAULT now()
);

The following module accepts a server-side requestEmbedding adapter. That adapter sends the listed model, input, and dimensions fields through an established CometAPI transport and returns parsed JSON. Keeping transport setup outside this module lets the example demonstrate the embedding and database contracts without placing authentication material in source code.

import pg from 'pg';
import pgvector from 'pgvector/pg';

const { Client } = pg;
const MODEL = 'text-embedding-3-large';
const DIMENSIONS = 1536;

class ContractError extends Error {
  constructor(code, receivedDimensions = null) {
    super(code);
    this.code = code;
    this.receivedDimensions = receivedDimensions;
  }
}

function readVector(response) {
  if (response?.model !== undefined && response.model !== MODEL) {
    throw new ContractError('model_mismatch');
  }

  const vector = response?.data?.[0]?.embedding;
  if (!Array.isArray(vector)) {
    throw new ContractError('embedding_missing');
  }
  if (vector.length !== DIMENSIONS) {
    throw new ContractError('dimension_mismatch', vector.length);
  }
  if (!vector.every((value) => Number.isFinite(value))) {
    throw new ContractError('embedding_not_finite', vector.length);
  }
  return vector;
}

function failureClass(error) {
  return error instanceof ContractError ? error.code : 'transport_error';
}

export async function openSemanticSearch({
  databaseConfig,
  requestEmbedding,
  log = () => {}
}) {
  const client = new Client(databaseConfig);
  await client.connect();
  await pgvector.registerTypes(client);

  async function embed(input, operationId) {
    const startedAt = Date.now();
    const inputChars = typeof input === 'string' ? input.length : 0;

    try {
      if (typeof input !== 'string' || input.trim().length === 0) {
        throw new ContractError('input_empty');
      }

      const response = await requestEmbedding({
        model: MODEL,
        input: input.trim(),
        dimensions: DIMENSIONS
      });
      const vector = readVector(response);

      log({
        event: 'embedding_request',
        outcome: 'ok',
        operation_id: operationId,
        model: MODEL,
        dimensions_expected: DIMENSIONS,
        dimensions_received: vector.length,
        input_chars: inputChars,
        duration_ms: Date.now() - startedAt
      });
      return vector;
    } catch (error) {
      log({
        event: 'embedding_request',
        outcome: 'error',
        operation_id: operationId,
        model: MODEL,
        dimensions_expected: DIMENSIONS,
        dimensions_received:
          error instanceof ContractError ? error.receivedDimensions : null,
        input_chars: inputChars,
        duration_ms: Date.now() - startedAt,
        error_class: failureClass(error)
      });
      throw error;
    }
  }

  async function upsertDocument({ sourceKey, content, operationId }) {
    if (typeof sourceKey !== 'string' || sourceKey.length === 0) {
      throw new ContractError('source_key_empty');
    }

    const vector = await embed(content, operationId);
    try {
      const result = await client.query(
        `INSERT INTO semantic_documents
           (source_key, content, embedding_model, embedding_dimensions, embedding)
         VALUES ($1, $2, $3, $4, $5)
         ON CONFLICT (source_key) DO UPDATE SET
           content = EXCLUDED.content,
           embedding_model = EXCLUDED.embedding_model,
           embedding_dimensions = EXCLUDED.embedding_dimensions,
           embedding = EXCLUDED.embedding,
           updated_at = now()`,
        [
          sourceKey,
          content,
          MODEL,
          DIMENSIONS,
          pgvector.toSql(vector)
        ]
      );

      log({
        event: 'semantic_document_upsert',
        outcome: 'ok',
        operation_id: operationId,
        model: MODEL,
        dimensions_expected: DIMENSIONS,
        row_count: result.rowCount
      });
    } catch (error) {
      log({
        event: 'semantic_document_upsert',
        outcome: 'error',
        operation_id: operationId,
        model: MODEL,
        dimensions_expected: DIMENSIONS,
        error_class: 'database_error'
      });
      throw error;
    }
  }

  async function search({ queryText, limit = 10, operationId }) {
    if (!Number.isInteger(limit) || limit < 1 || limit > 50) {
      throw new ContractError('limit_invalid');
    }

    const queryVector = await embed(queryText, operationId);
    const startedAt = Date.now();

    try {
      const result = await client.query(
        `SELECT
           source_key,
           content,
           1 - (embedding <=> $1) AS cosine_similarity
         FROM semantic_documents
         WHERE embedding_model = $2
           AND embedding_dimensions = $3
         ORDER BY embedding <=> $1
         LIMIT $4`,
        [pgvector.toSql(queryVector), MODEL, DIMENSIONS, limit]
      );

      log({
        event: 'semantic_search',
        outcome: 'ok',
        operation_id: operationId,
        model: MODEL,
        dimensions_expected: DIMENSIONS,
        limit,
        result_count: result.rowCount,
        duration_ms: Date.now() - startedAt
      });
      return result.rows;
    } catch (error) {
      log({
        event: 'semantic_search',
        outcome: 'error',
        operation_id: operationId,
        model: MODEL,
        dimensions_expected: DIMENSIONS,
        limit,
        duration_ms: Date.now() - startedAt,
        error_class: 'database_error'
      });
      throw error;
    }
  }

  return {
    upsertDocument,
    search,
    close: () => client.end()
  };
}

pgvector.toSql serializes each JavaScript array for PostgreSQL, while node-postgres sends it as a value parameter. The SQL text retains fixed table and column identifiers. This distinction matters because node-postgres can parameterize values but not identifiers.

Start with pgvector’s exact search so you can record a relevance baseline. Once that baseline passes, add the cosine HNSW index:

CREATE INDEX semantic_documents_embedding_hnsw
ON semantic_documents
USING hnsw (embedding vector_cosine_ops);

A concrete happy-path operator workflow is:

  1. Apply the extension and table migration in a staging database.
  2. Start the Node.js service, connect one client, and register pgvector types on that client.
  3. Upsert several stable test documents whose expected matches are known in advance.
  4. Search with a paraphrase rather than an exact phrase, then verify that the expected document ranks first.
  5. Record result order and latency with exact search, create the HNSW index, and repeat the same query set.
  6. Promote only if the indexed results meet the team’s measured relevance and latency targets.

The error-path workflow should be just as concrete:

  1. Make a test adapter return a vector whose length is not 1,536.
  2. Confirm that dimension_mismatch is raised before any insert or search query runs.
  3. Make the transport reject a request and confirm that the log contains transport_error, not a raw response body.
  4. Make the database unavailable and confirm that the service reports database_error without retrying an unsafe partial write.
  5. Verify after every failure that no malformed vector was persisted.

Use an allowlist for logs: event, outcome, operation_id, model, dimensions_expected, dimensions_received, input_chars, limit, result_count, row_count, duration_ms, transport_status, database_code, and error_class. Do not log input text, vectors, request payloads, response bodies, headers, or database configuration. An operation identifier should correlate events without encoding user content.

Who this is for

This tutorial is for Node.js developers who already operate a PostgreSQL application and want to add semantic retrieval without moving application records into a separate datastore. You should be comfortable running database migrations, passing dependencies into a server module, and writing integration tests.

The CometAPI request must originate in a trusted server environment. Keep transport configuration outside browser bundles and repositories. The storage field guide provides related persistence guidance. You also need a PostgreSQL environment where pgvector is installed or available through the hosting provider.

Key takeaways

  • Treat the model ID, requested dimensions, column type, stored metadata, query operator, and index operator class as one versioned contract.
  • Request 1,536 dimensions explicitly rather than relying on the model’s 3,072-dimension default.
  • Reject missing, non-finite, or incorrectly sized vectors before calling PostgreSQL.
  • Use pgvector.toSql and node-postgres value parameters for inserts and nearest-neighbor queries.
  • Use <=> for cosine distance and calculate cosine similarity as one minus that distance.
  • Establish an exact-search relevance baseline before accepting HNSW’s speed-for-recall tradeoff.
  • Re-embed the corpus when the model or dimensions change; do not silently mix incompatible generations.

Sources checked

  • The CometAPI text-embedding-3-large reference documents the model ID, 3,072-dimension default, dimension shortening, batch inputs, response vectors, and input limits.
  • The pgvector project documentation documents vector storage, cosine distance, exact and approximate search, HNSW and IVFFlat, and indexed type limits.
  • The pgvector-node documentation demonstrates enabling the extension, registering PostgreSQL types, converting vectors with toSql, inserting parameters, and issuing nearest-neighbor queries from Node.js.
  • The node-postgres query guide explains value parameterization and warns against concatenating values into SQL text. It also states that identifiers cannot be supplied as query parameters.
  • The PostgreSQL CREATE EXTENSION documentation defines the extension syntax, database-level installation behavior, supporting-file prerequisite, and privilege considerations.

Contract details to verify

Extension availability. CREATE EXTENSION loads an extension into the current database; it does not install missing server files. Confirm pgvector availability with the database provider before deploying the migration. Run the extension migration separately in every database that needs vector types and operators. IF NOT EXISTS avoids an error when an extension with that name is already loaded, but it is not a version check, so record the installed pgvector version in deployment evidence.

Request shape. Send model, input, and dimensions through the server-side adapter. This tutorial fixes them at text-embedding-3-large, the document or query text, and 1536. The CometAPI reference says the model accepts either a single input or an array and allows shorter output through dimensions. It also documents up to 8,192 tokens per input and up to 300,000 total input tokens in one request. Enforce chunking and batch-size policy before production ingestion rather than treating character count as a token count.

Response validation. Normalize the transport response to the local { data: [{ embedding }] } contract before it reaches this module. Verify the returned model when present, require one numeric array, require exactly 1,536 elements, and reject non-finite values. A successful transport response is not sufficient evidence that the payload is safe to store.

Database shape. vector(1536) makes PostgreSQL reject values with a different dimension. The explicit embedding_dimensions and embedding_model columns make the generation visible to operators and let search exclude records produced by another contract. The check constraint prevents metadata from claiming a different size than the column accepts.

Query parameterization. Both content and serialized vectors are values, so they belong in the parameter array. Keep table names, column names, sort operators, and operator classes fixed in reviewed SQL. If an application truly needs dynamic identifiers, do not pass untrusted strings into the template; node-postgres explicitly distinguishes identifier escaping from value parameterization.

Distance and similarity. pgvector defines <=> as cosine distance and documents cosine similarity as 1 - cosine distance. The query orders by ascending distance so the nearest records appear first, while the selected similarity field gives the application a more intuitive score. Do not present an unvalidated universal similarity cutoff. Build a labeled query set and choose any threshold from observed retrieval quality.

Index alignment. An HNSW cosine index must use vector_cosine_ops, matching the query’s <=> operator. An index built for L2 or inner product is not the index specified by this query contract. pgvector says exact search is the default and approximate indexes exchange some recall for speed. Compare both modes on the same fixtures before rollout.

Connection registration. pgvector-node registers PostgreSQL vector types on a client connection. Register every connection that will read vector values. The single-client example does this immediately after connecting; an application using a pool must apply the equivalent registration to each newly established pool connection.

Backfill identity. Upsert by a stable source key and retain the source text used to generate the stored vector. If text changes, regenerate its embedding in the same write workflow. If the model or dimension policy changes, create a distinguishable generation, backfill it, validate it, and then switch reads deliberately.

Failure modes

The default vector reaches a 1,536-dimension column. If the request omits dimensions or a transport drops that field, the model reference indicates that the default can be 3,072 dimensions. The application validator should reject it before PostgreSQL does. Log expected and received counts, then inspect the adapter contract.

The extension cannot be loaded. The server may lack pgvector support files, or the migration identity may lack sufficient privilege. Treat this as a deployment failure. Do not let application instances start accepting indexing work until the migration is confirmed in the target database.

The response is structurally incomplete. A transport failure, upstream change, or adapter bug can leave the expected embedding absent. Reject missing arrays, unexpected model identifiers, and non-finite elements. Do not substitute an empty or zero-filled vector because that converts a visible contract failure into misleading search results.

The index and query use different metrics. A cosine query paired with an L2 operator class breaks the intended index alignment. Keep the operator and index definition in the same reviewed migration, and inspect the executed plan during performance validation.

Approximate search changes ranking. pgvector warns that approximate indexing can return different results from exact search. Preserve a labeled relevance suite, compare recall after index creation, and tune only from measured evidence. HNSW settings affect build cost, memory, speed, and recall.

Mixed generations enter one corpus. Vectors from another model or dimension policy may be numerically valid yet semantically incompatible. Store model and dimension metadata, filter reads to one generation, and perform a controlled backfill before changing the active generation.

A long input fails upstream. The CometAPI model reference documents per-input and per-request token limits. Split long documents into stable chunks with source identifiers, and bound batch size. Do not retry the same oversized payload indefinitely.

SQL values are concatenated. Text or vector strings inserted directly into SQL create correctness and injection risk. Keep all runtime values in the node-postgres parameter array and reject attempts to make identifiers user-controlled.

Logs retain sensitive application data. Input text and embeddings can reveal document contents. Raw transport and database diagnostics may contain more detail than operators need. Emit only the allowlisted metadata fields, classify failures locally, and keep verbose payload capture disabled.

A partial backfill becomes active. If reads switch before all intended records are embedded, relevant documents disappear from results. Track expected, processed, rejected, and remaining row counts by generation. Switch the read contract only after reconciliation and relevance tests pass.

FAQ

Why use 1,536 dimensions instead of the 3,072 default?

The tutorial needs a conventional vector HNSW index. pgvector lists indexed vector support up to 2,000 dimensions, while CometAPI supports shortening through the dimensions parameter. A 1,536-dimension contract fits below that limit and leaves one consistent size across requests, storage, and queries.

Can I use the full model output?

Not with this schema. Evaluate the database type, index support, storage cost, latency, and retrieval quality as a separate design. pgvector documents indexed halfvec support up to 4,000 dimensions, but changing to it is a type and precision decision, not a drop-in edit. Test it with your corpus before adopting it.

Why choose cosine similarity?

pgvector directly supports cosine distance and a matching HNSW operator class. This tutorial uses that metric consistently from index definition through result presentation. It is still an application choice, so compare it with other supported metrics on labeled queries if retrieval quality is uncertain.

Do I need HNSW immediately?

No. pgvector uses exact nearest-neighbor search by default. Exact search provides the correctness baseline needed to evaluate an approximate index. Add HNSW when corpus size or latency measurements justify the recall tradeoff.

Why store model and dimension columns when the vector column already fixes size?

The vector column catches dimension errors, but it cannot explain which model produced a numerically compatible vector. Explicit metadata makes drift observable, supports generation filters, and gives backfills an auditable boundary.

Can the search limit be parameterized?

Yes. It is a value, and the example passes it as $4 after validating a narrow integer range. Table names, column names, and operators are identifiers or SQL syntax and remain fixed.

What should happen when the embedding request fails?

Stop before the database query, return a controlled application error, and log a local failure class plus non-sensitive measurements. Retry only under a bounded application policy that distinguishes temporary transport failures from deterministic contract errors such as an invalid dimension.

How should model changes be deployed?

Validate the current catalog entry, create a new generation boundary, backfill documents, compare relevance against the existing generation, and switch reads only after reconciliation. The model-catalog validation checklist covers the evidence step in more detail.

Reader next step

Create a staging table and index three small, non-sensitive documents with clearly different topics. Run one paraphrased query per document, record the exact-search ranking, and then repeat after creating the HNSW index. Add an error test whose adapter returns the wrong vector length and assert that no database write occurs. Finally, inspect the emitted events to confirm that they contain only the allowlisted fields.

Once that slice is reliable, expand the labeled query set before backfilling production data. Use the source and dimension checks above for every deployment before connecting this retrieval layer to the rest of the application.