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.
After this you will be able to
- generate text embeddings with a local model via
@huggingface/transformersand 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_searchandivfflat.probesand measure recall@10 - combine vector and full-text search with Reciprocal Rank Fusion
Know beforehand
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
- An embedding is a text as a point in meaning space
- Distances: cosine, dot product, L2
- Where embeddings come from: a local model or an API
- pgvector in Docker: table, loading and the first search
- HNSW and IVFFlat indexes
- Hybrid search: meaning plus keywords
- Chunking, common mistakes and when you need a separate vector database
- Exercises
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:
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)}`);
}
}$ 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.801Distances: 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
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
<=>andvector_cosine_opsif 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_opsindex 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.
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;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.
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);
}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
dimensionsparameter - 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_typeparameter, vectors are normalized
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.
# 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 "нужно приложение для телефона"< 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[
{"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."},
…
]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);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.
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);
},
});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();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();$ 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' │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.
-- 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;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;$ 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 MBThe 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,
SETbefore 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,
SETbefore the query - Default
- 1
- What it changes
- Clusters checked per query; start with √lists
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 CONCURRENTLYso 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 <=> $1uses the index,ORDER BY 1 - (embedding <=> $1) DESCdoes 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.
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();$ 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' │
└─────────┴───────────────────────┴───────────────┴──────────────┴──────────┘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 setinput_type - The query metric matches the index operator class (
<=>↔vector_cosine_ops) - The query has
ORDER BYon the distance and aLIMIT— 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.