Skip to content

Semantic Search

TypeGraph supports semantic search using vector embeddings, enabling you to find semantically similar content using embedding models like OpenAI, Sentence Transformers, CLIP, or any model that produces fixed-dimension vectors.

Traditional search relies on exact keyword matching. Semantic search understands meaning—“machine learning” matches documents about “neural networks” and “AI algorithms” even without those exact words.

Key capabilities:

  • Store embeddings as node properties alongside your graph data
  • Find the k most similar nodes using cosine, L2, or inner product distance
  • Combine semantic similarity with graph traversals and standard predicates
  • Automatic vector indexing for fast approximate nearest neighbor search

Build context-aware AI applications by retrieving relevant documents before generating responses:

async function ragQuery(question: string): Promise<string> {
const questionEmbedding = await embed(question);
const context = await store
.query()
.from("Document", "d")
.whereNode("d", (d) =>
d.embedding.similarTo(questionEmbedding, 5, {
metric: "cosine",
minScore: 0.7,
})
)
.select((ctx) => ({
title: ctx.d.title,
content: ctx.d.content,
}))
.execute();
return await llm.chat({
messages: [
{
role: "system",
content: `Answer based on this context:\n${context.map((d) => d.content).join("\n\n")}`,
},
{ role: "user", content: question },
],
});
}

Find documents by meaning rather than keywords:

const results = await store
.query()
.from("Article", "a")
.whereNode("a", (a) =>
a.embedding
.similarTo(queryEmbedding, 20)
.and(a.category.eq("technology"))
)
.select((ctx) => ctx.a)
.execute();

Use CLIP or similar vision models for image search:

const similarImages = await store
.query()
.from("Image", "i")
.whereNode("i", (i) => i.clipEmbedding.similarTo(queryImageEmbedding, 10))
.select((ctx) => ({
url: ctx.i.url,
caption: ctx.i.caption,
}))
.execute();

Recommend products based on embedding similarity:

const recommendations = await store
.query()
.from("Product", "p")
.whereNode("p", (p) =>
p.embedding
.similarTo(referenceProductEmbedding, 10)
.and(p.inStock.eq(true))
)
.select((ctx) => ctx.p)
.execute();

Vector search requires database-specific extensions for storing and querying high-dimensional vectors efficiently.

pgvector is the recommended extension for PostgreSQL. It provides:

  • Native vector column type
  • HNSW and IVFFlat indexes for fast approximate nearest neighbor search
  • Support for cosine, L2, and inner product distance

Installation:

-- Install the extension (requires superuser or database owner)
CREATE EXTENSION vector;

Docker setup:

services:
postgres:
image: pgvector/pgvector:pg16
environment:
POSTGRES_PASSWORD: password
POSTGRES_DB: myapp
ports:
- "5432:5432"

TypeGraph migration enables vector support:

import { generatePostgresMigrationSQL } from "@nicia-ai/typegraph/adapters/drizzle/postgres";
// Generates DDL including `CREATE EXTENSION IF NOT EXISTS vector;`.
// It does NOT create a single embeddings table — each embedding field gets
// its own typed `vector(N)` table, provisioned by `createStoreWithSchema`
// (the privileged migrator) at boot (see Storage Layout below).
const migrationSQL = generatePostgresMigrationSQL();

sqlite-vec provides vector search for SQLite. It offers:

  • vec_f32 type for 32-bit float vectors
  • Cosine and L2 distance functions

Installation:

Terminal window
npm install sqlite-vec

Loading the extension:

import Database from "better-sqlite3";
import * as sqliteVec from "sqlite-vec";
const sqlite = new Database("myapp.db");
sqliteVec.load(sqlite);

Limitations:

  • sqlite-vec does not support inner product distance
  • Use cosine or l2 metrics only

The libSQL / Turso backend (createLibsqlBackend) does not use sqlite-vec. libSQL has a built-in vector engine — no extension to load — so vector and hybrid search work out of the box on local files, embedded replicas, and remote Turso databases:

import { createClient } from "@libsql/client";
import { createLibsqlBackend } from "@nicia-ai/typegraph/adapters/drizzle/sqlite/libsql";
const client = createClient({ url: "libsql://my-db.turso.io", authToken: "..." });
const { backend } = await createLibsqlBackend(client);
// backend.capabilities.vector?.supported === true

Under the hood it stores embeddings as F32_BLOB and searches with vector_distance_cos / vector_distance_l2, with optional approximate nearest-neighbor (DiskANN) indexes via libsql_vector_idx + vector_top_k. Supported metrics are cosine and l2 (no inner_product), matching the sqlite-vec feature set.

One caveat specific to DiskANN: vector_top_k is a table function with no filter pushdown, so the liveness filter every search applies (only non-deleted nodes may rank — see below) runs after ANN retrieval. TypeGraph over-fetches 4× limit neighbors to leave headroom; if more than 3×limit of those neighbors are filtered out, fewer than limit results return. pgvector and sqlite-vec apply the filter inside the index scan and do not share this bound.

Metric PostgreSQL SQLite (sqlite-vec) libSQL / Turso Description
cosine <=> vec_distance_cosine vector_distance_cos Cosine distance (1 - similarity). Best for normalized embeddings.
l2 <-> vec_distance_l2 vector_distance_l2 Euclidean distance. Good for unnormalized vectors.
inner_product <#> Not supported Not supported Negative inner product. For maximum inner product search (MIPS).

Each embedding field is stored in its own typed, graph-scoped table named tg_vec_<graphId>_<kind>_<field>, carrying that field’s fixed dimension (pgvector vector(N), libSQL F32_BLOB(N), sqlite-vec vec0). The privileged migrator (createStoreWithSchema, and evolve() for runtime-added fields) provisions each table plus a durable contribution marker at boot; the runtime hot path then asserts the marker (a cached SELECT) and never issues DDL, so a least-privilege, DML-only role can read and write embeddings. An embedding write against an un-provisioned slot throws StoreNotInitializedError rather than lazily creating the table; vector reads (store.search.vector, store.search.hybrid, and query-builder .similarTo() predicates) compile straight to SQL, so they surface the engine’s missing-relation error instead — use createVerifiedStore to catch both at attach. See Database roles & least privilege. Graph-scoping means several graphs in one database can declare the same kind+field at different dimensions without collision. This is transparent to queries — .similarTo(), store.search.vector, and store.search.hybrid read it for you.

Every facade search (store.search.vector / fulltext / hybrid) computes its top-k over live nodes only: the search SQL constrains candidates to non-deleted node ids, so a stale embedding or fulltext row — one whose node was tombstoned by a writer that bypassed the store’s cleanup — can neither surface in results nor crowd live rows out of the top-k. You always get limit results when at least limit live matches exist (on libSQL DiskANN, subject to the over-fetch bound above).

Switching embedding models usually changes the vector dimension. Stored vectors can’t be reinterpreted at a new dimension, so a stray write at the old dimension throws EmbeddingDimensionChangedError. Update the field’s embedding(N) declaration, then recompute the stored vectors with store.reembedVectorField(), which recreates the field’s storage at the new dimension:

// embedding(1536) → embedding(3072): recreate storage and re-embed in batches.
// `embed` receives a page of nodes and returns a Map from node id to vector.
await store.reembedVectorField("Document", "embedding", {
embed: async (nodes) => {
const vectors = await batchEmbed(nodes.map((node) => node.content));
return new Map(nodes.map((node, index) => [node.id, vectors[index]]));
},
});
// → { recreated: true, reembedded: <count> }

Between the declaration change and the reembedVectorField() call, the slot is in a deliberate limbo: boot (createStoreWithSchema / evolve()) detects that the provisioned storage no longer matches the declared shape, warns, and leaves it untouched — it never recreates the table implicitly, because that would silently drop every stored vector. Embedding writes to the field fail with a StoreNotInitializedError whose reason is stale (its message points here) until reembedVectorField() recreates the storage and re-stamps its durable marker.

Without an embed callback the storage is recreated empty and you re-embed via normal update() writes.

Removing an embedding field from a kind that still exists orphans its tg_vec_* table. store.materializeRemovals() reclaims it — it drops per-field tables for embedding fields no longer in the active schema and reports them in reclaimedVectorFields:

const { reclaimedVectorFields } = await store.materializeRemovals();
// → [{ kind: "Document", fieldPath: "embedding", status: "reclaimed" }]

The active schema is the source of truth, so a removed-then-re-added field is never dropped. The pass is idempotent.

Earlier versions stored every embedding in a single shared typegraph_node_embeddings table. If you have existing data there, run the one-time, idempotent migrateLegacyEmbeddings() utility to copy it into the new per-field tables (new deployments need no action):

import { migrateLegacyEmbeddings } from "@nicia-ai/typegraph";
const result = await migrateLegacyEmbeddings({ backend });
// → { migrated, perField, skippedDimensionMismatch, legacyTablePresent }

Use the embedding() function to define vector properties with a specific dimension:

import { defineNode, embedding } from "@nicia-ai/typegraph";
import { z } from "zod";
const Document = defineNode("Document", {
schema: z.object({
title: z.string(),
content: z.string(),
embedding: embedding(1536), // OpenAI ada-002 dimension
}),
});
const Image = defineNode("Image", {
schema: z.object({
url: z.string(),
caption: z.string().optional(),
clipEmbedding: embedding(512), // CLIP ViT-B/32 dimension
}),
});
Model Dimensions Use Case
all-MiniLM-L6-v2 384 Fast, lightweight text embeddings
CLIP ViT-B/32 512 Image-text multimodal
BERT base 768 General text embeddings
OpenAI ada-002 1536 High-quality text embeddings
OpenAI text-embedding-3-small 1536 Efficient, high-quality
OpenAI text-embedding-3-large 3072 Maximum quality
Cohere embed-v3 1024 Multilingual support

Embedding properties can be optional for gradual population:

const Article = defineNode("Article", {
schema: z.object({
title: z.string(),
content: z.string(),
embedding: embedding(1536).optional(),
}),
});
// Create without embedding
const article = await store.nodes.Article.create({
title: "Draft Article",
content: "...",
});
// Add embedding later via background job
await store.nodes.Article.update(article.id, {
embedding: await generateEmbedding(article.content),
});

Nodes can have multiple embedding fields for different purposes:

const Product = defineNode("Product", {
schema: z.object({
name: z.string(),
description: z.string(),
imageUrl: z.string(),
// Text embedding for description search
textEmbedding: embedding(1536).optional(),
// Image embedding for visual similarity
imageEmbedding: embedding(512).optional(),
}),
});

Embeddings are stored when creating or updating nodes:

// Using OpenAI
import OpenAI from "openai";
const openai = new OpenAI();
async function generateEmbedding(text: string): Promise<number[]> {
const response = await openai.embeddings.create({
model: "text-embedding-ada-002",
input: text,
});
return response.data[0].embedding;
}
// Store with embedding
const embedding = await generateEmbedding("Machine learning fundamentals");
await store.nodes.Document.create({
title: "ML Guide",
content: "Machine learning fundamentals...",
embedding: embedding,
});

For bulk operations, batch your embedding API calls:

async function batchEmbed(texts: string[]): Promise<number[][]> {
const response = await openai.embeddings.create({
model: "text-embedding-ada-002",
input: texts,
});
return response.data.map((d) => d.embedding);
}
// Process in batches
const documents = await fetchDocumentsWithoutEmbeddings();
const batchSize = 100;
for (let i = 0; i < documents.length; i += batchSize) {
const batch = documents.slice(i, i + batchSize);
const embeddings = await batchEmbed(batch.map((d) => d.content));
await store.transaction(async (tx) => {
for (const [index, doc] of batch.entries()) {
await tx.nodes.Document.update(doc.id, {
embedding: embeddings[index],
});
}
});
}

Use .similarTo() to find the k most similar nodes:

const queryEmbedding = await generateEmbedding("neural networks");
const similar = await store
.query()
.from("Document", "d")
.whereNode("d", (d) =>
d.embedding.similarTo(queryEmbedding, 10) // Top 10 most similar
)
.select((ctx) => ({
title: ctx.d.title,
content: ctx.d.content,
}))
.execute();

Approximate retrieval for .similarTo() (opt-in)

Section titled “Approximate retrieval for .similarTo() (opt-in)”

By default .similarTo() ranks with an exact distance scan — correct at any scale, and index-served by the PostgreSQL planner where the plan shape allows. When a kind declares an ANN index (embedding(n) defaults to hnsw), you can opt the predicate into the engine’s native approximate retrieval:

const similar = await store
.query()
.from("Document", "d")
.whereNode("d", (d) =>
d.embedding
.similarTo(queryEmbedding, 10, { approximate: true })
.and(d.status.eq("published")),
)
.select((ctx) => ctx.d)
.execute();

This is a semantic change, never applied silently: results are subject to the index’s recall. Composed predicates constrain the ANN candidate set — exactly on pgvector and sqlite-vec, bounded by over-fetch on libSQL DiskANN. A kind declared with indexType: "none" keeps the exact scan even with the opt-in.

Scoped facade search: filters, pagination, subclasses

Section titled “Scoped facade search: filters, pagination, subclasses”

store.search.vector (and fulltext / hybrid) accept a where predicate, an offset, and includeSubClasses — all compiled into the search statement itself, so the engine ranks only eligible rows. A filter never costs you results: you get limit hits whenever limit matching nodes exist (on libSQL DiskANN, subject to the over-fetch bound above).

// Top 10 most similar *published* documents, second page.
const hits = await store.search.vector("Document", {
fieldPath: "embedding",
queryEmbedding,
limit: 10,
offset: 10,
where: (d) => d.status.eq("published"),
});
// Search a kind and all of its subClassOf descendants; per-kind results
// merge into one globally ordered ranking. Kinds that don't declare the
// embedding field are skipped.
const acrossKinds = await store.search.vector("Content", {
fieldPath: "embedding",
queryEmbedding,
limit: 10,
includeSubClasses: true,
});

The where predicate is compiled by the same query compiler as store.query() — property predicates behave identically, use the same declared indexes, and apply the same current-read semantics (tombstoned nodes and nodes outside their validity window never rank). Kinds expanded via includeSubClasses must share one declared metric: scores from different metrics cannot merge into one ranking (and a per-call metric cannot bridge the gap — each kind’s storage is validated against its declared metric), so mixed-metric expansions throw; search those kinds separately.

// Cosine similarity (default) - best for normalized embeddings
d.embedding.similarTo(queryEmbedding, 10, { metric: "cosine" })
// L2 (Euclidean) distance - for unnormalized embeddings
d.embedding.similarTo(queryEmbedding, 10, { metric: "l2" })
// Inner product - for maximum inner product search (PostgreSQL only)
d.embedding.similarTo(queryEmbedding, 10, { metric: "inner_product" })

When to use each:

  • Cosine: Most common choice. Works well with normalized embeddings (OpenAI, Sentence Transformers). Focuses on direction, not magnitude.
  • L2: Use when vector magnitude matters. Good for detecting exact duplicates.
  • Inner product: For MIPS (maximum inner product search). Useful when embeddings encode both relevance and importance in magnitude.

Filter results below a similarity threshold:

const highQualityMatches = await store
.query()
.from("Document", "d")
.whereNode("d", (d) =>
d.embedding.similarTo(queryEmbedding, 100, {
metric: "cosine",
minScore: 0.8, // Only results with similarity >= 0.8
})
)
.select((ctx) => ctx.d)
.execute();

The minScore parameter filters results using similarity (not distance):

  • Cosine: 1.0 = identical, 0.0 = orthogonal. Typical thresholds: 0.7-0.9
  • L2: Maximum distance to include (lower = more similar)
  • Inner product: Minimum inner product value

Semantic search integrates with all standard query predicates:

const filteredSearch = await store
.query()
.from("Document", "d")
.whereNode("d", (d) =>
d.embedding
.similarTo(queryEmbedding, 20)
.and(d.category.eq("technology"))
.and(d.publishedAt.gte("2024-01-01"))
.and(d.status.eq("published"))
)
.select((ctx) => ctx.d)
.execute();

Search within graph relationships:

// Find similar documents by authors I follow
const personalizedSearch = await store
.query()
.from("Person", "me")
.whereNode("me", (p) => p.id.eq(currentUserId))
.traverse("follows", "f")
.to("Person", "author")
.traverse("authored", "a", { direction: "in" })
.to("Document", "d")
.whereNode("d", (d) =>
d.embedding.similarTo(queryEmbedding, 10)
)
.select((ctx) => ({
title: ctx.d.title,
author: ctx.author.name,
}))
.execute();

Most embedding models produce normalized vectors (unit length). If yours doesn’t, normalize before storing:

function normalize(vector: number[]): number[] {
const magnitude = Math.sqrt(vector.reduce((sum, v) => sum + v * v, 0));
return vector.map((v) => v / magnitude);
}
await store.nodes.Document.create({
title: "Example",
content: "...",
embedding: normalize(rawEmbedding),
});

Always use the same model for both storing and querying:

// Bad: Mixing models
const docEmbedding = await embed("text-embedding-ada-002", content);
const queryEmbedding = await embed("text-embedding-3-small", query); // Different!
// Good: Same model throughout
const MODEL = "text-embedding-ada-002";
const docEmbedding = await embed(MODEL, content);
const queryEmbedding = await embed(MODEL, query);

Not all nodes may have embeddings. Handle gracefully:

// Only search nodes with embeddings
const results = await store
.query()
.from("Document", "d")
.whereNode("d", (d) =>
d.embedding
.isNotNull()
.and(d.embedding.similarTo(queryEmbedding, 10))
)
.select((ctx) => ctx.d)
.execute();

The k parameter (number of results) affects performance:

// For RAG: Small k (3-10) for focused context
d.embedding.similarTo(query, 5)
// For exploration: Larger k with pagination
d.embedding.similarTo(query, 100)

Vector indexes (HNSW, IVFFlat) trade accuracy for speed:

  • Small datasets (< 10K): Exact search is fast enough
  • Medium datasets (10K-1M): HNSW provides good recall with fast queries
  • Large datasets (> 1M): Consider IVFFlat with appropriate parameters

TypeGraph creates HNSW indexes by default for optimal balance.

Filtered vector search needs a node index on the filter field

Section titled “Filtered vector search needs a node index on the filter field”

Combining similarTo with a property predicate is the shape that degrades first at scale — and the vector index is not the reason. The candidates side (d.category.eq(...)) is a JSON property predicate over the nodes table, and rows that carry an embedding field have LARGE props: on PostgreSQL the predicate scan detoasts every row, so at 50k documents (384-dim embeddings) the filter alone costs ~375ms regardless of how the vector side is executed. SQLite pays the same class of cost parsing large JSON props per row.

Declare a node index on the filter field and materialize it — the candidates predicate becomes an index lookup:

import { defineNodeIndex } from "@nicia-ai/typegraph/indexes";
const categoryIndex = defineNodeIndex(Document, { fields: ["category"] });
const graph = defineGraph({
id: "docs",
nodes: { Document: { type: Document } },
edges: {},
indexes: [categoryIndex],
});
await store.materializeIndexes();

Measured at 50k documents on PostgreSQL: the filtered exact search drops from ~375ms to ~19ms and the filtered approximate search to ~20ms — a ~20× difference from one declared index. The bench:vector lane tracks both forms (vector:exact-filtered before the index, vector:exact-filtered-postindex after).

Approximate search under selective filters

Section titled “Approximate search under selective filters”

approximate: true combined with a highly selective property filter is the shape where approximate means it. The index scan walks neighbors best-first and keeps going until enough filtered rows surface (TypeGraph applies pgvector’s hnsw.iterative_scan = strict_order automatically on transaction-capable Postgres drivers with pgvector ≥ 0.8 — the setting is transaction-scoped, so non-transactional backends such as neon-http keep the plain bounded scan), but the scan is still bounded by pgvector’s hnsw.max_scan_tuples (default 20,000). If the nearest rows matching the filter live far from the query — a filter correlated with embedding geometry, like “category X” when category X’s documents form their own distant cluster — the scan can exhaust its budget and return plausible-but-distant rows. For filters independent of the embedding space (the common case), filtered approximate recall stays near 1.0. When the filter is known to be geometry-correlated and selective, drop approximate (the exact path is index-assisted on the candidates side by a node index on the filter field) or raise hnsw.max_scan_tuples.

pgvector’s HNSW index searches a dynamic candidate list whose size is the hnsw.ef_search GUC — default 40. That frontier caps how many neighbors a single scan can surface, so on corpora past a few million vectors recall@k flattens well below 1.0 at the default. TypeGraph exposes it as a per-search efSearch knob on store.search.vector and the vector half of store.search.hybrid:

const hits = await store.search.hybrid("Document", {
limit: 20,
vector: {
fieldPath: "embedding",
queryEmbedding,
k: 80, // over-fetch 80 candidates from the vector side
efSearch: 240, // ~3× k — high-recall frontier for this query
},
fulltext: { query: "renewable energy" },
});

Sizing guidance:

  • Floor — efSearch >= k. Hybrid over-fetches k candidates from the vector side (default 4 * limit). If efSearch is below k the scan can’t fill the candidate set, so the over-fetch silently under-delivers — RRF papers over this on head queries (the fulltext half covers the miss) but drops tail queries only the vector side knows about.
  • Target — ~2–4× k. On million-scale corpora this clears roughly 0.95 recall@10, versus ~0.82–0.85 at the default 40. Verify the curve against your own corpus rather than hard-coding a multiplier.
  • Ceiling — 1000. pgvector caps hnsw.ef_search at 1000; TypeGraph rejects a larger efSearch with a clear error.

Because it’s per-search, one connection pool can serve both a latency-sensitive interactive path (omit efSearch, inherit the session default) and a recall-sensitive batch/ETL path (raise it) — a session GUC can’t, a per-call override can.

Mechanics and limits. The override is applied transaction-locally (SET LOCAL hnsw.ef_search) around the vector SELECT, so it never leaks to the next query on a pooled connection. Omitting it preserves today’s behavior exactly — no transaction is opened. It applies to the Postgres HNSW path only:

  • sqlite-vec has no equivalent frontier knob and ignores efSearch (no-op).
  • Transaction-less Postgres drivers (drizzle-orm/neon-http) can’t scope SET LOCAL, so efSearch is ignored with a one-time warning — use a transactional driver (node-postgres / neon-serverless / postgres-js) to apply it.
  • It tunes HNSW only; IVFFlat’s analogous knob (ivfflat.probes) is not yet exposed.

PostgreSQL:

-- Check if pgvector is installed
SELECT * FROM pg_extension WHERE extname = 'vector';
-- Install it
CREATE EXTENSION vector;

SQLite:

// Ensure sqlite-vec is loaded before queries
import * as sqliteVec from "sqlite-vec";
sqliteVec.load(sqlite);

“Inner product not supported” (SQLite)

Section titled ““Inner product not supported” (SQLite)”

sqlite-vec only supports cosine and l2 metrics. Use one of those instead:

// Instead of:
d.embedding.similarTo(query, 10, { metric: "inner_product" })
// Use:
d.embedding.similarTo(query, 10, { metric: "cosine" })

Ensure query embedding has the same dimension as stored embeddings:

const Document = defineNode("Document", {
schema: z.object({
embedding: embedding(1536), // 1536 dimensions
}),
});
// Query embedding must also be 1536 dimensions
const queryEmbedding = await embed(text); // Verify this returns 1536-dim vector
  1. Check index creation: Vector indexes may not exist
  2. Reduce k: Smaller k = faster queries
  3. Add filters: Pre-filter with standard predicates before similarity search
  4. Consider approximate search: HNSW indexes sacrifice some accuracy for speed

Vector search excels at semantic similarity but misses exact matches — proper nouns, SKUs, code identifiers, rare technical terms. Hybrid search fuses vector and fulltext results with Reciprocal Rank Fusion and typically beats either approach alone.

// One query, both signals — fused with RRF at the SQL layer
const results = await store
.query()
.from("Document", "d")
.whereNode("d", (d) =>
d.$fulltext
.matches("renewable energy", 50)
.and(d.embedding.similarTo(queryVec, 50))
)
.select((ctx) => ctx.d)
.limit(10)
.execute();

For tunable per-source weights and RRF parameters, use the store-level store.search.hybrid() API. See the Fulltext Search guide for the complete hybrid workflow.

See the Predicates documentation for complete API reference of the similarTo() predicate and related options.

See Fulltext Search for the n.$fulltext.matches() predicate and searchable() schema brand.