How to buildOctober 12, 202611 min read
AI integrationsStep 5 of 8

Embeddings and vector search with pgvector

In short

An embedding is a vector of several hundred numbers that a model computes from a text so that texts with similar meaning get nearby vectors. You can store the vectors directly in PostgreSQL with the pgvector extension, find the nearest ones by cosine distance with the <=> operator, and add an approximate HNSW or IVFFlat index for large tables. Embeddings can come from a local model with no API keys or from a cloud API — what matters is encoding documents and queries with the same model.

TutorialLevel: IntermediateEmbeddings & Vector DatabasesPractice: ~1.5–3 h

After this you will be able to

  • generate text embeddings with a local model via @huggingface/transformers and explain what cosine similarity means
  • store vectors in PostgreSQL with pgvector and find the nearest documents with the <=> operator
  • add an HNSW or IVFFlat index, tune hnsw.ef_search and ivfflat.probes and measure recall@10
  • combine vector and full-text search with Reciprocal Rank Fusion

Verified with

PostgreSQL 17.11 + pgvector 0.8.7 (образ pgvector/pgvector:pg17)Node.js 24.18@huggingface/transformers 4.3.1, модель Xenova/multilingual-e5-smallpg 8.23.1, pgvector (npm) 0.3.0openai 7.32.0 (проверено на мок-сервере)Docker 29

Checked against docs: October 12, 2026

Contents
  1. 01An embedding is a text as a point in meaning space
  2. 02Distances: cosine, dot product, L2
  3. 03Where embeddings come from: a local model or an API
  4. 04pgvector in Docker: table, loading and the first search
  5. 05HNSW and IVFFlat indexes
  6. 06Hybrid search: meaning plus keywords
  7. 07Chunking, common mistakes and when you need a separate vector database
  8. 08Exercises

An embedding is a text as a point in meaning space

Keyword search finds a document only if it contains a word from the query. A customer types “need an app for the phone”, while the service description says “mobile apps for iOS and Android” — there are almost no shared words, and LIKE or full-text search will miss. Vector search compares meaning rather than words.

An embedding model (a neural network that turns text into a vector) reads a text and returns an array of numbers of fixed length. In this tutorial it is the multilingual multilingual-e5-small: 384 numbers for any text up to 512 tokens; longer input is truncated. According to its model card it supports about a hundred languages, including Russian and English. The individual numbers mean nothing on their own; what matters is how vectors relate to each other: texts about similar things end up close together.

Closeness of two vectors is measured by the cosine of the angle between them: the dot product divided by the product of their lengths. Identical directions give 1, perpendicular ones give 0. Let’s compute the cosine by hand for three phrases — no database, just the model and arithmetic. The demo corpus is in Russian because our studio works in Almaty; the model handles both languages:

src/cosine.ts
import { embed } from "./embed.ts";

function dot(a: number[], b: number[]): number {
  let sum = 0;
  for (let i = 0; i < a.length; i++) sum += a[i] * b[i];
  return sum;
}

function norm(a: number[]): number {
  return Math.sqrt(dot(a, a));
}

// Cosine similarity: dot product divided by the product of lengths
export function cosine(a: number[], b: number[]): number {
  return dot(a, b) / (norm(a) * norm(b));
}

const texts = [
  "Нужен интернет-магазин с каталогом товаров",
  "Разработка сайта с корзиной и онлайн-оплатой",
  "Как настроить резервное копирование базы данных",
];
const vectors = await embed(texts, "query");

console.log("dimensions:", vectors[0].length);
console.log("vector length:", norm(vectors[0]).toFixed(4));
console.log("first 5 numbers:", vectors[0].slice(0, 5).map((x) => x.toFixed(4)).join(", "));
for (let i = 0; i < texts.length; i++) {
  for (let j = i + 1; j < texts.length; j++) {
    console.log(`${i + 1}↔${j + 1}: ${cosine(vectors[i], vectors[j]).toFixed(3)}`);
  }
}
Cosine similarity by hand; the embed.ts module is in the next section
terminal
$ node src/cosine.ts
dimensions: 384
vector length: 1.0000
first 5 numbers: -0.0070, -0.0174, -0.0829, -0.0610, 0.0510
1↔2: 0.859
1↔3: 0.794
2↔3: 0.801
Real output. Phrases 1 and 2 (an online store and a site with checkout) are closer to each other than to the phrase about database backups

Distances: cosine, dot product, L2

pgvector supports several distance functions, each with its own operator and its own operator class for indexes. A distance is smaller when vectors are closer, so sorting is always ORDER BY … ASC.

<=>

What it computes
Cosine distance = 1 − cosine similarity
Operator class for the index
vector_cosine_ops

<#>

What it computes
Negative dot product (indexes only scan ASC)
Operator class for the index
vector_ip_ops

<->

What it computes
Euclidean distance (L2)
Operator class for the index
vector_l2_ops

<+>

What it computes
Manhattan distance (L1)
Operator class for the index
vector_l1_ops
pgvector distance operators for the vector type

When vectors are normalized to length 1, the three main metrics produce the same ranking: cosine equals the dot product, and L2 is monotonically related to it. The OpenAI docs and the Voyage AI embeddings guide state that their vectors are already normalized. We normalize ourselves with the normalize: true option of the Transformers.js pipeline.

  • Use <=> and vector_cosine_ops if you are not sure the vectors are normalized — cosine ignores length
  • For normalized vectors pgvector recommends <#>: the dot product is cheaper to compute
  • An index only serves the metric of its operator class: a vector_l2_ops index will not speed up a <=> query

Where embeddings come from: a local model or an API

For the tutorial we need a model that runs with no keys or sign-up. Transformers.js (@huggingface/transformers) runs ONNX models directly in Node.js: on the first call it downloads the weights from the Hugging Face Hub and caches them on disk. We use the quantized q8 variant — about 120 MB instead of 470 MB for full fp32; on our queries both variants put the same document in first place.

src/embed.ts
import { env, pipeline } from "@huggingface/transformers";

// Keep the weight cache in the project folder, not inside node_modules
// (survives reinstalls; add .models to .gitignore)
env.cacheDir = "./.models";

// Multilingual model: 384 dimensions, handles Russian and English.
// The first run downloads the weights from the Hugging Face Hub (~120 MB for q8).
const MODEL = "Xenova/multilingual-e5-small";
const extractor = await pipeline("feature-extraction", MODEL, { dtype: "q8" });

// E5 was trained with prefixes: "query: " for questions, "passage: " for documents.
export type Kind = "query" | "passage";

export async function embed(texts: string[], kind: Kind): Promise<number[][]> {
  const output = await extractor(
    texts.map((t) => `${kind}: ${t}`),
    { pooling: "mean", normalize: true }, // mean over tokens, vector length 1
  );
  return output.tolist() as number[][];
}

export const DIMENSIONS = 384;
Embedding module: the model loads once, on import

By default the cache lives inside node_modules/@huggingface/transformers/.cache. We move it to ./.models via env.cacheDir, as the Transformers.js docs describe. On Windows this also fixed a real error: the model file path inside node_modules exceeded 260 characters, and ONNX Runtime reported “File doesn't exist” for a file that was there.

In production, embeddings often come from an API. Anthropic has no embedding model of its own: the Claude docs describe Voyage AI (now part of MongoDB) as one provider and advise comparing several for your use case. OpenAI offers text-embedding-3-small and text-embedding-3-large. Below are wrappers for both; we did not send live requests — the code was checked against a local mock server that accepts the same requests.

src/cloud.ts
import OpenAI from "openai";

// Keys come only from the environment (.env on the server), never from code or the frontend
const openai = new OpenAI(); // reads OPENAI_API_KEY

export async function embedOpenAI(texts: string[]): Promise<number[][]> {
  const res = await openai.embeddings.create({
    model: "text-embedding-3-small",
    input: texts,
    dimensions: 384, // shorten the vector to fit a vector(384) column
  });
  return res.data.map((d) => d.embedding);
}

// Voyage AI is the provider covered in Anthropic's embeddings guide.
// The key is created in MongoDB Atlas (AI Model APIs section).
const VOYAGE_URL = process.env.VOYAGE_BASE_URL ?? "https://ai.mongodb.com/v1";

export async function embedVoyage(
  texts: string[],
  inputType: "query" | "document",
): Promise<number[][]> {
  const res = await fetch(`${VOYAGE_URL}/embeddings`, {
    method: "POST",
    headers: {
      "Content-Type": "application/json",
      Authorization: `Bearer ${process.env.VOYAGE_API_KEY}`,
    },
    body: JSON.stringify({ input: texts, model: "voyage-4", input_type: inputType }),
  });
  if (!res.ok) throw new Error(`Voyage ${res.status}: ${await res.text()}`);
  const json = (await res.json()) as { data: { embedding: number[]; index: number }[] };
  return json.data.sort((a, b) => a.index - b.index).map((d) => d.embedding);
}
Cloud embeddings. No live call was made (keys required); request shape and response parsing were checked against a mock server

multilingual-e5-small

Where it runs
Locally, Transformers.js
Dimensions
384
Keep in mind
query:/passage: prefixes, up to 512 tokens

text-embedding-3-small

Where it runs
OpenAI API
Dimensions
1536, shortened with the dimensions parameter
Keep in mind
Vectors are normalized

voyage-4

Where it runs
Voyage AI API (MongoDB Atlas)
Dimensions
1024 by default; 256, 512, 2048
Keep in mind
input_type parameter, vectors are normalized
Models in this tutorial: dimensions per provider docs

pgvector in Docker: table, loading and the first search

The official pgvector/pgvector:pg17 image is plain PostgreSQL 17 with the extension prebuilt — version 0.8.7 in our run. The search corpus is twelve short descriptions of the studio’s lines of work: web and mobile development, CRM, ERP, integrations, audit and so on.

bash
# PostgreSQL 17 + pgvector; the password test is for a throwaway local container only
docker run -d --rm --name kb-pgvector-emb -e POSTGRES_PASSWORD=test -p 55440:5432 pgvector/pgvector:pg17

npm init -y && npm pkg set type=module
npm install @huggingface/transformers@4.3.1 pg@8.23.1 pgvector@0.3.0
npm install -D typescript @types/node@24 @types/pg

# schema, corpus load and the first search
docker exec -i kb-pgvector-emb psql -U postgres -v ON_ERROR_STOP=1 < sql/01-schema.sql
export DATABASE_URL=postgres://postgres:test@localhost:55440/postgres
node src/seed.ts
node src/search.ts "нужно приложение для телефона"
Starting the stand. In PowerShell, replace < with Get-Content sql/01-schema.sql | docker exec -i … and set the variable as $env:DATABASE_URL="…". Node.js 24 runs .ts files directly
data/services.json
[
  {"slug":"web-development","title":"Разработка сайтов","body":"Корпоративные и продуктовые сайты, каталоги и интернет-магазины: структура, UX/UI-дизайн, frontend, SEO-ready архитектура, подключение аналитики и запуск на домене заказчика."},
  {"slug":"web-app-development","title":"Веб-приложения","body":"Личные кабинеты, внутренние порталы и панели управления: роли и права доступа, бизнес-логика, база данных, API и интеграции с внешними сервисами."},
  {"slug":"mobile-development","title":"Мобильные приложения","body":"Приложения для iOS и Android: сценарии, дизайн под гайдлайны платформ, backend с API, авторизация, push-уведомления и публикация в App Store и Google Play."},
  …
]
Start of the corpus: slug, title and text
sql/01-schema.sql
CREATE EXTENSION IF NOT EXISTS vector;

CREATE TABLE services (
  id        bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
  slug      text UNIQUE NOT NULL,
  title     text NOT NULL,
  body      text NOT NULL,
  -- the model fixes the size: multilingual-e5-small returns 384 numbers
  embedding vector(384) NOT NULL,
  -- for hybrid search: lexemes of the Russian text, kept up to date automatically
  tsv       tsvector GENERATED ALWAYS AS (
              to_tsvector('russian', title || ' ' || body)
            ) STORED
);

CREATE INDEX services_tsv_idx ON services USING gin (tsv);
Schema: a fixed-size vector and a generated column for full-text search

The vector(384) type rejects a vector of any other length — a guard against mixing models. The tsv column is for hybrid search; it is GENERATED … STORED, so PostgreSQL recomputes it whenever the text changes. The database connection lives in its own module: pgvector.registerTypes teaches the pg driver to send and read the vector type.

src/db.ts
import pg from "pg";
import pgvector from "pgvector/pg";

// Connection string comes from the environment; no password in code
export const pool = new pg.Pool({
  connectionString: process.env.DATABASE_URL,
  // register the vector type for every new pool connection
  onConnect: async (client) => {
    await pgvector.registerTypes(client);
  },
});
Connection pool with the vector type registered
src/seed.ts
import { readFile } from "node:fs/promises";
import pgvector from "pgvector/pg";
import { pool } from "./db.ts";
import { embed } from "./embed.ts";

type Doc = { slug: string; title: string; body: string };
const docs: Doc[] = JSON.parse(await readFile("data/services.json", "utf8"));

// Embed the documents with the passage prefix, in one batch
const vectors = await embed(docs.map((d) => `${d.title}. ${d.body}`), "passage");

for (const [i, d] of docs.entries()) {
  await pool.query(
    `INSERT INTO services (slug, title, body, embedding) VALUES ($1, $2, $3, $4)
     ON CONFLICT (slug) DO UPDATE
       SET title = EXCLUDED.title, body = EXCLUDED.body, embedding = EXCLUDED.embedding`,
    [d.slug, d.title, d.body, pgvector.toSql(vectors[i])],
  );
}
console.log(`documents loaded: ${docs.length}`);
await pool.end();
Loading: document embeddings in one batch, upsert by slug
src/search.ts
import pgvector from "pgvector/pg";
import { pool } from "./db.ts";
import { embed } from "./embed.ts";

const question = process.argv[2] ?? "need a mobile app for our customers";
const [q] = await embed([question], "query");

// <=> is cosine distance; similarity = 1 - distance
const { rows } = await pool.query(
  `SELECT slug, title, round((1 - (embedding <=> $1))::numeric, 3) AS similarity
     FROM services
    ORDER BY embedding <=> $1
    LIMIT 3`,
  [pgvector.toSql(q)],
);
console.log(`«${question}»`);
console.table(rows);
await pool.end();
Finding the three documents closest to a question
terminal
$ node src/seed.ts
documents loaded: 12
$ node src/search.ts "нужно приложение для телефона"
┌─────────┬──────────────────────┬────────────────────────┬────────────┐
│ (index) │ slug                 │ title                  │ similarity │
├─────────┼──────────────────────┼────────────────────────┼────────────┤
│ 0       │ 'mobile-development' │ 'Мобильные приложения' │ '0.888'    │
│ 1       │ 'support'            │ 'Сопровождение'        │ '0.834'    │
│ 2       │ 'optimization'       │ 'Оптимизация продукта' │ '0.833'    │
└─────────┴──────────────────────┴────────────────────────┴────────────┘
$ node src/search.ts "хотим, чтобы заказы из Kaspi сами попадали в учёт"
│ 0       │ 'integrations'     │ 'Интеграции и API' │ '0.829'    │
│ 1       │ 'support'          │ 'Сопровождение'    │ '0.812'    │
│ 2       │ 'saas-development' │ 'SaaS-продукты'    │ '0.801'    │
$ node src/search.ts "who can review our code and security?"
│ 0       │ 'support'             │ 'Сопровождение'        │ '0.779'    │
│ 1       │ 'optimization'        │ 'Оптимизация продукта' │ '0.773'    │
│ 2       │ 'web-app-development' │ 'Веб-приложения'       │ '0.770'    │
$ node src/search.ts "technical audit of an existing app"
│ 0       │ 'audit'              │ 'Технический аудит'    │ '0.821'    │
│ 1       │ 'support'            │ 'Сопровождение'        │ '0.794'    │
│ 2       │ 'mobile-development' │ 'Мобильные приложения' │ '0.790'    │
Real output on our corpus (for the last three queries only the result rows are shown). Results are deterministic: the same model and corpus give the same numbers

The first two queries contain neither “mobile” nor “integrations”, yet the right document is first. The English question “who can review our code and security?” matched the Russian texts worse: the audit did not make the top three. Rephrased as “technical audit of an existing app”, it is found. A small multilingual model does understand other languages, but less well than the corpus language — a reason to test search on real user questions rather than convenient examples.

HNSW and IVFFlat indexes

Without an index pgvector runs an exact search: it computes the distance to every row and sorts. Recall is perfect, but time grows linearly with the table. Approximate indexes trade some recall for speed — the pgvector docs warn that query results may change after you add one.

An index won’t speed up twelve rows — the planner picks a sequential scan. So for measurements we build a bench of 20,000 vectors of dimension 384, generated as clusters around random centers, and compute recall@10: what share of the ten exact neighbors a query returns through the index.

sql/03-index.sql
-- Test bench: 20,000 vectors with the model's dimensionality.
-- Real embeddings form semantic clusters, so we generate vectors
-- around 200 random centers instead of uniformly.
SELECT setseed(0.42);
CREATE FUNCTION rnd_vec(scale float8) RETURNS vector LANGUAGE sql VOLATILE AS
  $$ SELECT array_agg((random() - 0.5) * scale)::vector FROM generate_series(1, 384) $$;

CREATE TABLE centers AS SELECT k, rnd_vec(1) AS c FROM generate_series(0, 199) AS k;
CREATE TABLE bench (id int PRIMARY KEY, embedding vector(384));
INSERT INTO bench
SELECT i, c + rnd_vec(1.4) FROM generate_series(1, 20000) AS i JOIN centers ON k = i % 200;

-- 50 queries and the ground truth: exact search while there is no index
CREATE TABLE queries AS
SELECT q, (c + rnd_vec(1.4))::vector(384) AS v FROM generate_series(1, 50) AS q JOIN centers ON k = q * 3;
CREATE TABLE truth AS
SELECT q.q, b.id
  FROM queries q
  CROSS JOIN LATERAL (SELECT id FROM bench ORDER BY embedding <=> q.v LIMIT 10) b;
ANALYZE bench;

EXPLAIN (COSTS OFF)
SELECT id FROM bench ORDER BY embedding <=> (SELECT v FROM queries WHERE q = 1) LIMIT 10;

\timing on
CREATE INDEX bench_hnsw ON bench USING hnsw (embedding vector_cosine_ops)
  WITH (m = 16, ef_construction = 64);
\timing off

EXPLAIN (COSTS OFF)
SELECT id FROM bench ORDER BY embedding <=> (SELECT v FROM queries WHERE q = 1) LIMIT 10;

-- recall@10: share of the exact neighbors the index found
CREATE FUNCTION recall_at_10() RETURNS numeric LANGUAGE sql AS $$
  SELECT round(count(*)::numeric / (SELECT count(*) FROM truth), 3)
    FROM queries q
    CROSS JOIN LATERAL (SELECT id FROM bench ORDER BY embedding <=> q.v LIMIT 10) a
    JOIN truth t ON t.q = q.q AND t.id = a.id
$$;

SET hnsw.ef_search = 10;  SELECT 'ef_search=10'  AS setting, recall_at_10();
SET hnsw.ef_search = 40;  SELECT 'ef_search=40'  AS setting, recall_at_10();
SET hnsw.ef_search = 200; SELECT 'ef_search=200' AS setting, recall_at_10();
SELECT pg_size_pretty(pg_relation_size('bench')) AS table_size,
       pg_size_pretty(pg_relation_size('bench_hnsw')) AS hnsw_size;
Bench, plan before and after an HNSW index, and recall@10 for different ef_search
sql/04-ivfflat.sql
DROP INDEX bench_hnsw;
-- lists ≈ rows / 1000 for tables up to 1M rows (pgvector guidance)
\timing on
CREATE INDEX bench_ivf ON bench USING ivfflat (embedding vector_cosine_ops) WITH (lists = 20);
\timing off

SET ivfflat.probes = 1; SELECT 'probes=1' AS setting, recall_at_10();
SET ivfflat.probes = 4; SELECT 'probes=4' AS setting, recall_at_10();
SELECT pg_size_pretty(pg_relation_size('bench_ivf')) AS ivfflat_size;
The same measurement for IVFFlat
terminal
$ docker exec -i kb-pgvector-emb psql -U postgres < sql/03-index.sql
 Limit
   ->  Sort
         Sort Key: ((bench.embedding <=> (InitPlan 1).col1))
         ->  Seq Scan on bench
CREATE INDEX
Time: 1698.993 ms (00:01.699)
 Limit
   ->  Index Scan using bench_hnsw on bench
         Order By: (embedding <=> (InitPlan 1).col1)
 ef_search=10  |        0.910
 ef_search=40  |        1.000
 ef_search=200 |        1.000
 table_size | hnsw_size
 31 MB      | 39 MB

$ docker exec -i kb-pgvector-emb psql -U postgres < sql/04-ivfflat.sql
CREATE INDEX
Time: 234.909 ms
 probes=1 |        0.916
 probes=4 |        0.962
 ivfflat_size
 31 MB
Output trimmed. Synthetic data, one run on our machine: timing and recall vary between runs because the HNSW graph and IVFFlat clusters are built with randomness

The plan confirms the key point: with the index, Seq Scan and Sort give way to an Index Scan with Order By on the distance. The index is used only when the query has ORDER BY on a distance operator and a LIMIT; a condition like WHERE embedding <=> $1 < 0.3 without sorting won’t use it.

m

Where it is set
HNSW, at creation
Default
16
What it changes
Connections per graph layer: higher means better recall and more memory

ef_construction

Where it is set
HNSW, at creation
Default
64
What it changes
Search width during build: higher means a better graph and a slower build

hnsw.ef_search

Where it is set
HNSW, SET before the query
Default
40
What it changes
Candidates examined per search: higher means better recall, slower queries

lists

Where it is set
IVFFlat, at creation
Default
—
What it changes
Number of clusters; start with rows / 1000 up to 1M rows, √rows above

ivfflat.probes

Where it is set
IVFFlat, SET before the query
Default
1
What it changes
Clusters checked per query; start with √lists
Index parameters per the pgvector 0.8 docs

HNSW

  • Multilayer graph, better speed/recall trade-off
  • Can be created on an empty table
  • Slower to build and uses more memory (39 MB for a 31 MB table in our run)

IVFFlat

  • k-means clusters, faster to build and smaller
  • Create it after loading data — the clusters are trained on it
  • Built on a nearly empty table, it returns fewer rows — the docs advise dropping it until there is more data
  • Create the index after the initial load — it is faster; on a live database use CREATE INDEX CONCURRENTLY so writes are not blocked
  • An HNSW build is much faster when the graph fits into maintenance_work_mem
  • Sort by the distance operator itself, ascending: ORDER BY embedding <=> $1 uses the index, ORDER BY 1 - (embedding <=> $1) DESC does not
  • Recall in production is checked the same way as here: compare the index result with exact search under SET LOCAL enable_indexscan = off

Hybrid search: meaning plus keywords

Vector search is good at paraphrases but can underrate an exact match: a product name, an SKU, “1C”, “Excel”. PostgreSQL full-text search is the opposite: precise on words, blind to synonyms. pgvector suggests combining them and merging the results with Reciprocal Rank Fusion (RRF): each document gets the sum of 1 / (k + rank) across both lists, with k = 60 as in the pgvector-node example.

src/hybrid.ts
import pgvector from "pgvector/pg";
import { pool } from "./db.ts";
import { embed } from "./embed.ts";

const question = process.argv[2] ?? "Kaspi";
const [q] = await embed([question], "query");

// Reciprocal Rank Fusion: add up 1 / (k + rank) from both lists.
// k = 60 smooths out the gap between the top positions.
const { rows } = await pool.query(
  `WITH semantic AS (
     SELECT id, rank() OVER (ORDER BY embedding <=> $2) AS r
       FROM services
      ORDER BY embedding <=> $2
      LIMIT 20
   ),
   keyword AS (
     SELECT id, rank() OVER (ORDER BY ts_rank_cd(tsv, query) DESC) AS r
       FROM services, websearch_to_tsquery('russian', $1) AS query
      WHERE tsv @@ query
      ORDER BY ts_rank_cd(tsv, query) DESC
      LIMIT 20
   )
   SELECT s.slug, semantic.r AS semantic_rank, keyword.r AS keyword_rank,
          round((coalesce(1.0 / ($3 + semantic.r), 0)
               + coalesce(1.0 / ($3 + keyword.r), 0))::numeric, 4) AS score
     FROM semantic
     FULL JOIN keyword USING (id)
     JOIN services s USING (id)
    ORDER BY score DESC
    LIMIT 5`,
  [question, pgvector.toSql(q), 60],
);
console.log(`«${question}»`);
console.table(rows);
await pool.end();
Hybrid search: two ranked lists merged with RRF in a single SQL query
terminal
$ node src/hybrid.ts "импорт из Excel"
┌─────────┬───────────────────────┬───────────────┬──────────────┬──────────┐
│ (index) │ slug                  │ semantic_rank │ keyword_rank │ score    │
├─────────┼───────────────────────┼───────────────┼──────────────┼──────────┤
│ 0       │ 'crm-development'     │ '4'           │ '1'          │ '0.0320' │
│ 1       │ 'integrations'        │ '1'           │ null         │ '0.0164' │
│ 2       │ 'optimization'        │ '2'           │ null         │ '0.0161' │
│ 3       │ 'saas-development'    │ '3'           │ null         │ '0.0159' │
│ 4       │ 'business-automation' │ '5'           │ null         │ '0.0154' │
└─────────┴───────────────────────┴───────────────┴──────────────┴──────────┘
Real output: semantics ranked CRM fourth, full-text search ranked it first, and after fusion CRM is on top (the query means “import from Excel”)

The CRM description does mention importing customers and deals from Excel, but the model considered general integration texts closer. The keyword pulled up the right document. websearch_to_tsquery is chosen on purpose: per the PostgreSQL docs it accepts raw user input and never raises a syntax error. The 'russian' configuration reduces words to their stems, so a noun form matches a verb form.

Chunking, common mistakes and when you need a separate vector database

Our corpus is short paragraphs, so each document is one vector. Real documents exceed the model limit (512 tokens for E5; the rest is dropped), and one vector per long page blurs its meaning. Documents are split into chunks by sections or paragraphs, with metadata stored alongside: source, section title, date, language, access rights. Chunking is covered in detail in the RAG tutorial.

  • Documents and queries are encoded by one model, and the model name is stored next to the vector
  • E5 inputs have query:/passage: prefixes; Voyage calls set input_type
  • The query metric matches the index operator class (<=> ↔ vector_cosine_ops)
  • The query has ORDER BY on the distance and a LIMIT — otherwise the index is not used
  • Metadata filters are checked for recall; iterative scan is enabled where needed
  • Cloud provider keys live in environment variables on the server
  • Index recall is measured on your own data, not taken on faith

When PostgreSQL is enough. In our experience, if vectors complement regular data (products, articles, leads), pgvector is more convenient than a separate service: the same transactions, JOINs, permissions, backups and replication. To save memory pgvector offers the halfvec type and binary quantization with re-ranking.

When to look at a dedicated vector database is an engineering trade-off, not a rule: when vector search becomes the main workload, the index no longer fits into one server’s memory, search itself needs horizontal scaling, or you want built-in features such as ready-made hybrid search with reranking. Storage trade-offs are compared in the article on when you need NoSQL.

Exercises

Next step: the RAG tutorial. On the same stand we chunk an FAQ, retrieve fragments for a question, assemble them into a prompt and measure retrieval quality on a set of test questions.

How this was verified6 · passed1 · partialchecked 12 October 2026

“Run” means the code was executed in a scratch project or container and the output in the article comes from that run. “Partial” means it was checked against stubs instead of external services. “Not run” means it needs a device, an account or a key; the steps were checked against the official documentation. The verification project files and run logs are kept in the InterLAB archive.

Tell us about your task — we'll review your request and get back to you

Got a project?
We'll scope and estimate it — for free.

Tell us about the product or the problem. After reviewing the task we'll come back with an architecture, a timeline and a price for the first version.

  • Scope and risks estimated before we start
  • NDA — yours or ours
  • Work starts within a week

Please do not send passwords, access keys or source code through this form — we will agree a safe way to share access separately.

By clicking the button you agree to the processing of your personal data.