feat(core): Add vector store support to the agents SDK (no-changelog) (#33683)

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Robin Braumann
2026-07-09 09:14:29 +02:00
committed by GitHub
parent 8bb6489918
commit 52b72dd5a8
36 changed files with 10919 additions and 8 deletions
+52 -1
View File
@@ -15,6 +15,18 @@
],
"sandbox": [
"dist/workspace/sandbox/index.d.ts"
],
"vector-stores/postgres": [
"dist/vector-stores/postgres.d.ts"
],
"vector-stores/pinecone": [
"dist/vector-stores/pinecone.d.ts"
],
"vector-stores/qdrant": [
"dist/vector-stores/qdrant.d.ts"
],
"vector-stores/supabase": [
"dist/vector-stores/supabase.d.ts"
]
}
},
@@ -35,6 +47,22 @@
"./sandbox": {
"types": "./dist/workspace/sandbox/index.d.ts",
"default": "./dist/workspace/sandbox/index.js"
},
"./vector-stores/postgres": {
"types": "./dist/vector-stores/postgres.d.ts",
"default": "./dist/vector-stores/postgres.js"
},
"./vector-stores/pinecone": {
"types": "./dist/vector-stores/pinecone.d.ts",
"default": "./dist/vector-stores/pinecone.js"
},
"./vector-stores/qdrant": {
"types": "./dist/vector-stores/qdrant.d.ts",
"default": "./dist/vector-stores/qdrant.js"
},
"./vector-stores/supabase": {
"types": "./dist/vector-stores/supabase.d.ts",
"default": "./dist/vector-stores/supabase.js"
}
},
"files": [
@@ -56,7 +84,8 @@
"test:dev": "vitest --silent=false",
"test:integration": "vitest run --config vitest.integration.config.mjs",
"test:integration:replay": "cross-env CI=1 vitest run --config vitest.integration.config.mjs",
"test:integration:record": "cross-env VCR_MODE=record vitest run --config vitest.integration.config.mjs"
"test:integration:record": "cross-env VCR_MODE=record vitest run --config vitest.integration.config.mjs",
"fixtures:generate": "tsx src/__tests__/fixtures/generate-bulk-vector-fixture.ts"
},
"dependencies": {
"@ai-sdk/amazon-bedrock": "catalog:",
@@ -89,6 +118,10 @@
"zod-to-json-schema": "catalog:"
},
"peerDependencies": {
"@pinecone-database/pinecone": "catalog:",
"@qdrant/js-client-rest": "catalog:",
"@supabase/supabase-js": "catalog:",
"pg": "catalog:",
"zod": "catalog:"
},
"peerDependenciesMeta": {
@@ -103,16 +136,34 @@
},
"@opentelemetry/exporter-trace-otlp-http": {
"optional": true
},
"pg": {
"optional": true
},
"@pinecone-database/pinecone": {
"optional": true
},
"@qdrant/js-client-rest": {
"optional": true
},
"@supabase/supabase-js": {
"optional": true
}
},
"devDependencies": {
"@n8n/typescript-config": "workspace:*",
"@n8n/vitest-config": "workspace:*",
"@pinecone-database/pinecone": "catalog:",
"@qdrant/js-client-rest": "catalog:",
"@supabase/supabase-js": "catalog:",
"@types/json-schema": "catalog:",
"@types/pg": "^8.15.6",
"@vitest/coverage-v8": "catalog:",
"dotenv": "catalog:",
"cross-env": "catalog:",
"nock": "catalog:",
"pg": "catalog:",
"tsx": "catalog:",
"vite": "catalog:",
"vitest": "catalog:",
"vitest-mock-extended": "catalog:",
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,146 @@
/**
* One-time (re-)generation script for bulk-vector-fixture.json — a committed
* corpus of real documents with precomputed embeddings used by the
* vector-store-bulk-*.test.ts and agent-vector-store-*.test.ts suites to
* exercise all three vector store backends against a real corpus without
* making OpenAI calls to embed documents at test time.
*
* Pulls 30 rows from the Hugging Face dataset
* Qdrant/dbpedia-entities-openai3-text-embedding-3-small-1536-100K (already
* embedded with text-embedding-3-small at its native 1536 dims — matches
* live query embeddings from the same model with no truncation needed), and
* assigns synthetic round-robin categories for filter tests. Embeds a
* handful of natural-language queries via the AI SDK and computes each
* query's local top match so the test suite has ground truth that doesn't
* depend on any backend's exact search semantics.
*
* Run with: pnpm fixtures:generate
*/
import { randomUUID } from 'node:crypto';
import { rm, writeFile } from 'node:fs/promises';
import path from 'node:path';
import { config as loadEnv } from 'dotenv';
import { embedMany } from 'ai';
import {
cosineSimilarity,
type BulkFixture,
type FixtureDocument,
type FixtureQuery,
} from '../integration/vector-store-helpers';
import { createEmbeddingModel } from '../../runtime/model/model-factory';
loadEnv({ path: path.resolve(__dirname, '../../../.env') });
const DOC_COUNT = 30;
const DIMENSIONS = 1536;
const CATEGORIES = ['history', 'science', 'geography'] as const;
const HF_DATASET = 'Qdrant/dbpedia-entities-openai3-text-embedding-3-small-1536-100K';
const EMBEDDING_FIELD = 'text-embedding-3-small-1536-embedding';
const OUT_PATH = path.join(__dirname, 'bulk-vector-fixture.json');
// Picked from the corpus after inspecting the printed titles below — distinctive
// enough that the top match isn't ambiguous with the #2 result.
const QUERIES = [
'What lake is located in Jefferson County, Florida?',
'What is the tallest federal building in Manhattan, and how many stories does it have?',
'Which cricketer played as a wicketkeeper for South Africa against Australia in 1970?',
'Who commanded the ANZAC forces during the Gallipoli campaign?',
];
interface HfRow {
title: string;
text: string;
[EMBEDDING_FIELD]: number[];
}
function roundVector(vector: number[]): number[] {
return vector.map((value) => Math.round(value * 1e6) / 1e6);
}
async function fetchWithRetry(url: string, retries = 5): Promise<Response> {
for (let attempt = 1; attempt <= retries; attempt++) {
const res = await fetch(url);
if (res.ok) return res;
if (attempt === retries) {
throw new Error(
`Request failed after ${retries} attempts: ${res.status} ${await res.text()}`,
);
}
console.warn(` Request failed (${res.status}), retrying (${attempt}/${retries})...`);
await new Promise((resolve) => setTimeout(resolve, attempt * 2000));
}
throw new Error('unreachable');
}
async function fetchHfRows(): Promise<HfRow[]> {
const url =
`https://datasets-server.huggingface.co/rows?dataset=${encodeURIComponent(HF_DATASET)}` +
`&config=default&split=train&offset=0&length=${DOC_COUNT}`;
const res = await fetchWithRetry(url);
const body = (await res.json()) as { rows: Array<{ row: HfRow }> };
return body.rows.map((r) => r.row);
}
async function embedQueries(queries: string[]): Promise<number[][]> {
if (!process.env.OPENAI_API_KEY) {
throw new Error('OPENAI_API_KEY is required to embed the fixture queries.');
}
const { embeddings } = await embedMany({
model: createEmbeddingModel('openai/text-embedding-3-small'),
values: queries,
});
return embeddings;
}
async function main(): Promise<void> {
await rm(OUT_PATH, { force: true });
console.log(`Fetching ${DOC_COUNT} rows from ${HF_DATASET}...`);
const rows = await fetchHfRows();
console.log('Titles fetched (for picking distinctive queries):');
for (const row of rows) console.log(` - ${row.title}`);
const documents: FixtureDocument[] = rows.map((row, index) => ({
id: randomUUID(),
content: row.text,
metadata: { title: row.title, category: CATEGORIES[index % CATEGORIES.length] },
vector: roundVector(row[EMBEDDING_FIELD]),
}));
console.log(`\nEmbedding ${QUERIES.length} queries...`);
const queryEmbeddings = await embedQueries(QUERIES);
const queries: FixtureQuery[] = QUERIES.map((text, i) => {
const vector = roundVector(queryEmbeddings[i]);
const ranked = documents
.map((doc) => ({
id: doc.id,
title: doc.metadata.title,
score: cosineSimilarity(vector, doc.vector),
}))
.sort((a, b) => b.score - a.score)
.slice(0, 3);
console.log(`\nQuery: "${text}"`);
for (const r of ranked) console.log(` ${r.score.toFixed(4)} ${r.title}`);
const gap = ranked[0].score - ranked[1].score;
if (gap < 0.02) {
console.warn(
` WARNING: top-1/top-2 score gap is only ${gap.toFixed(4)} — consider picking a more distinctive query.`,
);
}
return { text, vector, expectedTopId: ranked[0].id };
});
const fixture: BulkFixture = { dimensions: DIMENSIONS, documents, queries };
await writeFile(OUT_PATH, JSON.stringify(fixture));
console.log(`\nWrote ${documents.length} documents and ${queries.length} queries to ${OUT_PATH}`);
}
main().catch((error: unknown) => {
console.error(error);
process.exitCode = 1;
});
@@ -0,0 +1,57 @@
/**
* End-to-end: a real model searches a real Pinecone-backed VectorStore
* populated with the fixture corpus (see fixtures/generate-bulk-vector-fixture.ts)
* and answers from the results. Gated on PINECONE_TEST_API_KEY plus both
* provider keys; self-skips in CI replay since Pinecone can't be replayed
* from cassettes.
*/
import type { Pinecone } from '@pinecone-database/pinecone';
import { afterAll, beforeAll, describe } from 'vitest';
import {
createPineconeIndex,
loadBulkFixture,
registerAgentVectorStoreTests,
upsertFixtureDocuments,
waitForPineconeRecordCount,
} from './vector-store-helpers';
import { VectorStore } from '../../index';
import { PineconeVectorStore } from '../../vector-stores/pinecone';
const PINECONE_API_KEY = process.env.PINECONE_TEST_API_KEY;
const hasKeys = Boolean(process.env.ANTHROPIC_API_KEY) && Boolean(process.env.OPENAI_API_KEY);
const HOOK_TIMEOUT_MS = 240_000;
const fixture = loadBulkFixture();
describe.skipIf(!PINECONE_API_KEY || !hasKeys)('Agent + PineconeVectorStore end-to-end', () => {
const indexName = `vs-e2e-${Date.now()}-${Math.floor(Math.random() * 1e6)}`;
let adminClient: Pinecone;
let store: PineconeVectorStore;
let knowledge: VectorStore;
beforeAll(async () => {
const { Pinecone: PineconeCtor } = await import('@pinecone-database/pinecone');
adminClient = new PineconeCtor({ apiKey: PINECONE_API_KEY! });
await createPineconeIndex(adminClient, indexName, fixture.dimensions);
store = new PineconeVectorStore('knowledge-base', {
apiKey: PINECONE_API_KEY!,
indexName,
});
await upsertFixtureDocuments(store, fixture);
await waitForPineconeRecordCount(adminClient.index(indexName), fixture.documents.length);
knowledge = new VectorStore('knowledge-base')
.store(store)
.embeddingModel('openai/text-embedding-3-small')
.description('Search the knowledge base of encyclopedia articles');
}, HOOK_TIMEOUT_MS);
afterAll(async () => {
await adminClient.deleteIndex(indexName);
store.close();
});
registerAgentVectorStoreTests(() => knowledge);
});
@@ -0,0 +1,51 @@
/**
* End-to-end: a real model searches a real Postgres-backed VectorStore
* populated with the fixture corpus (see fixtures/generate-bulk-vector-fixture.ts)
* and answers from the results. Gated on PG_VECTOR_TEST_URL plus both
* provider keys; self-skips in CI replay since Postgres can't be replayed
* from cassettes.
*/
import type { Pool } from 'pg';
import { afterAll, beforeAll, describe } from 'vitest';
import {
createPgVectorTable,
loadBulkFixture,
registerAgentVectorStoreTests,
upsertFixtureDocuments,
} from './vector-store-helpers';
import { VectorStore } from '../../index';
import { PgVectorStore } from '../../vector-stores/postgres';
const PG_URL = process.env.PG_VECTOR_TEST_URL;
const hasKeys = Boolean(process.env.ANTHROPIC_API_KEY) && Boolean(process.env.OPENAI_API_KEY);
const fixture = loadBulkFixture();
describe.skipIf(!PG_URL || !hasKeys)('Agent + PgVectorStore end-to-end', () => {
const tableName = `vs_e2e_${Date.now()}_${Math.floor(Math.random() * 1e6)}`;
let adminPool: Pool;
let store: PgVectorStore;
let knowledge: VectorStore;
beforeAll(async () => {
const { Pool: PoolCtor } = await import('pg');
adminPool = new PoolCtor({ connectionString: PG_URL });
await createPgVectorTable(adminPool, tableName, { dimensions: fixture.dimensions });
store = new PgVectorStore('knowledge-base', { connectionString: PG_URL!, tableName });
await upsertFixtureDocuments(store, fixture);
knowledge = new VectorStore('knowledge-base')
.store(store)
.embeddingModel('openai/text-embedding-3-small')
.description('Search the knowledge base of encyclopedia articles');
});
afterAll(async () => {
await adminPool.query(`DROP TABLE IF EXISTS "${tableName}";`);
await store.close();
await adminPool.end();
});
registerAgentVectorStoreTests(() => knowledge);
});
@@ -0,0 +1,60 @@
/**
* End-to-end: a real model searches a real Qdrant-backed VectorStore
* populated with the fixture corpus (see fixtures/generate-bulk-vector-fixture.ts)
* and answers from the results. Gated on QDRANT_TEST_URL plus both provider
* keys; self-skips in CI replay since Qdrant can't be replayed from cassettes.
*/
import type { QdrantClient } from '@qdrant/js-client-rest';
import { afterAll, beforeAll, describe } from 'vitest';
import {
loadBulkFixture,
registerAgentVectorStoreTests,
upsertFixtureDocuments,
} from './vector-store-helpers';
import { VectorStore } from '../../index';
import { QdrantVectorStore } from '../../vector-stores/qdrant';
const QDRANT_URL = process.env.QDRANT_TEST_URL;
const QDRANT_API_KEY = process.env.QDRANT_TEST_API_KEY;
const hasKeys = Boolean(process.env.ANTHROPIC_API_KEY) && Boolean(process.env.OPENAI_API_KEY);
const fixture = loadBulkFixture();
describe.skipIf(!QDRANT_URL || !hasKeys)('Agent + QdrantVectorStore end-to-end', () => {
const collectionName = `vs_e2e_${Date.now()}_${Math.floor(Math.random() * 1e6)}`;
let adminClient: QdrantClient;
let store: QdrantVectorStore;
let knowledge: VectorStore;
beforeAll(async () => {
const { QdrantClient: QdrantClientCtor } = await import('@qdrant/js-client-rest');
adminClient = new QdrantClientCtor({ url: QDRANT_URL, apiKey: QDRANT_API_KEY });
await adminClient.createCollection(collectionName, {
vectors: { size: fixture.dimensions, distance: 'Cosine' },
});
// Qdrant Cloud requires a payload index to filter on a field at all.
await adminClient.createPayloadIndex(collectionName, {
field_name: 'metadata.category',
field_schema: 'keyword',
});
store = new QdrantVectorStore('knowledge-base', {
url: QDRANT_URL!,
apiKey: QDRANT_API_KEY,
collectionName,
});
await upsertFixtureDocuments(store, fixture);
knowledge = new VectorStore('knowledge-base')
.store(store)
.embeddingModel('openai/text-embedding-3-small')
.description('Search the knowledge base of encyclopedia articles');
});
afterAll(async () => {
await adminClient.deleteCollection(collectionName);
store.close();
});
registerAgentVectorStoreTests(() => knowledge);
});
@@ -0,0 +1,72 @@
/**
* End-to-end: a real model searches a real Supabase-backed VectorStore
* populated with the fixture corpus (see fixtures/generate-bulk-vector-fixture.ts)
* and answers from the results. Gated on SUPABASE_TEST_* plus both provider
* keys; self-skips in CI replay since Supabase can't be replayed from cassettes.
*/
import type { Pool } from 'pg';
import { afterAll, beforeAll, describe } from 'vitest';
import {
createSupabaseVectorTableAndFunction,
dropSupabaseVectorTableAndFunction,
loadBulkFixture,
registerAgentVectorStoreTests,
upsertFixtureDocuments,
waitUntilQueryable,
} from './vector-store-helpers';
import { VectorStore } from '../../index';
import { SupabaseVectorStore } from '../../vector-stores/supabase';
const SUPABASE_URL = process.env.SUPABASE_TEST_URL;
const SUPABASE_API_KEY = process.env.SUPABASE_TEST_API_KEY;
const SUPABASE_DB_URL = process.env.SUPABASE_TEST_DB_URL;
const hasKeys = Boolean(process.env.ANTHROPIC_API_KEY) && Boolean(process.env.OPENAI_API_KEY);
const HOOK_TIMEOUT_MS = 45_000;
const fixture = loadBulkFixture();
describe.skipIf(!SUPABASE_URL || !SUPABASE_API_KEY || !SUPABASE_DB_URL || !hasKeys)(
'Agent + SupabaseVectorStore end-to-end',
() => {
const suffix = `${Date.now()}_${Math.floor(Math.random() * 1e6)}`;
const tableName = `vs_e2e_${suffix}`;
const queryName = `match_vs_e2e_${suffix}`;
let adminPool: Pool;
let store: SupabaseVectorStore;
let knowledge: VectorStore;
beforeAll(async () => {
const { Pool: PoolCtor } = await import('pg');
adminPool = new PoolCtor({ connectionString: SUPABASE_DB_URL });
await createSupabaseVectorTableAndFunction(
adminPool,
tableName,
queryName,
fixture.dimensions,
);
store = new SupabaseVectorStore('knowledge-base', {
url: SUPABASE_URL!,
apiKey: SUPABASE_API_KEY!,
tableName,
queryName,
});
await waitUntilQueryable(store, fixture.dimensions);
await upsertFixtureDocuments(store, fixture);
knowledge = new VectorStore('knowledge-base')
.store(store)
.embeddingModel('openai/text-embedding-3-small')
.description('Search the knowledge base of encyclopedia articles');
}, HOOK_TIMEOUT_MS);
afterAll(async () => {
await dropSupabaseVectorTableAndFunction(adminPool, tableName, queryName);
store.close();
await adminPool.end();
});
registerAgentVectorStoreTests(() => knowledge);
},
);
@@ -0,0 +1,288 @@
/**
* Integration tests against a real Postgres + pgvector instance. Gated on
* PG_VECTOR_TEST_URL (not the usual API-key/cassette convention) since
* Postgres is raw TCP, not HTTP — these self-skip when the var is unset.
*/
import { MockEmbeddingModelV3 } from 'ai/test';
import type { Pool } from 'pg';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { createPgVectorTable } from './vector-store-helpers';
import { VectorStore } from '../../sdk/vector-store';
import { PgVectorStore } from '../../vector-stores/postgres';
const PG_URL = process.env.PG_VECTOR_TEST_URL;
describe.skipIf(!PG_URL)('PgVectorStore', () => {
const tableName = `vs_test_${Date.now()}_${Math.floor(Math.random() * 1e6)}`;
let store: PgVectorStore;
let adminPool: Pool;
beforeAll(async () => {
const { Pool: PoolCtor } = await import('pg');
adminPool = new PoolCtor({ connectionString: PG_URL });
await createPgVectorTable(adminPool, tableName, { dimensions: 3 });
store = new PgVectorStore('pg-integration', { connectionString: PG_URL!, tableName });
});
afterAll(async () => {
await adminPool.query(`DROP TABLE IF EXISTS "${tableName}";`);
await store.close();
await adminPool.end();
});
it('upserts vectors with metadata and queries by similarity', async () => {
await store.upsert([
{ id: 'exact', vector: [1, 0, 0], content: 'exact match', metadata: { topic: 'a' } },
{ id: 'near', vector: [0.9, 0.1, 0], content: 'near match', metadata: {} },
{ id: 'far', vector: [0, 1, 0], content: 'far match', metadata: {} },
]);
const results = await store.query([1, 0, 0], { topK: 2 });
expect(results).toHaveLength(2);
expect(results[0].id).toBe('exact');
expect(results[0].score).toBeCloseTo(1, 6);
expect(results[0].metadata).toEqual({ topic: 'a' });
expect(results[1].id).toBe('near');
});
it('re-upserting the same id updates its content', async () => {
await store.upsert([
{ id: 'exact', vector: [1, 0, 0], content: 'updated content', metadata: {} },
]);
const results = await store.query([1, 0, 0], { topK: 1 });
expect(results[0].id).toBe('exact');
expect(results[0].content).toBe('updated content');
});
it('deletes by ids', async () => {
await store.delete({ ids: ['far'] });
const results = await store.query([0, 1, 0], { topK: 5 });
expect(results.find((r) => r.id === 'far')).toBeUndefined();
});
it('round-trips through the VectorStore orchestrator with a mock embedding model', async () => {
const roundTripTable = `${tableName}_roundtrip`;
await createPgVectorTable(adminPool, roundTripTable, { dimensions: 3 });
const roundTripStore = new PgVectorStore('pg-integration-roundtrip', {
connectionString: PG_URL!,
tableName: roundTripTable,
});
const embeddingModel = new MockEmbeddingModelV3({
doEmbed: async ({ values }: { values: string[] }) => ({
embeddings: values.map(() => [1, 0, 0]),
warnings: [],
}),
});
try {
const knowledge = new VectorStore('roundtrip')
.store(roundTripStore)
.embeddingModel(embeddingModel);
await knowledge.addDocuments([{ content: 'refunds take 5 days' }]);
const results = await knowledge.search('how long do refunds take');
expect(results[0].content).toBe('refunds take 5 days');
} finally {
await adminPool.query(`DROP TABLE IF EXISTS "${roundTripTable}";`);
await roundTripStore.close();
}
});
});
describe.skipIf(!PG_URL)('PgVectorStore — metadata filtering', () => {
const filterTableName = `vs_filter_test_${Date.now()}_${Math.floor(Math.random() * 1e6)}`;
let filterStore: PgVectorStore;
let adminPool: Pool;
beforeAll(async () => {
const { Pool: PoolCtor } = await import('pg');
adminPool = new PoolCtor({ connectionString: PG_URL });
await createPgVectorTable(adminPool, filterTableName, { dimensions: 3, ginIndex: true });
filterStore = new PgVectorStore('pg-filter-integration', {
connectionString: PG_URL!,
tableName: filterTableName,
});
await filterStore.upsert([
{
id: 'a',
content: 'Row A content',
vector: [1, 0, 0],
metadata: { topic: 'billing', count: 5, active: true },
},
{
id: 'b',
content: 'Row B content',
vector: [0.9, 0.1, 0],
metadata: { topic: 'ops', count: 10, active: false },
},
// Missing all keys — required to prove ne/nin match rows that never had the key.
{ id: 'c', content: 'Row C content', vector: [0, 1, 0], metadata: {} },
// Stores count as a JSON string, not a number — proves eq is type-correct.
{ id: 'd', content: 'Row D content', vector: [0, 0.5, 0.5], metadata: { count: '5' } },
]);
});
afterAll(async () => {
await adminPool.query(`DROP TABLE IF EXISTS "${filterTableName}";`);
await filterStore.close();
await adminPool.end();
});
async function idsFor(filter: Parameters<PgVectorStore['query']>[1]['filter']) {
const results = await filterStore.query([1, 0, 0], { topK: 10, filter });
return results.map((r) => r.id).sort();
}
it('eq matches on string, number, and boolean, and is type-correct', async () => {
expect(
await idsFor({ conditions: [{ key: 'topic', operator: 'eq', value: 'billing' }] }),
).toEqual(['a']);
expect(await idsFor({ conditions: [{ key: 'count', operator: 'eq', value: 5 }] })).toEqual([
'a',
]);
expect(await idsFor({ conditions: [{ key: 'active', operator: 'eq', value: true }] })).toEqual([
'a',
]);
expect(
await idsFor({ conditions: [{ key: 'topic', operator: 'eq', value: 'nonexistent' }] }),
).toEqual([]);
});
it('combines multiple conditions with AND', async () => {
expect(
await idsFor({
conditions: [
{ key: 'topic', operator: 'eq', value: 'billing' },
{ key: 'count', operator: 'eq', value: 5 },
],
combineWith: 'and',
}),
).toEqual(['a']);
});
it('ne excludes only the matching row, including rows missing the key', async () => {
const ids = await idsFor({ conditions: [{ key: 'topic', operator: 'ne', value: 'billing' }] });
expect(ids).not.toContain('a');
expect(ids).toContain('b');
expect(ids).toContain('c');
});
it('in matches only listed values', async () => {
expect(
await idsFor({ conditions: [{ key: 'topic', operator: 'in', value: ['billing', 'ops'] }] }),
).toEqual(['a', 'b']);
});
it('in is type-correct: a numeric candidate does not match a string-valued row', async () => {
expect(await idsFor({ conditions: [{ key: 'count', operator: 'in', value: [5] }] })).toEqual([
'a',
]);
expect(await idsFor({ conditions: [{ key: 'count', operator: 'in', value: ['5'] }] })).toEqual([
'd',
]);
});
it('nin excludes listed values but matches rows missing the key (the reference NIN bug case)', async () => {
const ids = await idsFor({
conditions: [{ key: 'topic', operator: 'nin', value: ['billing'] }],
});
expect(ids).not.toContain('a');
expect(ids).toContain('b');
expect(ids).toContain('c'); // missing key — must match, not be excluded like `!= ANY` incorrectly does
});
it('supports an OR-combined group', async () => {
expect(
await idsFor({
conditions: [
{ key: 'topic', operator: 'eq', value: 'billing' },
{ key: 'topic', operator: 'eq', value: 'ops' },
],
combineWith: 'or',
}),
).toEqual(['a', 'b']);
});
});
describe.skipIf(!PG_URL)('PgVectorStore — filtered HNSW under-return (iterative scan)', () => {
const iterTableName = `vs_iter_test_${Date.now()}_${Math.floor(Math.random() * 1e6)}`;
let iterStore: PgVectorStore;
let adminPool: Pool;
let targetIds: string[];
const queryVector = [1, 0, 0];
function mulberry32(seed: number) {
return function random() {
let t = (seed += 0x6d2b79f5);
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
beforeAll(async () => {
const { Pool: PoolCtor } = await import('pg');
adminPool = new PoolCtor({ connectionString: PG_URL });
await createPgVectorTable(adminPool, iterTableName, { dimensions: 3, hnswIndex: true });
iterStore = new PgVectorStore('pg-iterative-scan', {
connectionString: PG_URL!,
tableName: iterTableName,
});
const random = mulberry32(42);
const records: Array<{
id: string;
content: string;
vector: number[];
metadata: Record<string, string>;
}> = [];
// Noise: tightly clustered around the query vector, satisfying HNSW's default candidate search alone.
for (let i = 0; i < 1995; i++) {
records.push({
id: `noise-${i}`,
content: `noise ${i}`,
vector: [1 + (random() - 0.5) * 0.01, (random() - 0.5) * 0.01, (random() - 0.5) * 0.01],
metadata: { group: 'noise' },
});
}
// Target: far from the query, the only rows matching the filter below.
targetIds = [];
for (let i = 0; i < 5; i++) {
const id = `target-${i}`;
targetIds.push(id);
records.push({
id,
content: `target ${i}`,
vector: [(random() - 0.5) * 0.01, (random() - 0.5) * 0.01, 1 + (random() - 0.5) * 0.01],
metadata: { group: 'target' },
});
}
targetIds.sort();
await iterStore.upsert(records);
});
afterAll(async () => {
await adminPool.query(`DROP TABLE IF EXISTS "${iterTableName}";`);
await iterStore.close();
await adminPool.end();
});
it('returns all matching rows through the adapter despite the HNSW under-return bug', async () => {
const results = await iterStore.query(queryVector, {
topK: 5,
filter: { conditions: [{ key: 'group', operator: 'eq', value: 'target' }] },
});
expect(results.map((r) => r.id).sort()).toEqual(targetIds);
});
});
@@ -0,0 +1,262 @@
/**
* Integration tests against a real Pinecone project. Gated on
* PINECONE_TEST_API_KEY (not the usual API-key/cassette convention) since
* Pinecone is a separate service, not LLM HTTP — these self-skip when it's
* unset. Pinecone writes and deletes are eventually consistent, so every
* assertion that depends on a preceding mutation is wrapped in `eventually`,
* which retries the assertion until it passes or a timeout elapses.
*/
import type { Pinecone } from '@pinecone-database/pinecone';
import { MockEmbeddingModelV3 } from 'ai/test';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { createPineconeIndex } from './vector-store-helpers';
import { VectorStore } from '../../sdk/vector-store';
import { PineconeVectorStore } from '../../vector-stores/pinecone';
const PINECONE_API_KEY = process.env.PINECONE_TEST_API_KEY;
const HOOK_TIMEOUT_MS = 180_000;
async function eventually(fn: () => void | Promise<void>, timeoutMs = 15_000): Promise<void> {
const deadline = Date.now() + timeoutMs;
for (;;) {
try {
await fn();
return;
} catch (error) {
if (Date.now() > deadline) throw error;
await new Promise((resolve) => setTimeout(resolve, 500));
}
}
}
describe.skipIf(!PINECONE_API_KEY)('PineconeVectorStore', () => {
const indexName = `vs-test-${Date.now()}-${Math.floor(Math.random() * 1e6)}`;
let store: PineconeVectorStore;
let adminClient: Pinecone;
beforeAll(async () => {
const { Pinecone: PineconeCtor } = await import('@pinecone-database/pinecone');
adminClient = new PineconeCtor({ apiKey: PINECONE_API_KEY! });
await createPineconeIndex(adminClient, indexName, 3);
store = new PineconeVectorStore('pinecone-integration', {
apiKey: PINECONE_API_KEY!,
indexName,
});
}, HOOK_TIMEOUT_MS);
afterAll(async () => {
await adminClient.deleteIndex(indexName);
store.close();
});
it('upserts vectors with metadata and queries by similarity', async () => {
await store.upsert([
{ id: 'exact', vector: [1, 0, 0], content: 'exact match', metadata: { topic: 'a' } },
{ id: 'near', vector: [0.9, 0.1, 0], content: 'near match', metadata: {} },
{ id: 'far', vector: [0, 1, 0], content: 'far match', metadata: {} },
]);
await eventually(async () => {
const results = await store.query([1, 0, 0], { topK: 2 });
expect(results).toHaveLength(2);
expect(results[0].id).toBe('exact');
expect(results[0].score).toBeCloseTo(1, 3);
expect(results[0].metadata).toEqual({ topic: 'a' });
expect(results[1].id).toBe('near');
});
});
it('re-upserting the same id updates its content', async () => {
await store.upsert([
{ id: 'exact', vector: [1, 0, 0], content: 'updated content', metadata: {} },
]);
await eventually(async () => {
const results = await store.query([1, 0, 0], { topK: 1 });
expect(results[0].id).toBe('exact');
expect(results[0].content).toBe('updated content');
});
});
it('deletes by ids', async () => {
await store.delete({ ids: ['far'] });
await eventually(async () => {
const results = await store.query([0, 1, 0], { topK: 5 });
expect(results.find((r) => r.id === 'far')).toBeUndefined();
});
});
it('rejects upserting metadata that uses the reserved _content key', async () => {
await expect(
store.upsert([{ id: 'bad', vector: [1, 0, 0], content: 'x', metadata: { _content: 'y' } }]),
).rejects.toThrow(/reserved for the document content/);
});
it('rejects upserting a nested-object metadata value', async () => {
await expect(
store.upsert([
{ id: 'bad', vector: [1, 0, 0], content: 'x', metadata: { nested: { a: 1 } } },
]),
).rejects.toThrow(/unsupported/);
});
it('round-trips through the VectorStore orchestrator with a mock embedding model', async () => {
const roundTripStore = new PineconeVectorStore('pinecone-integration-roundtrip', {
apiKey: PINECONE_API_KEY!,
indexName,
namespace: 'roundtrip',
});
const embeddingModel = new MockEmbeddingModelV3({
doEmbed: async ({ values }: { values: string[] }) => ({
embeddings: values.map(() => [1, 0, 0]),
warnings: [],
}),
});
try {
const knowledge = new VectorStore('roundtrip')
.store(roundTripStore)
.embeddingModel(embeddingModel);
await knowledge.addDocuments([{ content: 'refunds take 5 days' }]);
await eventually(async () => {
const results = await knowledge.search('how long do refunds take');
expect(results[0].content).toBe('refunds take 5 days');
});
} finally {
roundTripStore.close();
}
});
});
describe.skipIf(!PINECONE_API_KEY)('PineconeVectorStore — metadata filtering', () => {
const indexName = `vs-test-filter-${Date.now()}-${Math.floor(Math.random() * 1e6)}`;
let filterStore: PineconeVectorStore;
let adminClient: Pinecone;
beforeAll(async () => {
const { Pinecone: PineconeCtor } = await import('@pinecone-database/pinecone');
adminClient = new PineconeCtor({ apiKey: PINECONE_API_KEY! });
await createPineconeIndex(adminClient, indexName, 3);
filterStore = new PineconeVectorStore('pinecone-filter-integration', {
apiKey: PINECONE_API_KEY!,
indexName,
});
await filterStore.upsert([
{
id: 'a',
content: 'Row A content',
vector: [1, 0, 0],
metadata: { topic: 'billing', count: 5, active: true },
},
{
id: 'b',
content: 'Row B content',
vector: [0.9, 0.1, 0],
metadata: { topic: 'ops', count: 10, active: false },
},
// Missing all keys — required to prove ne/nin match rows that never had the key.
{ id: 'c', content: 'Row C content', vector: [0, 1, 0], metadata: {} },
// Stores count as a JSON string, not a number — proves a numeric filter
// doesn't match it, and (unlike Qdrant, which restricts a field to one
// index type) that a string filter on the same field matches it back.
{ id: 'd', content: 'Row D content', vector: [0, 0.5, 0.5], metadata: { count: '5' } },
]);
// Pinecone writes are eventually consistent — wait for the full corpus to be queryable before any test runs.
await eventually(async () => {
const results = await filterStore.query([1, 0, 0], { topK: 10 });
expect(results.map((r) => r.id).sort()).toEqual(['a', 'b', 'c', 'd']);
}, 30_000);
}, HOOK_TIMEOUT_MS);
afterAll(async () => {
await adminClient.deleteIndex(indexName);
filterStore.close();
});
async function idsFor(filter: Parameters<PineconeVectorStore['query']>[1]['filter']) {
const results = await filterStore.query([1, 0, 0], { topK: 10, filter });
return results.map((r) => r.id).sort();
}
it('eq matches on string, number, and boolean, and is type-correct', async () => {
expect(
await idsFor({ conditions: [{ key: 'topic', operator: 'eq', value: 'billing' }] }),
).toEqual(['a']);
expect(await idsFor({ conditions: [{ key: 'count', operator: 'eq', value: 5 }] })).toEqual([
'a',
]);
expect(await idsFor({ conditions: [{ key: 'active', operator: 'eq', value: true }] })).toEqual([
'a',
]);
expect(
await idsFor({ conditions: [{ key: 'topic', operator: 'eq', value: 'nonexistent' }] }),
).toEqual([]);
});
it('eq is type-correct in both directions between numeric and string-typed values', async () => {
expect(await idsFor({ conditions: [{ key: 'count', operator: 'eq', value: 5 }] })).toEqual([
'a',
]);
expect(await idsFor({ conditions: [{ key: 'count', operator: 'eq', value: '5' }] })).toEqual([
'd',
]);
});
it('combines multiple conditions with AND', async () => {
expect(
await idsFor({
conditions: [
{ key: 'topic', operator: 'eq', value: 'billing' },
{ key: 'count', operator: 'eq', value: 5 },
],
combineWith: 'and',
}),
).toEqual(['a']);
});
it('ne excludes only the matching row, including rows missing the key', async () => {
const ids = await idsFor({
conditions: [{ key: 'topic', operator: 'ne', value: 'billing' }],
});
expect(ids).not.toContain('a');
expect(ids).toContain('b');
expect(ids).toContain('c');
});
it('in matches only listed values', async () => {
expect(
await idsFor({ conditions: [{ key: 'topic', operator: 'in', value: ['billing', 'ops'] }] }),
).toEqual(['a', 'b']);
});
it('in is type-correct: a numeric candidate does not match a string-valued row', async () => {
expect(await idsFor({ conditions: [{ key: 'count', operator: 'in', value: [5] }] })).toEqual([
'a',
]);
});
it('nin excludes listed values but matches rows missing the key (the reference NIN bug case)', async () => {
const ids = await idsFor({
conditions: [{ key: 'topic', operator: 'nin', value: ['billing'] }],
});
expect(ids).not.toContain('a');
expect(ids).toContain('b');
expect(ids).toContain('c'); // missing key — must match, not be excluded like `!= ANY` incorrectly does
});
it('supports an OR-combined group', async () => {
expect(
await idsFor({
conditions: [
{ key: 'topic', operator: 'eq', value: 'billing' },
{ key: 'topic', operator: 'eq', value: 'ops' },
],
combineWith: 'or',
}),
).toEqual(['a', 'b']);
});
});
@@ -0,0 +1,287 @@
/**
* Integration tests against a real Qdrant instance — local or cloud. Gated on
* QDRANT_TEST_URL (not the usual API-key/cassette convention) since Qdrant is
* a separate service, not LLM HTTP — these self-skip when it's unset.
* QDRANT_TEST_API_KEY is optional, for cloud instances that require auth.
*/
import type { QdrantClient } from '@qdrant/js-client-rest';
import { MockEmbeddingModelV3 } from 'ai/test';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { VectorStore } from '../../sdk/vector-store';
import { QdrantVectorStore } from '../../vector-stores/qdrant';
const QDRANT_URL = process.env.QDRANT_TEST_URL;
const QDRANT_API_KEY = process.env.QDRANT_TEST_API_KEY;
// Qdrant only accepts UUID or unsigned-integer point ids.
const ID_EXACT = '00000000-0000-4000-8000-000000000001';
const ID_NEAR = '00000000-0000-4000-8000-000000000002';
const ID_FAR = '00000000-0000-4000-8000-000000000003';
const ID_A = '00000000-0000-4000-8000-00000000000a';
const ID_B = '00000000-0000-4000-8000-00000000000b';
const ID_C = '00000000-0000-4000-8000-00000000000c';
const ID_D = '00000000-0000-4000-8000-00000000000d';
/** Stands in for the BYO user's own setup, since QdrantVectorStore itself never creates collections. */
async function createVectorCollection(
client: QdrantClient,
collectionName: string,
opts: { dimensions: number },
): Promise<void> {
await client.createCollection(collectionName, {
vectors: { size: opts.dimensions, distance: 'Cosine' },
});
}
describe.skipIf(!QDRANT_URL)('QdrantVectorStore', () => {
const collectionName = `vs_test_${Date.now()}_${Math.floor(Math.random() * 1e6)}`;
let store: QdrantVectorStore;
let adminClient: QdrantClient;
beforeAll(async () => {
const { QdrantClient: QdrantClientCtor } = await import('@qdrant/js-client-rest');
adminClient = new QdrantClientCtor({ url: QDRANT_URL, apiKey: QDRANT_API_KEY });
await createVectorCollection(adminClient, collectionName, { dimensions: 3 });
store = new QdrantVectorStore('qdrant-integration', {
url: QDRANT_URL!,
apiKey: QDRANT_API_KEY,
collectionName,
});
});
afterAll(async () => {
await adminClient.deleteCollection(collectionName);
store.close();
});
it('upserts vectors with metadata and queries by similarity', async () => {
await store.upsert([
{ id: ID_EXACT, vector: [1, 0, 0], content: 'exact match', metadata: { topic: 'a' } },
{ id: ID_NEAR, vector: [0.9, 0.1, 0], content: 'near match', metadata: {} },
{ id: ID_FAR, vector: [0, 1, 0], content: 'far match', metadata: {} },
]);
const results = await store.query([1, 0, 0], { topK: 2 });
expect(results).toHaveLength(2);
expect(results[0].id).toBe(ID_EXACT);
expect(results[0].score).toBeCloseTo(1, 6);
expect(results[0].metadata).toEqual({ topic: 'a' });
expect(results[1].id).toBe(ID_NEAR);
});
it('re-upserting the same id updates its content', async () => {
await store.upsert([
{ id: ID_EXACT, vector: [1, 0, 0], content: 'updated content', metadata: {} },
]);
const results = await store.query([1, 0, 0], { topK: 1 });
expect(results[0].id).toBe(ID_EXACT);
expect(results[0].content).toBe('updated content');
});
it('deletes by ids', async () => {
await store.delete({ ids: [ID_FAR] });
const results = await store.query([0, 1, 0], { topK: 5 });
expect(results.find((r) => r.id === ID_FAR)).toBeUndefined();
});
it('rejects a non-UUID, non-integer id', async () => {
await expect(
store.upsert([{ id: 'exact', vector: [1, 0, 0], content: 'bad id', metadata: {} }]),
).rejects.toThrow(/Qdrant requires ids to be a UUID or a canonical unsigned integer/);
});
it('rejects a non-canonical numeric id', async () => {
await expect(
store.upsert([{ id: '007', vector: [1, 0, 0], content: 'bad id', metadata: {} }]),
).rejects.toThrow(/canonical unsigned integer/);
});
it('round-trips through the VectorStore orchestrator with a mock embedding model', async () => {
const roundTripCollection = `${collectionName}_roundtrip`;
await createVectorCollection(adminClient, roundTripCollection, { dimensions: 3 });
const roundTripStore = new QdrantVectorStore('qdrant-integration-roundtrip', {
url: QDRANT_URL!,
apiKey: QDRANT_API_KEY,
collectionName: roundTripCollection,
});
const embeddingModel = new MockEmbeddingModelV3({
doEmbed: async ({ values }: { values: string[] }) => ({
embeddings: values.map(() => [1, 0, 0]),
warnings: [],
}),
});
try {
const knowledge = new VectorStore('roundtrip')
.store(roundTripStore)
.embeddingModel(embeddingModel);
await knowledge.addDocuments([{ content: 'refunds take 5 days' }]);
const results = await knowledge.search('how long do refunds take');
expect(results[0].content).toBe('refunds take 5 days');
} finally {
await adminClient.deleteCollection(roundTripCollection);
roundTripStore.close();
}
});
});
describe.skipIf(!QDRANT_URL)('QdrantVectorStore — metadata filtering', () => {
const filterCollectionName = `vs_test_filter_${Date.now()}_${Math.floor(Math.random() * 1e6)}`;
let filterStore: QdrantVectorStore;
let adminClient: QdrantClient;
beforeAll(async () => {
const { QdrantClient: QdrantClientCtor } = await import('@qdrant/js-client-rest');
adminClient = new QdrantClientCtor({ url: QDRANT_URL, apiKey: QDRANT_API_KEY });
await createVectorCollection(adminClient, filterCollectionName, { dimensions: 3 });
// Qdrant Cloud requires a payload index to filter on a field at all, and
// allows only one index type per field (a second `createPayloadIndex`
// call on the same field replaces rather than adds) — see the `count`
// fixture note below for how that constrains what this suite can assert.
await adminClient.createPayloadIndex(filterCollectionName, {
field_name: 'metadata.topic',
field_schema: 'keyword',
});
await adminClient.createPayloadIndex(filterCollectionName, {
field_name: 'metadata.count',
field_schema: 'integer',
});
await adminClient.createPayloadIndex(filterCollectionName, {
field_name: 'metadata.active',
field_schema: 'bool',
});
filterStore = new QdrantVectorStore('qdrant-filter-integration', {
url: QDRANT_URL!,
apiKey: QDRANT_API_KEY,
collectionName: filterCollectionName,
});
await filterStore.upsert([
{
id: ID_A,
content: 'Row A content',
vector: [1, 0, 0],
metadata: { topic: 'billing', count: 5, active: true },
},
{
id: ID_B,
content: 'Row B content',
vector: [0.9, 0.1, 0],
metadata: { topic: 'ops', count: 10, active: false },
},
// Missing all keys — required to prove ne/nin match rows that never had the key.
{ id: ID_C, content: 'Row C content', vector: [0, 1, 0], metadata: {} },
// Stores count as a JSON string, not a number — proves a numeric filter
// doesn't match it. (The reverse — a string filter matching this row —
// isn't tested here: Qdrant's `metadata.count` field is indexed as
// `integer` above, and a field can only carry one index type at a time,
// so a `keyword` filter query on the same field isn't servable.)
{ id: ID_D, content: 'Row D content', vector: [0, 0.5, 0.5], metadata: { count: '5' } },
]);
});
afterAll(async () => {
await adminClient.deleteCollection(filterCollectionName);
filterStore.close();
});
async function idsFor(filter: Parameters<QdrantVectorStore['query']>[1]['filter']) {
const results = await filterStore.query([1, 0, 0], { topK: 10, filter });
return results.map((r) => r.id).sort();
}
it('eq matches on string, number, and boolean, and is type-correct', async () => {
expect(
await idsFor({ conditions: [{ key: 'topic', operator: 'eq', value: 'billing' }] }),
).toEqual([ID_A]);
expect(await idsFor({ conditions: [{ key: 'count', operator: 'eq', value: 5 }] })).toEqual([
ID_A,
]);
expect(await idsFor({ conditions: [{ key: 'active', operator: 'eq', value: true }] })).toEqual([
ID_A,
]);
expect(
await idsFor({ conditions: [{ key: 'topic', operator: 'eq', value: 'nonexistent' }] }),
).toEqual([]);
});
it('combines multiple conditions with AND', async () => {
expect(
await idsFor({
conditions: [
{ key: 'topic', operator: 'eq', value: 'billing' },
{ key: 'count', operator: 'eq', value: 5 },
],
combineWith: 'and',
}),
).toEqual([ID_A]);
});
it('ne excludes only the matching row, including rows missing the key', async () => {
const ids = await idsFor({
conditions: [{ key: 'topic', operator: 'ne', value: 'billing' }],
});
expect(ids).not.toContain(ID_A);
expect(ids).toContain(ID_B);
expect(ids).toContain(ID_C);
});
it('in matches only listed values', async () => {
expect(
await idsFor({ conditions: [{ key: 'topic', operator: 'in', value: ['billing', 'ops'] }] }),
).toEqual([ID_A, ID_B].sort());
});
it('in is type-correct: a numeric candidate does not match a string-valued row', async () => {
expect(await idsFor({ conditions: [{ key: 'count', operator: 'in', value: [5] }] })).toEqual([
ID_A,
]);
});
it('nin excludes listed values but matches rows missing the key (the reference NIN bug case)', async () => {
const ids = await idsFor({
conditions: [{ key: 'topic', operator: 'nin', value: ['billing'] }],
});
expect(ids).not.toContain(ID_A);
expect(ids).toContain(ID_B);
expect(ids).toContain(ID_C); // missing key — must match, not be excluded like `!= ANY` incorrectly does
});
it('supports an OR-combined group', async () => {
expect(
await idsFor({
conditions: [
{ key: 'topic', operator: 'eq', value: 'billing' },
{ key: 'topic', operator: 'eq', value: 'ops' },
],
combineWith: 'or',
}),
).toEqual([ID_A, ID_B].sort());
});
it('rejects a float value for eq', async () => {
await expect(
filterStore.query([1, 0, 0], {
topK: 10,
filter: { conditions: [{ key: 'count', operator: 'eq', value: 5.5 }] },
}),
).rejects.toThrow(/does not support float values/);
});
it('rejects a mixed-type array for in', async () => {
await expect(
filterStore.query([1, 0, 0], {
topK: 10,
filter: { conditions: [{ key: 'topic', operator: 'in', value: ['billing', 5] }] },
}),
).rejects.toThrow(/mixed-type or float values/);
});
});
@@ -0,0 +1,288 @@
/**
* Integration tests against a real Supabase project. Gated on
* SUPABASE_TEST_URL / SUPABASE_TEST_API_KEY / SUPABASE_TEST_DB_URL (not the
* usual API-key/cassette convention) since Supabase is a separate service,
* not LLM HTTP — these self-skip when any are unset. SUPABASE_TEST_DB_URL is
* the project's direct Postgres connection string (Dashboard > Connect >
* Session pooler), used only to provision the table/function via `pg` since
* supabase-js (PostgREST) cannot run DDL.
*/
import { MockEmbeddingModelV3 } from 'ai/test';
import type { Pool } from 'pg';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import {
createSupabaseVectorTableAndFunction,
dropSupabaseVectorTableAndFunction,
waitUntilQueryable,
} from './vector-store-helpers';
import { VectorStore } from '../../sdk/vector-store';
import { SupabaseVectorStore } from '../../vector-stores/supabase';
const SUPABASE_URL = process.env.SUPABASE_TEST_URL;
const SUPABASE_API_KEY = process.env.SUPABASE_TEST_API_KEY;
const SUPABASE_DB_URL = process.env.SUPABASE_TEST_DB_URL;
const HOOK_TIMEOUT_MS = 45_000;
describe.skipIf(!SUPABASE_URL || !SUPABASE_API_KEY || !SUPABASE_DB_URL)(
'SupabaseVectorStore',
() => {
const suffix = `${Date.now()}_${Math.floor(Math.random() * 1e6)}`;
const tableName = `vs_test_${suffix}`;
const queryName = `match_vs_test_${suffix}`;
let store: SupabaseVectorStore;
let adminPool: Pool;
beforeAll(async () => {
const { Pool: PoolCtor } = await import('pg');
adminPool = new PoolCtor({ connectionString: SUPABASE_DB_URL });
await createSupabaseVectorTableAndFunction(adminPool, tableName, queryName, 3);
store = new SupabaseVectorStore('supabase-integration', {
url: SUPABASE_URL!,
apiKey: SUPABASE_API_KEY!,
tableName,
queryName,
});
await waitUntilQueryable(store, 3);
}, HOOK_TIMEOUT_MS);
afterAll(async () => {
await dropSupabaseVectorTableAndFunction(adminPool, tableName, queryName);
store.close();
await adminPool.end();
});
it('upserts vectors with metadata and queries by similarity', async () => {
await store.upsert([
{ id: 'exact', vector: [1, 0, 0], content: 'exact match', metadata: { topic: 'a' } },
{ id: 'near', vector: [0.9, 0.1, 0], content: 'near match', metadata: {} },
{ id: 'far', vector: [0, 1, 0], content: 'far match', metadata: {} },
]);
const results = await store.query([1, 0, 0], { topK: 2 });
expect(results).toHaveLength(2);
expect(results[0].id).toBe('exact');
expect(results[0].score).toBeCloseTo(1, 6);
expect(results[0].metadata).toEqual({ topic: 'a' });
expect(results[1].id).toBe('near');
});
it('re-upserting the same id updates its content', async () => {
await store.upsert([
{ id: 'exact', vector: [1, 0, 0], content: 'updated content', metadata: {} },
]);
const results = await store.query([1, 0, 0], { topK: 1 });
expect(results[0].id).toBe('exact');
expect(results[0].content).toBe('updated content');
});
it('deletes by ids', async () => {
await store.delete({ ids: ['far'] });
const results = await store.query([0, 1, 0], { topK: 5 });
expect(results.find((r) => r.id === 'far')).toBeUndefined();
});
it(
'round-trips through the VectorStore orchestrator with a mock embedding model',
async () => {
const roundTripTable = `${tableName}_roundtrip`;
const roundTripQueryName = `${queryName}_roundtrip`;
await createSupabaseVectorTableAndFunction(
adminPool,
roundTripTable,
roundTripQueryName,
3,
);
const roundTripStore = new SupabaseVectorStore('supabase-integration-roundtrip', {
url: SUPABASE_URL!,
apiKey: SUPABASE_API_KEY!,
tableName: roundTripTable,
queryName: roundTripQueryName,
});
await waitUntilQueryable(roundTripStore, 3);
const embeddingModel = new MockEmbeddingModelV3({
doEmbed: async ({ values }: { values: string[] }) => ({
embeddings: values.map(() => [1, 0, 0]),
warnings: [],
}),
});
try {
const knowledge = new VectorStore('roundtrip')
.store(roundTripStore)
.embeddingModel(embeddingModel);
await knowledge.addDocuments([{ content: 'refunds take 5 days' }]);
const results = await knowledge.search('how long do refunds take');
expect(results[0].content).toBe('refunds take 5 days');
} finally {
await dropSupabaseVectorTableAndFunction(adminPool, roundTripTable, roundTripQueryName);
roundTripStore.close();
}
},
HOOK_TIMEOUT_MS,
);
},
);
describe.skipIf(!SUPABASE_URL || !SUPABASE_API_KEY || !SUPABASE_DB_URL)(
'SupabaseVectorStore — metadata filtering',
() => {
const suffix = `${Date.now()}_${Math.floor(Math.random() * 1e6)}`;
const filterTableName = `vs_filter_test_${suffix}`;
const filterQueryName = `match_vs_filter_test_${suffix}`;
let filterStore: SupabaseVectorStore;
let adminPool: Pool;
beforeAll(async () => {
const { Pool: PoolCtor } = await import('pg');
adminPool = new PoolCtor({ connectionString: SUPABASE_DB_URL });
await createSupabaseVectorTableAndFunction(adminPool, filterTableName, filterQueryName, 3);
filterStore = new SupabaseVectorStore('supabase-filter-integration', {
url: SUPABASE_URL!,
apiKey: SUPABASE_API_KEY!,
tableName: filterTableName,
queryName: filterQueryName,
});
await waitUntilQueryable(filterStore, 3);
await filterStore.upsert([
{
id: 'a',
content: 'Row A content',
vector: [1, 0, 0],
metadata: { topic: 'billing', count: 5, active: true },
},
{
id: 'b',
content: 'Row B content',
vector: [0.9, 0.1, 0],
metadata: { topic: 'ops', count: 10, active: false },
},
// Missing all keys — required to prove ne/nin match rows that never had the key.
{ id: 'c', content: 'Row C content', vector: [0, 1, 0], metadata: {} },
// Stores count as a JSON string, not a number — proves eq is type-correct.
{ id: 'd', content: 'Row D content', vector: [0, 0.5, 0.5], metadata: { count: '5' } },
]);
}, HOOK_TIMEOUT_MS);
afterAll(async () => {
await dropSupabaseVectorTableAndFunction(adminPool, filterTableName, filterQueryName);
filterStore.close();
await adminPool.end();
});
async function idsFor(filter: Parameters<SupabaseVectorStore['query']>[1]['filter']) {
const results = await filterStore.query([1, 0, 0], { topK: 10, filter });
return results.map((r) => r.id).sort();
}
it('eq matches on string, number, and boolean, and is type-correct', async () => {
expect(
await idsFor({ conditions: [{ key: 'topic', operator: 'eq', value: 'billing' }] }),
).toEqual(['a']);
expect(await idsFor({ conditions: [{ key: 'count', operator: 'eq', value: 5 }] })).toEqual([
'a',
]);
expect(
await idsFor({ conditions: [{ key: 'active', operator: 'eq', value: true }] }),
).toEqual(['a']);
expect(
await idsFor({ conditions: [{ key: 'topic', operator: 'eq', value: 'nonexistent' }] }),
).toEqual([]);
});
it('combines multiple conditions with AND', async () => {
expect(
await idsFor({
conditions: [
{ key: 'topic', operator: 'eq', value: 'billing' },
{ key: 'count', operator: 'eq', value: 5 },
],
combineWith: 'and',
}),
).toEqual(['a']);
});
it('combines in with another condition under AND', async () => {
expect(
await idsFor({
conditions: [
{ key: 'topic', operator: 'in', value: ['billing', 'ops'] },
{ key: 'active', operator: 'eq', value: true },
],
combineWith: 'and',
}),
).toEqual(['a']);
});
it('ne excludes only the matching row, including rows missing the key', async () => {
const ids = await idsFor({
conditions: [{ key: 'topic', operator: 'ne', value: 'billing' }],
});
expect(ids).not.toContain('a');
expect(ids).toContain('b');
expect(ids).toContain('c');
});
it('in matches only listed values', async () => {
expect(
await idsFor({ conditions: [{ key: 'topic', operator: 'in', value: ['billing', 'ops'] }] }),
).toEqual(['a', 'b']);
});
it('in is type-correct: a numeric candidate does not match a string-valued row', async () => {
expect(await idsFor({ conditions: [{ key: 'count', operator: 'in', value: [5] }] })).toEqual([
'a',
]);
expect(
await idsFor({ conditions: [{ key: 'count', operator: 'in', value: ['5'] }] }),
).toEqual(['d']);
});
it('nin excludes listed values but matches rows missing the key (the reference NIN bug case)', async () => {
const ids = await idsFor({
conditions: [{ key: 'topic', operator: 'nin', value: ['billing'] }],
});
expect(ids).not.toContain('a');
expect(ids).toContain('b');
expect(ids).toContain('c'); // missing key — must match, not be excluded like `!= ANY` incorrectly does
});
it('supports an OR-combined group', async () => {
expect(
await idsFor({
conditions: [
{ key: 'topic', operator: 'eq', value: 'billing' },
{ key: 'topic', operator: 'eq', value: 'ops' },
],
combineWith: 'or',
}),
).toEqual(['a', 'b']);
});
it('rejects an empty array for in', async () => {
await expect(
filterStore.query([1, 0, 0], {
topK: 10,
filter: { conditions: [{ key: 'topic', operator: 'in', value: [] }] },
}),
).rejects.toThrow(/requires a non-empty array value/);
});
it('rejects an empty array for nin', async () => {
await expect(
filterStore.query([1, 0, 0], {
topK: 10,
filter: { conditions: [{ key: 'topic', operator: 'nin', value: [] }] },
}),
).rejects.toThrow(/requires a non-empty array value/);
});
},
);
@@ -0,0 +1,53 @@
/**
* Bulk retrieval-quality suite run against PineconeVectorStore using a
* committed 30-document fixture with precomputed embeddings (see
* fixtures/generate-bulk-vector-fixture.ts). No OpenAI calls at test time —
* both document and query vectors are precomputed, so "ground truth" is
* computed locally via cosine similarity and compared against what the
* backend actually returns.
*/
import type { Pinecone } from '@pinecone-database/pinecone';
import { afterAll, beforeAll, describe } from 'vitest';
import {
createPineconeIndex,
loadBulkFixture,
registerBulkVectorStoreTests,
upsertFixtureDocuments,
waitForPineconeRecordCount,
} from './vector-store-helpers';
import { PineconeVectorStore } from '../../vector-stores/pinecone';
const PINECONE_API_KEY = process.env.PINECONE_TEST_API_KEY;
const HOOK_TIMEOUT_MS = 240_000;
const fixture = loadBulkFixture();
describe.skipIf(!PINECONE_API_KEY)('PineconeVectorStore — bulk fixture corpus', () => {
const indexName = `vs-bulk-${Date.now()}-${Math.floor(Math.random() * 1e6)}`;
let adminClient: Pinecone;
let store: PineconeVectorStore;
beforeAll(async () => {
const { Pinecone: PineconeCtor } = await import('@pinecone-database/pinecone');
adminClient = new PineconeCtor({ apiKey: PINECONE_API_KEY! });
await createPineconeIndex(adminClient, indexName, fixture.dimensions);
store = new PineconeVectorStore('bulk-pinecone', {
apiKey: PINECONE_API_KEY!,
indexName,
});
await upsertFixtureDocuments(store, fixture);
await waitForPineconeRecordCount(adminClient.index(indexName), fixture.documents.length);
}, HOOK_TIMEOUT_MS);
afterAll(async () => {
await adminClient.deleteIndex(indexName);
store.close();
});
registerBulkVectorStoreTests(fixture, () => store, {
afterDelete: async (remainingCount) =>
await waitForPineconeRecordCount(adminClient.index(indexName), remainingCount),
});
});
@@ -0,0 +1,46 @@
/**
* Bulk retrieval-quality suite run against PgVectorStore using a committed
* 30-document fixture with precomputed embeddings (see
* fixtures/generate-bulk-vector-fixture.ts). No OpenAI calls at test time —
* both document and query vectors are precomputed, so "ground truth" is
* computed locally via cosine similarity and compared against what the
* backend actually returns.
*/
import type { Pool } from 'pg';
import { afterAll, beforeAll, describe } from 'vitest';
import {
createPgVectorTable,
loadBulkFixture,
registerBulkVectorStoreTests,
upsertFixtureDocuments,
} from './vector-store-helpers';
import { PgVectorStore } from '../../vector-stores/postgres';
const PG_URL = process.env.PG_VECTOR_TEST_URL;
const HOOK_TIMEOUT_MS = 60_000;
const fixture = loadBulkFixture();
describe.skipIf(!PG_URL)('PgVectorStore — bulk fixture corpus', () => {
const tableName = `vs_bulk_${Date.now()}_${Math.floor(Math.random() * 1e6)}`;
let pool: Pool;
let store: PgVectorStore;
beforeAll(async () => {
const { Pool: PoolCtor } = await import('pg');
pool = new PoolCtor({ connectionString: PG_URL });
await createPgVectorTable(pool, tableName, { dimensions: fixture.dimensions });
store = new PgVectorStore('bulk-pg', { connectionString: PG_URL!, tableName });
await upsertFixtureDocuments(store, fixture);
}, HOOK_TIMEOUT_MS);
afterAll(async () => {
await pool.query(`DROP TABLE IF EXISTS "${tableName}";`);
await store.close();
await pool.end();
});
registerBulkVectorStoreTests(fixture, () => store);
});
@@ -0,0 +1,56 @@
/**
* Bulk retrieval-quality suite run against QdrantVectorStore using a
* committed 30-document fixture with precomputed embeddings (see
* fixtures/generate-bulk-vector-fixture.ts). No OpenAI calls at test time —
* both document and query vectors are precomputed, so "ground truth" is
* computed locally via cosine similarity and compared against what the
* backend actually returns.
*/
import type { QdrantClient } from '@qdrant/js-client-rest';
import { afterAll, beforeAll, describe } from 'vitest';
import {
loadBulkFixture,
registerBulkVectorStoreTests,
upsertFixtureDocuments,
} from './vector-store-helpers';
import { QdrantVectorStore } from '../../vector-stores/qdrant';
const QDRANT_URL = process.env.QDRANT_TEST_URL;
const QDRANT_API_KEY = process.env.QDRANT_TEST_API_KEY;
const HOOK_TIMEOUT_MS = 60_000;
const fixture = loadBulkFixture();
describe.skipIf(!QDRANT_URL)('QdrantVectorStore — bulk fixture corpus', () => {
const collectionName = `vs_bulk_${Date.now()}_${Math.floor(Math.random() * 1e6)}`;
let adminClient: QdrantClient;
let store: QdrantVectorStore;
beforeAll(async () => {
const { QdrantClient: QdrantClientCtor } = await import('@qdrant/js-client-rest');
adminClient = new QdrantClientCtor({ url: QDRANT_URL, apiKey: QDRANT_API_KEY });
await adminClient.createCollection(collectionName, {
vectors: { size: fixture.dimensions, distance: 'Cosine' },
});
// Qdrant Cloud requires a payload index to filter on a field at all.
await adminClient.createPayloadIndex(collectionName, {
field_name: 'metadata.category',
field_schema: 'keyword',
});
store = new QdrantVectorStore('bulk-qdrant', {
url: QDRANT_URL!,
apiKey: QDRANT_API_KEY,
collectionName,
});
await upsertFixtureDocuments(store, fixture);
}, HOOK_TIMEOUT_MS);
afterAll(async () => {
await adminClient.deleteCollection(collectionName);
store.close();
});
registerBulkVectorStoreTests(fixture, () => store);
});
@@ -0,0 +1,61 @@
/**
* Bulk retrieval-quality suite run against SupabaseVectorStore using a
* committed 30-document fixture with precomputed embeddings (see
* fixtures/generate-bulk-vector-fixture.ts). No OpenAI calls at test time —
* both document and query vectors are precomputed, so "ground truth" is
* computed locally via cosine similarity and compared against what the
* backend actually returns.
*/
import type { Pool } from 'pg';
import { afterAll, beforeAll, describe } from 'vitest';
import {
createSupabaseVectorTableAndFunction,
dropSupabaseVectorTableAndFunction,
loadBulkFixture,
registerBulkVectorStoreTests,
upsertFixtureDocuments,
waitUntilQueryable,
} from './vector-store-helpers';
import { SupabaseVectorStore } from '../../vector-stores/supabase';
const SUPABASE_URL = process.env.SUPABASE_TEST_URL;
const SUPABASE_API_KEY = process.env.SUPABASE_TEST_API_KEY;
const SUPABASE_DB_URL = process.env.SUPABASE_TEST_DB_URL;
const HOOK_TIMEOUT_MS = 60_000;
const fixture = loadBulkFixture();
describe.skipIf(!SUPABASE_URL || !SUPABASE_API_KEY || !SUPABASE_DB_URL)(
'SupabaseVectorStore — bulk fixture corpus',
() => {
const suffix = `${Date.now()}_${Math.floor(Math.random() * 1e6)}`;
const tableName = `vs_bulk_${suffix}`;
const queryName = `match_vs_bulk_${suffix}`;
let pool: Pool;
let store: SupabaseVectorStore;
beforeAll(async () => {
const { Pool: PoolCtor } = await import('pg');
pool = new PoolCtor({ connectionString: SUPABASE_DB_URL });
await createSupabaseVectorTableAndFunction(pool, tableName, queryName, fixture.dimensions);
store = new SupabaseVectorStore('bulk-supabase', {
url: SUPABASE_URL!,
apiKey: SUPABASE_API_KEY!,
tableName,
queryName,
});
await waitUntilQueryable(store, fixture.dimensions);
await upsertFixtureDocuments(store, fixture);
}, HOOK_TIMEOUT_MS);
afterAll(async () => {
await dropSupabaseVectorTableAndFunction(pool, tableName, queryName);
store.close();
await pool.end();
});
registerBulkVectorStoreTests(fixture, () => store);
},
);
@@ -0,0 +1,341 @@
/**
* Shared helpers for the vector-store integration suites (Postgres, Qdrant,
* Supabase, Pinecone) — fixture loading, locally computed ground truth,
* Postgres/Supabase DDL setup, Pinecone index provisioning, and shared
* test-body registration, deduplicated from the per-backend test files.
*/
import type { Index, Pinecone } from '@pinecone-database/pinecone';
import { readFileSync } from 'node:fs';
import path from 'node:path';
import type { Pool } from 'pg';
import { expect, it } from 'vitest';
import { findLastTextContent } from './helpers';
import { Agent } from '../../index';
import type { VectorStore } from '../../index';
import type { BaseVectorStore } from '../../storage/base-vector-store';
import type { SupabaseVectorStore } from '../../vector-stores/supabase';
const SCHEMA_WAIT_TIMEOUT_MS = 15_000;
export interface FixtureDocument {
id: string;
content: string;
metadata: { title: string; category: string };
vector: number[];
}
export interface FixtureQuery {
text: string;
vector: number[];
expectedTopId: string;
}
export interface BulkFixture {
dimensions: number;
documents: FixtureDocument[];
queries: FixtureQuery[];
}
// vitest injects __dirname for TypeScript test files in the node environment.
export function loadBulkFixture(): BulkFixture {
return JSON.parse(
readFileSync(path.resolve(__dirname, '../fixtures/bulk-vector-fixture.json'), 'utf-8'),
) as BulkFixture;
}
export function cosineSimilarity(a: number[], b: number[]): number {
let dot = 0;
let magA = 0;
let magB = 0;
for (let i = 0; i < a.length; i++) {
dot += a[i] * b[i];
magA += a[i] * a[i];
magB += b[i] * b[i];
}
return dot / (Math.sqrt(magA) * Math.sqrt(magB));
}
/** Locally computed ground truth — independent of any backend's search semantics. */
export function localTopIds(
docs: FixtureDocument[],
queryVector: number[],
topK: number,
): string[] {
return [...docs]
.map((doc) => ({ id: doc.id, score: cosineSimilarity(queryVector, doc.vector) }))
.sort((a, b) => b.score - a.score)
.slice(0, topK)
.map((doc) => doc.id);
}
/** Stands in for the BYO user's own setup; dimensions match the embedding model in use. */
export async function createPgVectorTable(
pool: Pool,
tableName: string,
opts: { dimensions: number; hnswIndex?: boolean; ginIndex?: boolean },
): Promise<void> {
await pool.query('CREATE EXTENSION IF NOT EXISTS vector;');
await pool.query(
`CREATE TABLE IF NOT EXISTS "${tableName}" (
id TEXT PRIMARY KEY,
content TEXT NOT NULL,
metadata JSONB NOT NULL DEFAULT '{}',
embedding vector(${opts.dimensions}) NOT NULL
);`,
);
if (opts.hnswIndex) {
await pool.query(
`CREATE INDEX IF NOT EXISTS "${tableName}_embedding_idx" ON "${tableName}" USING hnsw (embedding vector_cosine_ops);`,
);
}
if (opts.ginIndex) {
await pool.query(
`CREATE INDEX IF NOT EXISTS "${tableName}_metadata_idx" ON "${tableName}" USING gin (metadata jsonb_path_ops);`,
);
}
}
/** Stands in for the BYO user's own setup, since SupabaseVectorStore itself never runs DDL. */
export async function createSupabaseVectorTableAndFunction(
pool: Pool,
tableName: string,
queryName: string,
dimensions: number,
): Promise<void> {
await createPgVectorTable(pool, tableName, { dimensions });
await pool.query(
`CREATE OR REPLACE FUNCTION "${queryName}"(query_embedding vector(${dimensions}))
RETURNS TABLE (id text, content text, metadata jsonb, similarity float)
LANGUAGE sql STABLE AS $$
SELECT id, content, metadata, 1 - (embedding <=> query_embedding) AS similarity
FROM "${tableName}"
ORDER BY embedding <=> query_embedding;
$$;`,
);
// PostgREST caches the schema; new tables/functions aren't servable until it reloads.
await pool.query("NOTIFY pgrst, 'reload schema';");
}
export async function dropSupabaseVectorTableAndFunction(
pool: Pool,
tableName: string,
queryName: string,
): Promise<void> {
await pool.query(`DROP FUNCTION IF EXISTS "${queryName}"(vector);`);
await pool.query(`DROP TABLE IF EXISTS "${tableName}";`);
}
/** PostgREST's schema cache reloads asynchronously after DDL — poll until the new function is servable. */
export async function waitUntilQueryable(
store: SupabaseVectorStore,
dimensions: number,
): Promise<void> {
const zeroVector = new Array<number>(dimensions).fill(0);
const deadline = Date.now() + SCHEMA_WAIT_TIMEOUT_MS;
for (;;) {
try {
await store.query(zeroVector, { topK: 1 });
return;
} catch (error) {
if (Date.now() > deadline) throw error;
await new Promise((resolve) => setTimeout(resolve, 500));
}
}
}
/** Stands in for the BYO user's own setup, since PineconeVectorStore itself never creates indexes. */
export async function createPineconeIndex(
pc: Pinecone,
name: string,
dimensions: number,
): Promise<void> {
await pc.createIndex({
name,
dimension: dimensions,
metric: 'cosine',
spec: { serverless: { cloud: 'aws', region: 'us-east-1' } },
waitUntilReady: true,
});
}
/** Pinecone writes are eventually consistent — poll index stats until the expected record count is visible. */
export async function waitForPineconeRecordCount(index: Index, expected: number): Promise<void> {
const deadline = Date.now() + SCHEMA_WAIT_TIMEOUT_MS * 4;
for (;;) {
const stats = await index.describeIndexStats();
if (stats.totalRecordCount === expected) return;
if (Date.now() > deadline) {
throw new Error(
`Timed out waiting for Pinecone record count to reach ${expected} (last seen: ${stats.totalRecordCount ?? 0}).`,
);
}
await new Promise((resolve) => setTimeout(resolve, 1000));
}
}
export async function upsertFixtureDocuments(
store: BaseVectorStore,
fixture: BulkFixture,
): Promise<void> {
await store.upsert(
fixture.documents.map((doc) => ({
id: doc.id,
vector: doc.vector,
content: doc.content,
metadata: doc.metadata,
})),
);
}
/**
* Registers the bulk retrieval-quality `it` blocks shared by all four
* backends' `vector-store-bulk-*.test.ts` suites. Must be called inside the
* suite's `describe` body, after `getStore` is guaranteed to resolve (i.e.
* after `beforeAll` populates the store). Order matters: the delete test
* mutates the corpus and must run last.
*/
export function registerBulkVectorStoreTests(
fixture: BulkFixture,
getStore: () => BaseVectorStore,
opts?: { afterDelete?: (remainingCount: number) => Promise<void> },
): void {
it('returns the expected best match for each known-answer query', async () => {
const store = getStore();
for (const query of fixture.queries) {
const results = await store.query(query.vector, { topK: 5 });
expect(results).toHaveLength(5);
expect(results[0].id).toBe(query.expectedTopId);
expect(results[0].content.length).toBeGreaterThan(0);
expect(typeof results[0].metadata.title).toBe('string');
for (let i = 1; i < results.length; i++) {
expect(results[i].score).toBeLessThanOrEqual(results[i - 1].score);
}
}
});
it('filters by category over the large corpus and matches locally computed ground truth', async () => {
const store = getStore();
const scienceDocs = fixture.documents.filter((doc) => doc.metadata.category === 'science');
const expectedIds = localTopIds(scienceDocs, fixture.queries[0].vector, 10);
const results = await store.query(fixture.queries[0].vector, {
topK: 10,
filter: { conditions: [{ key: 'category', operator: 'eq', value: 'science' }] },
});
expect(results.map((r) => r.id)).toEqual(expectedIds);
for (const result of results) expect(result.metadata.category).toBe('science');
});
it('nin filter excludes categories across the corpus', async () => {
const store = getStore();
const geographyDocs = fixture.documents.filter((doc) => doc.metadata.category === 'geography');
const expectedIds = localTopIds(geographyDocs, fixture.queries[0].vector, 10);
const results = await store.query(fixture.queries[0].vector, {
topK: 10,
filter: { conditions: [{ key: 'category', operator: 'nin', value: ['history', 'science'] }] },
});
expect(results.map((r) => r.id)).toEqual(expectedIds);
for (const result of results) expect(result.metadata.category).toBe('geography');
});
it('deletes a batch and stops returning the deleted documents', async () => {
const store = getStore();
const idsToDelete = localTopIds(fixture.documents, fixture.queries[1].vector, 20);
expect(idsToDelete).toContain(fixture.queries[1].expectedTopId);
await store.delete({ ids: idsToDelete });
await opts?.afterDelete?.(fixture.documents.length - idsToDelete.length);
const remaining = fixture.documents.filter((doc) => !idsToDelete.includes(doc.id));
const expectedTopIds = localTopIds(remaining, fixture.queries[1].vector, 5);
const results = await store.query(fixture.queries[1].vector, { topK: 5 });
const resultIds = results.map((r) => r.id);
expect(resultIds).toEqual(expectedTopIds);
for (const deletedId of idsToDelete) expect(resultIds).not.toContain(deletedId);
});
}
/**
* Registers the agent end-to-end `it` blocks shared by all four backends'
* `agent-vector-store-*.test.ts` suites. Must be called inside the suite's
* `describe` body, after `getKnowledge` is guaranteed to resolve.
*/
export function registerAgentVectorStoreTests(getKnowledge: () => VectorStore): void {
it('answers a question using knowledge retrieved from the backend', async () => {
const agent = new Agent('kb-assistant')
.model('anthropic/claude-haiku-4-5')
.instructions(
'You answer questions from the knowledge base. Always search it before answering. Be concise.',
)
.vectorStore(getKnowledge());
const result = await agent.generate(
'What is the tallest federal building in Manhattan, and how many stories does it have?',
);
const searchCalls = (result.toolCalls ?? []).filter(
(tc) => tc.tool === 'search_knowledge_base',
);
expect(searchCalls.length).toBeGreaterThanOrEqual(1);
const searchOutput = searchCalls[0].output as {
results: Array<{ content: string; score: number; metadata: { title?: string } }>;
};
expect(searchOutput.results.length).toBeGreaterThanOrEqual(1);
expect(searchOutput.results[0].metadata.title).toBe('Jacob K. Javits Federal Building');
const answer = findLastTextContent(result.messages);
expect(answer).toMatch(/41/);
});
it('narrows results with a model-controlled metadata filter', async () => {
const agent = new Agent('kb-assistant-filtered')
.model('anthropic/claude-haiku-4-5')
.instructions(
'You answer questions from the knowledge base. Always search it before answering. ' +
'Always pass a filter on the category key when the user names a specific category.',
)
.vectorStore(getKnowledge(), {
filterableKeys: {
category: "Document category, exactly one of: 'history', 'science', 'geography'",
},
});
const result = await agent.generate(
'Search only the geography category: what places are described?',
);
const searchCalls = (result.toolCalls ?? []).filter(
(tc) => tc.tool === 'search_knowledge_base',
);
expect(searchCalls.length).toBeGreaterThanOrEqual(1);
const input = searchCalls[0].input as {
filter?: Array<{ key: string; operator: string; value: unknown }>;
};
const categoryCondition = input.filter?.find((c) => c.key === 'category');
expect(categoryCondition).toBeDefined();
if (categoryCondition?.operator === 'in') {
expect(categoryCondition.value).toContain('geography');
} else {
expect(categoryCondition?.value).toBe('geography');
}
const searchOutput = searchCalls[0].output as {
results: Array<{ metadata: { category?: string } }>;
};
for (const searchResult of searchOutput.results) {
expect(searchResult.metadata.category).toBe('geography');
}
const answer = findLastTextContent(result.messages);
expect(answer).toBeTruthy();
});
}
@@ -0,0 +1,282 @@
/**
* Unit coverage for the metadata-filter translation and metadata-shape
* validation in `PineconeVectorStore` — logic that never runs in CI because
* the integration suites self-skip without a real Pinecone project.
*
* Exercises `query()`/`upsert()` through the public API with a stubbed
* `Index` (assigned directly to the private `index` field) so no network
* calls or `@pinecone-database/pinecone` import happen.
*/
import { describe, expect, it, vi } from 'vitest';
import { PineconeVectorStore } from '../vector-stores/pinecone';
function createStub(queryResult: { matches: unknown[] }) {
const query = vi.fn().mockResolvedValue(queryResult);
const upsert = vi.fn().mockResolvedValue(undefined);
return { query, upsert };
}
function createStore(): { store: PineconeVectorStore; stub: ReturnType<typeof createStub> } {
const store = new PineconeVectorStore('test-store', {
apiKey: 'test-key',
indexName: 'docs',
});
const stub = createStub({ matches: [] });
(store as unknown as { index: unknown }).index = stub;
return { store, stub };
}
describe('PineconeVectorStore filter translation', () => {
it('queries with topK and includeMetadata, with no filter key when absent', async () => {
const { store, stub } = createStore();
await store.query([1, 2, 3], { topK: 4 });
expect(stub.query).toHaveBeenCalledWith({
vector: [1, 2, 3],
topK: 4,
includeMetadata: true,
});
});
it('omits the filter key when the filter has no conditions', async () => {
const { store, stub } = createStore();
await store.query([0], { topK: 1, filter: { conditions: [] } });
expect(stub.query).toHaveBeenCalledWith({ vector: [0], topK: 1, includeMetadata: true });
});
it('translates eq to an $eq term wrapped in $and', async () => {
const { store, stub } = createStore();
await store.query([0], {
topK: 1,
filter: { conditions: [{ key: 'topic', operator: 'eq', value: 'billing' }] },
});
expect(stub.query).toHaveBeenCalledWith(
expect.objectContaining({ filter: { $and: [{ topic: { $eq: 'billing' } }] } }),
);
});
it('translates ne to an $or with an $exists:false arm', async () => {
const { store, stub } = createStore();
await store.query([0], {
topK: 1,
filter: { conditions: [{ key: 'topic', operator: 'ne', value: 'billing' }] },
});
expect(stub.query).toHaveBeenCalledWith(
expect.objectContaining({
filter: {
$and: [{ $or: [{ topic: { $ne: 'billing' } }, { topic: { $exists: false } }] }],
},
}),
);
});
it('translates in to an $in term', async () => {
const { store, stub } = createStore();
await store.query([0], {
topK: 1,
filter: { conditions: [{ key: 'topic', operator: 'in', value: ['billing', 'ops'] }] },
});
expect(stub.query).toHaveBeenCalledWith(
expect.objectContaining({ filter: { $and: [{ topic: { $in: ['billing', 'ops'] } }] } }),
);
});
it('translates nin to an $or with an $exists:false arm', async () => {
const { store, stub } = createStore();
await store.query([0], {
topK: 1,
filter: { conditions: [{ key: 'topic', operator: 'nin', value: ['billing', 'ops'] }] },
});
expect(stub.query).toHaveBeenCalledWith(
expect.objectContaining({
filter: {
$and: [
{
$or: [{ topic: { $nin: ['billing', 'ops'] } }, { topic: { $exists: false } }],
},
],
},
}),
);
});
it('combines multiple conditions under $and, in condition order', async () => {
const { store, stub } = createStore();
await store.query([0], {
topK: 1,
filter: {
conditions: [
{ key: 'topic', operator: 'eq', value: 'billing' },
{ key: 'active', operator: 'eq', value: true },
],
combineWith: 'and',
},
});
expect(stub.query).toHaveBeenCalledWith(
expect.objectContaining({
filter: { $and: [{ topic: { $eq: 'billing' } }, { active: { $eq: true } }] },
}),
);
});
it('combines multiple conditions under $or', async () => {
const { store, stub } = createStore();
await store.query([0], {
topK: 1,
filter: {
conditions: [
{ key: 'topic', operator: 'eq', value: 'billing' },
{ key: 'topic', operator: 'eq', value: 'ops' },
],
combineWith: 'or',
},
});
expect(stub.query).toHaveBeenCalledWith(
expect.objectContaining({
filter: { $or: [{ topic: { $eq: 'billing' } }, { topic: { $eq: 'ops' } }] },
}),
);
});
it('rejects an empty array for in without calling query', async () => {
const { store, stub } = createStore();
await expect(
store.query([0], {
topK: 1,
filter: { conditions: [{ key: 'topic', operator: 'in', value: [] }] },
}),
).rejects.toThrow(/requires a non-empty array value/);
expect(stub.query).not.toHaveBeenCalled();
});
it('rejects an empty array for nin without calling query', async () => {
const { store, stub } = createStore();
await expect(
store.query([0], {
topK: 1,
filter: { conditions: [{ key: 'topic', operator: 'nin', value: [] }] },
}),
).rejects.toThrow(/requires a non-empty array value/);
expect(stub.query).not.toHaveBeenCalled();
});
it('maps matches to query results, stripping _content into content', async () => {
const { store, stub } = createStore();
stub.query.mockResolvedValue({
matches: [{ id: 7, score: 0.5, metadata: { _content: 'hello', topic: 'billing' } }],
});
const results = await store.query([0], { topK: 1 });
expect(results).toEqual([
{ id: '7', content: 'hello', metadata: { topic: 'billing' }, score: 0.5 },
]);
});
it('defaults content to an empty string and score to 0 when absent', async () => {
const { store, stub } = createStore();
stub.query.mockResolvedValue({ matches: [{ id: 'a', metadata: {} }] });
const results = await store.query([0], { topK: 1 });
expect(results).toEqual([{ id: 'a', content: '', metadata: {}, score: 0 }]);
});
it('upserts records with content stored under the reserved _content key', async () => {
const { store, stub } = createStore();
await store.upsert([
{ id: '1', vector: [1, 0], content: 'hello', metadata: { topic: 'billing' } },
]);
expect(stub.upsert).toHaveBeenCalledWith([
{ id: '1', values: [1, 0], metadata: { _content: 'hello', topic: 'billing' } },
]);
});
it('rejects upserting metadata that uses the reserved _content key', async () => {
const { store } = createStore();
await expect(
store.upsert([{ id: '1', vector: [0], content: 'hi', metadata: { _content: 'x' } }]),
).rejects.toThrow(/reserved for the document content/);
});
it('rejects upserting a nested-object metadata value', async () => {
const { store } = createStore();
await expect(
store.upsert([{ id: '1', vector: [0], content: 'hi', metadata: { nested: { a: 1 } } }]),
).rejects.toThrow(/unsupported/);
});
it('rejects upserting a mixed-type array metadata value', async () => {
const { store } = createStore();
await expect(
store.upsert([{ id: '1', vector: [0], content: 'hi', metadata: { tags: ['a', 1] } }]),
).rejects.toThrow(/unsupported/);
});
it('accepts a string-array metadata value', async () => {
const { store, stub } = createStore();
await store.upsert([{ id: '1', vector: [0], content: 'hi', metadata: { tags: ['a', 'b'] } }]);
expect(stub.upsert).toHaveBeenCalledWith([
{ id: '1', values: [0], metadata: { _content: 'hi', tags: ['a', 'b'] } },
]);
});
it('splits large upserts into sequential batches of 200', async () => {
const { store, stub } = createStore();
const records = Array.from({ length: 401 }, (_, i) => ({
id: String(i),
vector: [0],
content: 'c',
metadata: {},
}));
await store.upsert(records);
expect(stub.upsert).toHaveBeenCalledTimes(3);
const calls = stub.upsert.mock.calls as Array<[Array<{ id: string }>]>;
expect(calls[0][0]).toHaveLength(200);
expect(calls[1][0]).toHaveLength(200);
expect(calls[2][0]).toHaveLength(1);
expect(calls[0][0][0].id).toBe('0');
expect(calls[1][0][0].id).toBe('200');
expect(calls[2][0][0].id).toBe('400');
});
it('rejects invalid metadata without sending any batch', async () => {
const { store, stub } = createStore();
const records = Array.from({ length: 201 }, (_, i) => ({
id: String(i),
vector: [0],
content: 'c',
metadata: i === 200 ? { nested: { a: 1 } } : {},
}));
await expect(store.upsert(records)).rejects.toThrow(/unsupported/);
expect(stub.upsert).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,55 @@
/**
* Unit coverage for the point-id validation in `QdrantVectorStore` — pure
* string-matching logic that never runs in CI because the integration suite
* self-skips without a real Qdrant instance (gated on `QDRANT_TEST_URL`).
*
* Exercises `upsert()`/`delete()` through the public API with a stubbed
* client (assigned directly to the private `client` field) so no network
* calls or `@qdrant/js-client-rest` connection happen.
*/
import { describe, expect, it, vi } from 'vitest';
import { QdrantVectorStore } from '../vector-stores/qdrant';
function createStore(): { store: QdrantVectorStore; upsert: ReturnType<typeof vi.fn> } {
const store = new QdrantVectorStore('test-store', {
url: 'http://localhost:6333',
collectionName: 'docs',
});
const upsert = vi.fn().mockResolvedValue(undefined);
(store as unknown as { client: unknown }).client = { upsert };
return { store, upsert };
}
describe('QdrantVectorStore point id validation', () => {
it.each([
['hyphenated', '550e8400-e29b-41d4-a716-446655440000'],
['simple (no hyphens)', '936da01f9abd4d9d80c702af85c822a8'],
['urn-prefixed', 'urn:uuid:550e8400-e29b-41d4-a716-446655440000'],
['braced', '{550e8400-e29b-41d4-a716-446655440000}'],
['uppercase hyphenated', '550E8400-E29B-41D4-A716-446655440000'],
['canonical unsigned integer', '42'],
])('accepts a %s id', async (_label, id) => {
const { store, upsert } = createStore();
await store.upsert([{ id, vector: [1, 0, 0], content: 'c', metadata: {} }]);
expect(upsert).toHaveBeenCalledWith('docs', {
wait: true,
points: [
{ id: id === '42' ? 42 : id, vector: [1, 0, 0], payload: { content: 'c', metadata: {} } },
],
});
});
it.each([
['non-canonical numeric id', '007'],
['arbitrary non-UUID string', 'not-a-uuid'],
])('rejects a %s', async (_label, id) => {
const { store } = createStore();
await expect(
store.upsert([{ id, vector: [1, 0, 0], content: 'c', metadata: {} }]),
).rejects.toThrow(/Qdrant requires ids to be a UUID or a canonical unsigned integer/);
});
});
@@ -0,0 +1,253 @@
/**
* Unit coverage for the PostgREST filter-string translation in
* `SupabaseVectorStore` — string-building code (quoting, nested
* `or(...)`/`and(...)` groups) that never runs in CI because the
* integration suites self-skip without a real Supabase project.
*
* Exercises `query()` through the public API with a stubbed PostgREST
* filter chain (assigned directly to the private `client` field) so no
* network calls or `@supabase/supabase-js` import happen.
*/
import { describe, expect, it, vi } from 'vitest';
import { SupabaseVectorStore } from '../vector-stores/supabase';
type ChainCall = { method: 'filter' | 'not' | 'or'; args: unknown[] };
function createStub(result: { data: unknown[] | null; error: { message: string } | null }) {
const calls: ChainCall[] = [];
let limitArg: number | undefined;
const chain = {
filter: (...args: unknown[]) => {
calls.push({ method: 'filter', args });
return chain;
},
not: (...args: unknown[]) => {
calls.push({ method: 'not', args });
return chain;
},
or: (...args: unknown[]) => {
calls.push({ method: 'or', args });
return chain;
},
// eslint-disable-next-line @typescript-eslint/promise-function-async -- no await needed; matches `require-await`
limit: (n: number) => {
limitArg = n;
return Promise.resolve(result);
},
};
const rpc = vi.fn(() => chain);
return { client: { rpc }, calls, rpc, getLimitArg: () => limitArg };
}
function createStore(queryName?: string): {
store: SupabaseVectorStore;
stub: ReturnType<typeof createStub>;
} {
const store = new SupabaseVectorStore('test-store', {
url: 'http://localhost',
apiKey: 'test-key',
tableName: 'docs',
...(queryName ? { queryName } : {}),
});
const stub = createStub({ data: [], error: null });
(store as unknown as { client: unknown }).client = stub.client;
return { store, stub };
}
const containment = (key: string, value: unknown): string => JSON.stringify({ [key]: value });
const quoteOr = (value: string): string =>
`"${value.replaceAll('\\', '\\\\').replaceAll('"', '\\"')}"`;
describe('SupabaseVectorStore filter translation', () => {
it('invokes rpc with the vector and applies limit, with no filter calls', async () => {
const { store, stub } = createStore();
await store.query([1, 2, 3], { topK: 4 });
expect(stub.rpc).toHaveBeenCalledTimes(1);
expect(stub.rpc).toHaveBeenCalledWith('match_documents', { query_embedding: [1, 2, 3] });
expect(stub.calls).toEqual([]);
expect(stub.getLimitArg()).toBe(4);
});
it('uses a custom queryName when provided', async () => {
const { store, stub } = createStore('custom_fn');
await store.query([1, 0, 0], { topK: 1 });
expect(stub.rpc).toHaveBeenCalledWith('custom_fn', { query_embedding: [1, 0, 0] });
});
it('translates eq to a single filter call', async () => {
const { store, stub } = createStore();
await store.query([0], {
topK: 1,
filter: { conditions: [{ key: 'topic', operator: 'eq', value: 'billing' }] },
});
expect(stub.calls).toEqual([
{ method: 'filter', args: ['metadata', 'cs', containment('topic', 'billing')] },
]);
});
it('translates ne to a single not call', async () => {
const { store, stub } = createStore();
await store.query([0], {
topK: 1,
filter: { conditions: [{ key: 'topic', operator: 'ne', value: 'billing' }] },
});
expect(stub.calls).toEqual([
{ method: 'not', args: ['metadata', 'cs', containment('topic', 'billing')] },
]);
});
it('translates in (AND path) to a single or call', async () => {
const { store, stub } = createStore();
await store.query([0], {
topK: 1,
filter: { conditions: [{ key: 'topic', operator: 'in', value: ['billing', 'ops'] }] },
});
const expected = [
`metadata.cs.${quoteOr(containment('topic', 'billing'))}`,
`metadata.cs.${quoteOr(containment('topic', 'ops'))}`,
].join(',');
expect(stub.calls).toEqual([{ method: 'or', args: [expected] }]);
});
it('translates nin (AND path) to one not call per value', async () => {
const { store, stub } = createStore();
await store.query([0], {
topK: 1,
filter: { conditions: [{ key: 'topic', operator: 'nin', value: ['billing', 'ops'] }] },
});
expect(stub.calls).toEqual([
{ method: 'not', args: ['metadata', 'cs', containment('topic', 'billing')] },
{ method: 'not', args: ['metadata', 'cs', containment('topic', 'ops')] },
]);
});
it('combines in with another condition under AND, in condition order', async () => {
const { store, stub } = createStore();
await store.query([0], {
topK: 1,
filter: {
conditions: [
{ key: 'topic', operator: 'in', value: ['billing', 'ops'] },
{ key: 'active', operator: 'eq', value: true },
],
combineWith: 'and',
},
});
const orExpected = [
`metadata.cs.${quoteOr(containment('topic', 'billing'))}`,
`metadata.cs.${quoteOr(containment('topic', 'ops'))}`,
].join(',');
expect(stub.calls).toEqual([
{ method: 'or', args: [orExpected] },
{ method: 'filter', args: ['metadata', 'cs', containment('active', true)] },
]);
});
it('builds a single or call for a combineWith "or" group with all operators', async () => {
const { store, stub } = createStore();
await store.query([0], {
topK: 1,
filter: {
conditions: [
{ key: 'topic', operator: 'eq', value: 'billing' },
{ key: 'count', operator: 'ne', value: 5 },
{ key: 'category', operator: 'in', value: ['a', 'b'] },
{ key: 'category', operator: 'nin', value: ['c'] },
],
combineWith: 'or',
},
});
const expected = [
`metadata.cs.${quoteOr(containment('topic', 'billing'))}`,
`metadata.not.cs.${quoteOr(containment('count', 5))}`,
`or(metadata.cs.${quoteOr(containment('category', 'a'))},metadata.cs.${quoteOr(containment('category', 'b'))})`,
`and(metadata.not.cs.${quoteOr(containment('category', 'c'))})`,
].join(',');
expect(stub.calls).toEqual([{ method: 'or', args: [expected] }]);
});
it('escapes quotes and backslashes in OR-combined logic terms', async () => {
const { store, stub } = createStore();
await store.query([0], {
topK: 1,
filter: {
conditions: [{ key: 'k', operator: 'eq', value: 'a"b\\c' }],
combineWith: 'or',
},
});
const expected = `metadata.cs.${quoteOr(containment('k', 'a"b\\c'))}`;
expect(stub.calls).toEqual([{ method: 'or', args: [expected] }]);
});
it('rejects an empty array for in without calling the filter chain', async () => {
const { store, stub } = createStore();
await expect(
store.query([0], {
topK: 1,
filter: { conditions: [{ key: 'topic', operator: 'in', value: [] }] },
}),
).rejects.toThrow(/requires a non-empty array value/);
expect(stub.calls).toEqual([]);
});
it('rejects an empty array for nin without calling the filter chain', async () => {
const { store, stub } = createStore();
await expect(
store.query([0], {
topK: 1,
filter: { conditions: [{ key: 'topic', operator: 'nin', value: [] }] },
}),
).rejects.toThrow(/requires a non-empty array value/);
expect(stub.calls).toEqual([]);
});
it('wraps a PostgREST error with a descriptive message', async () => {
const store = new SupabaseVectorStore('test-store', {
url: 'http://localhost',
apiKey: 'test-key',
tableName: 'docs',
});
const stub = createStub({ data: null, error: { message: 'boom' } });
(store as unknown as { client: unknown }).client = stub.client;
await expect(store.query([0], { topK: 1 })).rejects.toThrow('Supabase query failed: boom');
});
it('maps rows to query results, coercing id to a string and defaulting metadata', async () => {
const store = new SupabaseVectorStore('test-store', {
url: 'http://localhost',
apiKey: 'test-key',
tableName: 'docs',
});
const stub = createStub({
data: [{ id: 7, content: 'c', metadata: null, similarity: 0.5 }],
error: null,
});
(store as unknown as { client: unknown }).client = stub.client;
const results = await store.query([0], { topK: 1 });
expect(results).toEqual([{ id: '7', content: 'c', metadata: {}, score: 0.5 }]);
});
});
+21 -2
View File
@@ -105,8 +105,16 @@ export {
export { createCancellation, isCancellation, CANCELLATION_TYPE } from './sdk/cancellation';
export type { Cancellation } from './sdk/cancellation';
export { Tool, wrapToolForApproval } from './sdk/tool';
export { Tool, wrapToolForApproval, sanitizeToolName } from './sdk/tool';
export { Memory } from './sdk/memory';
export { VectorStore } from './sdk/vector-store';
export {
FILTER_OPERATORS,
normalizeFilterInput,
assertValidFilter,
buildFilterInputSchema,
} from './sdk/vector-store-filter';
export type { VectorFilterInput } from './sdk/vector-store-filter';
export { Guardrail } from './sdk/guardrail';
export {
redactText,
@@ -217,10 +225,21 @@ export type {
ModelLimits,
} from './sdk/catalog';
export { BaseMemory } from './storage/base-memory';
export { BaseVectorStore } from './storage/base-vector-store';
export type { ToolDescriptor } from './types/sdk/tool-descriptor';
export type {
BuiltVectorStoreBackend,
VectorDocument,
VectorRecord,
VectorQueryResult,
FilterOperator,
FilterValue,
FilterCondition,
VectorFilter,
} from './types';
export { createModel } from './runtime/model/model-factory';
export type { FetchFn } from './runtime/model/model-factory';
export type { FetchFn, EmbeddingProviderOptions } from './runtime/model/model-factory';
export {
DEFAULT_SUB_AGENT_MAX_CHILDREN,
ROOT_SUB_AGENT_TASK_PATH,
@@ -15,7 +15,7 @@ import type { ModelConfig } from '../../types/sdk/agent';
* model calls route through the configured HTTP(S)_PROXY.
*/
export type FetchFn = typeof globalThis.fetch;
type EmbeddingProviderOptions = {
export type EmbeddingProviderOptions = {
apiKey?: string;
baseURL?: string;
fetch?: FetchFn;
@@ -0,0 +1,435 @@
import { MockEmbeddingModelV3 } from 'ai/test';
import type { BuiltVectorStoreBackend, VectorFilter, VectorQueryResult } from '../../types';
import { isZodSchema } from '../../utils/zod';
import { Agent } from '../agent';
import { sanitizeToolName } from '../tool';
import { VectorStore } from '../vector-store';
import { assertValidFilter, normalizeFilterInput } from '../vector-store-filter';
function makeEmbeddingModel(vector: number[] = [1, 0, 0]) {
return new MockEmbeddingModelV3({
doEmbed: async ({ values }) =>
await Promise.resolve({
embeddings: values.map(() => vector),
warnings: [],
}),
});
}
function makeBackend(overrides: Partial<BuiltVectorStoreBackend> = {}): BuiltVectorStoreBackend {
return {
upsert: vi.fn().mockResolvedValue(undefined),
query: vi.fn().mockResolvedValue([]),
delete: vi.fn().mockResolvedValue(undefined),
...overrides,
};
}
describe('normalizeFilterInput', () => {
it('wraps plain object shorthand into an eq-conditions AND group', () => {
expect(normalizeFilterInput({ plan: 'cloud' })).toEqual({
conditions: [{ key: 'plan', operator: 'eq', value: 'cloud' }],
combineWith: 'and',
});
});
it('passes a VectorFilter through unchanged', () => {
const filter: VectorFilter = {
conditions: [{ key: 'plan', operator: 'eq', value: 'cloud' }],
combineWith: 'or',
};
expect(normalizeFilterInput(filter)).toBe(filter);
});
});
describe('assertValidFilter', () => {
it('throws when in has a non-array or empty-array value', () => {
expect(() =>
assertValidFilter({ conditions: [{ key: 'plan', operator: 'in', value: 'cloud' }] }),
).toThrow(/"in" on key "plan" requires a non-empty array value/);
expect(() =>
assertValidFilter({ conditions: [{ key: 'plan', operator: 'in', value: [] }] }),
).toThrow(/"in" on key "plan" requires a non-empty array value/);
});
it('throws when eq has an array value', () => {
expect(() =>
assertValidFilter({ conditions: [{ key: 'plan', operator: 'eq', value: ['cloud'] }] }),
).toThrow(/"eq" on key "plan" requires a string, number, or boolean value/);
});
it('throws when in/nin has a non-string/number array element', () => {
expect(() =>
assertValidFilter({
conditions: [{ key: 'plan', operator: 'in', value: [true as never] }],
}),
).toThrow(/"in" on key "plan" requires array elements to be strings or numbers/);
});
it('throws on an unknown (removed) operator', () => {
expect(() =>
assertValidFilter({
conditions: [{ key: 'plan', operator: 'text_match' as never, value: 'cloud' }],
}),
).toThrow(/Invalid filter operator "text_match"/);
});
});
describe('VectorStore — configuration validation', () => {
it('throws when .store() is not set', async () => {
const vectorStore = new VectorStore('kb').embeddingModel(makeEmbeddingModel());
await expect(vectorStore.search('hello')).rejects.toThrow(/requires a backend/);
});
it('throws when .embeddingModel() is not set', async () => {
const vectorStore = new VectorStore('kb').store(makeBackend());
await expect(vectorStore.search('hello')).rejects.toThrow(/requires an embedding model/);
});
});
describe('VectorStore — search()', () => {
it('embeds the query and calls backend.query with resolved topK', async () => {
const backend = makeBackend();
const vectorStore = new VectorStore('kb')
.store(backend)
.embeddingModel(makeEmbeddingModel([1, 2, 3]));
await vectorStore.search('hello');
expect(backend.query).toHaveBeenCalledWith([1, 2, 3], { topK: 4 });
await vectorStore.search('hello', { topK: 10 });
expect(backend.query).toHaveBeenLastCalledWith([1, 2, 3], { topK: 10 });
});
it('returns the results from the backend', async () => {
const results: VectorQueryResult[] = [{ id: '1', content: 'a', metadata: {}, score: 0.9 }];
const backend = makeBackend({ query: vi.fn().mockResolvedValue(results) });
const vectorStore = new VectorStore('kb').store(backend).embeddingModel(makeEmbeddingModel());
await expect(vectorStore.search('hello')).resolves.toEqual(results);
});
it('normalizes an object-shorthand per-call filter before reaching the backend', async () => {
const backend = makeBackend();
const vectorStore = new VectorStore('kb').store(backend).embeddingModel(makeEmbeddingModel());
await vectorStore.search('hello', { filter: { plan: 'cloud' } });
expect(backend.query).toHaveBeenCalledWith([1, 0, 0], {
topK: 4,
filter: { conditions: [{ key: 'plan', operator: 'eq', value: 'cloud' }], combineWith: 'and' },
});
});
it('omits filter when an empty object shorthand is passed', async () => {
const backend = makeBackend();
const vectorStore = new VectorStore('kb').store(backend).embeddingModel(makeEmbeddingModel());
await vectorStore.search('hello', { filter: {} });
expect(backend.query).toHaveBeenCalledWith([1, 0, 0], { topK: 4 });
});
it('rejects a non-integer or non-positive topK on the builder', () => {
const vectorStore = new VectorStore('kb');
expect(() => vectorStore.topK(0)).toThrow(/topK must be an integer >= 1/);
expect(() => vectorStore.topK(-5)).toThrow(/topK must be an integer >= 1/);
expect(() => vectorStore.topK(2.5)).toThrow(/topK must be an integer >= 1/);
expect(() => vectorStore.topK(NaN)).toThrow(/topK must be an integer >= 1/);
});
it('rejects an invalid per-call topK without touching the backend', async () => {
const backend = makeBackend();
const vectorStore = new VectorStore('kb').store(backend).embeddingModel(makeEmbeddingModel());
await expect(vectorStore.search('hello', { topK: 0 })).rejects.toThrow(
/topK must be an integer >= 1/,
);
expect(backend.query).not.toHaveBeenCalled();
});
});
describe('VectorStore — addDocuments()', () => {
it('returns [] and never touches the embedder or backend for an empty array', async () => {
const backend = makeBackend();
const embeddingModel = makeEmbeddingModel();
const vectorStore = new VectorStore('kb').store(backend).embeddingModel(embeddingModel);
const ids = await vectorStore.addDocuments([]);
expect(ids).toEqual([]);
expect(backend.upsert).not.toHaveBeenCalled();
});
it('generates an id when none is provided and preserves a given id', async () => {
const backend = makeBackend();
const vectorStore = new VectorStore('kb')
.store(backend)
.embeddingModel(makeEmbeddingModel([1, 0, 0]));
const ids = await vectorStore.addDocuments([
{ content: 'no id given' },
{ id: 'explicit-id', content: 'has an id' },
]);
expect(ids[1]).toBe('explicit-id');
expect(ids[0]).toEqual(expect.any(String));
expect(ids[0]).not.toBe('explicit-id');
});
it('upserts records with embeddings, provided metadata, and a default of {}', async () => {
const backend = makeBackend();
const vectorStore = new VectorStore('kb')
.store(backend)
.embeddingModel(makeEmbeddingModel([1, 0, 0]));
const ids = await vectorStore.addDocuments([
{ content: 'hello', metadata: { topic: 'a' } },
{ content: 'world' },
]);
expect(backend.upsert).toHaveBeenCalledWith([
{ id: ids[0], vector: [1, 0, 0], content: 'hello', metadata: { topic: 'a' } },
{ id: ids[1], vector: [1, 0, 0], content: 'world', metadata: {} },
]);
});
it('rejects an empty-content document, naming its index', async () => {
const backend = makeBackend();
const vectorStore = new VectorStore('kb').store(backend).embeddingModel(makeEmbeddingModel());
await expect(vectorStore.addDocuments([{ content: '' }])).rejects.toThrow(
/Document at index 0 has empty content/,
);
});
it('rejects a whitespace-only content document without touching the backend', async () => {
const backend = makeBackend();
const vectorStore = new VectorStore('kb').store(backend).embeddingModel(makeEmbeddingModel());
await expect(vectorStore.addDocuments([{ content: 'ok' }, { content: ' ' }])).rejects.toThrow(
/Document at index 1 has empty content/,
);
expect(backend.upsert).not.toHaveBeenCalled();
});
});
describe('VectorStore — deleteDocuments()', () => {
it('returns early and never touches the backend for an empty array', async () => {
const backend = makeBackend();
const vectorStore = new VectorStore('kb').store(backend).embeddingModel(makeEmbeddingModel());
await vectorStore.deleteDocuments([]);
expect(backend.delete).not.toHaveBeenCalled();
});
it('calls backend.delete with the given ids', async () => {
const backend = makeBackend();
const vectorStore = new VectorStore('kb').store(backend).embeddingModel(makeEmbeddingModel());
await vectorStore.deleteDocuments(['a', 'b']);
expect(backend.delete).toHaveBeenCalledWith({ ids: ['a', 'b'] });
});
});
describe('VectorStore — asTool()', () => {
it('defaults the tool name to search_<sanitized store name>', () => {
const vectorStore = new VectorStore('product-docs').description('Search the docs');
const tool = vectorStore.asTool().build();
expect(tool.name).toBe('search_product-docs');
});
it('throws when no description is set anywhere', () => {
const vectorStore = new VectorStore('product-docs');
expect(() => vectorStore.asTool()).toThrow(/requires a description/);
});
it('uses the per-call description over the builder default', () => {
const vectorStore = new VectorStore('product-docs').description('default description');
const tool = vectorStore.asTool({ description: 'override description' }).build();
expect(tool.description).toBe('override description');
});
it('Agent.vectorStore() registers a search_<name> tool', () => {
const vectorStore = new VectorStore('product-docs').description('Search the docs');
const agent = new Agent('assistant').vectorStore(vectorStore);
expect(agent.snapshot.tools).toEqual([
{ name: 'search_product-docs', description: 'Search the docs' },
]);
});
it('handler returns { results } from search()', async () => {
const results: VectorQueryResult[] = [{ id: '1', content: 'a', metadata: {}, score: 0.9 }];
const backend = makeBackend({ query: vi.fn().mockResolvedValue(results) });
const vectorStore = new VectorStore('product-docs')
.store(backend)
.embeddingModel(makeEmbeddingModel())
.description('Search the docs');
const tool = vectorStore.asTool().build();
const output = await tool.handler!({ query: 'hello' }, {});
expect(output).toEqual({ results });
});
it('without filterableKeys, the schema has no filter field', () => {
const vectorStore = new VectorStore('product-docs').description('Search the docs');
const tool = vectorStore.asTool().build();
expect(isZodSchema(tool.inputSchema)).toBe(true);
if (!isZodSchema(tool.inputSchema)) return;
const parsed = tool.inputSchema.safeParse({
query: 'hello',
filter: [{ key: 'x', operator: 'eq', value: 'y' }],
});
expect(parsed.success).toBe(true);
expect(parsed.data).toEqual({ query: 'hello' });
});
describe('with filterableKeys', () => {
it('accepts a valid filter array', () => {
const vectorStore = new VectorStore('product-docs').description('Search the docs');
const tool = vectorStore.asTool({ filterableKeys: { plan: 'cloud or self-hosted' } }).build();
expect(isZodSchema(tool.inputSchema)).toBe(true);
if (!isZodSchema(tool.inputSchema)) return;
const parsed = tool.inputSchema.safeParse({
query: 'hello',
filter: [{ key: 'plan', operator: 'eq', value: 'cloud' }],
});
expect(parsed.success).toBe(true);
});
it('rejects a filter key outside filterableKeys', () => {
const vectorStore = new VectorStore('product-docs').description('Search the docs');
const tool = vectorStore.asTool({ filterableKeys: { plan: 'cloud or self-hosted' } }).build();
expect(isZodSchema(tool.inputSchema)).toBe(true);
if (!isZodSchema(tool.inputSchema)) return;
const parsed = tool.inputSchema.safeParse({
query: 'hello',
filter: [{ key: 'unlisted', operator: 'eq', value: 'x' }],
});
expect(parsed.success).toBe(false);
});
it('rejects an unknown (removed) operator', () => {
const vectorStore = new VectorStore('product-docs').description('Search the docs');
const tool = vectorStore.asTool({ filterableKeys: { plan: 'cloud or self-hosted' } }).build();
expect(isZodSchema(tool.inputSchema)).toBe(true);
if (!isZodSchema(tool.inputSchema)) return;
const parsed = tool.inputSchema.safeParse({
query: 'hello',
filter: [{ key: 'plan', operator: 'text_match', value: 'x' }],
});
expect(parsed.success).toBe(false);
});
it('rejects an array value for a scalar operator (eq/ne)', () => {
const vectorStore = new VectorStore('product-docs').description('Search the docs');
const tool = vectorStore.asTool({ filterableKeys: { plan: 'cloud or self-hosted' } }).build();
expect(isZodSchema(tool.inputSchema)).toBe(true);
if (!isZodSchema(tool.inputSchema)) return;
const parsed = tool.inputSchema.safeParse({
query: 'hello',
filter: [{ key: 'plan', operator: 'eq', value: ['cloud'] }],
});
expect(parsed.success).toBe(false);
});
it('rejects a scalar value for an array operator (in/nin)', () => {
const vectorStore = new VectorStore('product-docs').description('Search the docs');
const tool = vectorStore.asTool({ filterableKeys: { plan: 'cloud or self-hosted' } }).build();
expect(isZodSchema(tool.inputSchema)).toBe(true);
if (!isZodSchema(tool.inputSchema)) return;
const parsed = tool.inputSchema.safeParse({
query: 'hello',
filter: [{ key: 'plan', operator: 'in', value: 'cloud' }],
});
expect(parsed.success).toBe(false);
});
it('rejects a condition missing a value', () => {
const vectorStore = new VectorStore('product-docs').description('Search the docs');
const tool = vectorStore.asTool({ filterableKeys: { plan: 'cloud or self-hosted' } }).build();
expect(isZodSchema(tool.inputSchema)).toBe(true);
if (!isZodSchema(tool.inputSchema)) return;
const parsed = tool.inputSchema.safeParse({
query: 'hello',
filter: [{ key: 'plan', operator: 'eq' }],
});
expect(parsed.success).toBe(false);
});
it('passes the model filter to backend.query as the single filter group', async () => {
const backend = makeBackend();
const vectorStore = new VectorStore('product-docs')
.store(backend)
.embeddingModel(makeEmbeddingModel())
.description('Search the docs');
const tool = vectorStore.asTool({ filterableKeys: { plan: 'cloud or self-hosted' } }).build();
await tool.handler!(
{ query: 'hello', filter: [{ key: 'plan', operator: 'eq', value: 'cloud' }] },
{},
);
expect(backend.query).toHaveBeenCalledWith([1, 0, 0], {
topK: 4,
filter: {
conditions: [{ key: 'plan', operator: 'eq', value: 'cloud' }],
combineWith: 'and',
},
});
});
it('omits the filter key when the model calls without a filter', async () => {
const backend = makeBackend();
const vectorStore = new VectorStore('product-docs')
.store(backend)
.embeddingModel(makeEmbeddingModel())
.description('Search the docs');
const tool = vectorStore.asTool({ filterableKeys: { plan: 'cloud or self-hosted' } }).build();
await tool.handler!({ query: 'hello' }, {});
expect(backend.query).toHaveBeenCalledWith([1, 0, 0], { topK: 4 });
});
});
});
describe('sanitizeToolName', () => {
it('preserves hyphens', () => {
expect(sanitizeToolName('product-docs')).toBe('product-docs');
});
it('collapses runs of invalid characters into a single underscore', () => {
expect(sanitizeToolName('My Docs!!')).toBe('My_Docs_');
});
it('truncates to 64 chars with no trailing underscore or hyphen', () => {
const sanitized = sanitizeToolName('x'.repeat(80));
expect(sanitized.length).toBeLessThanOrEqual(64);
expect(sanitized).not.toMatch(/[_-]$/);
});
it('caps the composed default tool name in asTool() at 64 chars', () => {
const tool = new VectorStore('x'.repeat(80)).description('d').asTool().build();
expect(tool.name.length).toBeLessThanOrEqual(64);
expect(tool.name.startsWith('search_')).toBe(true);
});
});
+9
View File
@@ -8,6 +8,7 @@ import type { McpClient } from './mcp-client';
import { Memory, normalizeMemoryConfig, resolveMemoryConfigDefaults } from './memory';
import { Telemetry } from './telemetry';
import { wrapToolForApproval } from './tool';
import type { VectorStore } from './vector-store';
import { AgentRuntime, type AgentRuntimeConfig } from '../runtime/loop/agent-runtime';
import { RECALL_MEMORY_TOOL_NAME } from '../runtime/memory/episodic-memory';
import type { ScopedMemoryTaskEvent } from '../runtime/memory/scoped-memory-task-runner';
@@ -259,6 +260,14 @@ export class Agent implements BuiltAgent, AgentBuilder {
return this;
}
/** Attach a vector store as a search tool. Accepts a VectorStore builder. */
vectorStore(
store: VectorStore,
options?: { name?: string; description?: string; filterableKeys?: Record<string, string> },
): this {
return this.tool(store.asTool(options));
}
/** Add tools that are searchable through `search_tools` and activated on demand with `load_tool`. */
deferredTool(t: ToolParameter | ToolParameter[], options?: DeferredToolOptions): this {
const tools = Array.isArray(t) ? t : [t];
+16
View File
@@ -378,3 +378,19 @@ export class Tool<
};
}
}
const MAX_TOOL_NAME_LENGTH = 64;
/**
* Coerce an arbitrary string into a provider-safe tool name
* (`[a-zA-Z0-9_-]`, max 64 chars — OpenAI's limit). Mirrors
* `nodeNameToToolName` in `n8n-workflow`; keep the two in sync — the SDK
* deliberately has no dependency on that package.
*/
export function sanitizeToolName(name: string): string {
let toolName = name.replace(/[^a-zA-Z0-9_-]+/g, '_');
if (toolName.length > MAX_TOOL_NAME_LENGTH) {
toolName = toolName.slice(0, MAX_TOOL_NAME_LENGTH).replace(/[_-]+$/, '');
}
return toolName;
}
@@ -0,0 +1,105 @@
import { z } from 'zod';
import type { FilterCondition, FilterOperator, VectorFilter } from '../types/sdk/vector-store';
const SCALAR_OPERATORS = ['eq', 'ne'] as const satisfies readonly FilterOperator[];
const ARRAY_OPERATORS = ['in', 'nin'] as const satisfies readonly FilterOperator[];
export const FILTER_OPERATORS = [
...SCALAR_OPERATORS,
...ARRAY_OPERATORS,
] as const satisfies readonly FilterOperator[];
export type VectorFilterInput = VectorFilter | Record<string, string | number | boolean>;
function isVectorFilter(input: VectorFilterInput): input is VectorFilter {
return Array.isArray(input.conditions);
}
/** Normalizes the `.search({ filter })` shorthand into a canonical `VectorFilter`. */
export function normalizeFilterInput(input: VectorFilterInput): VectorFilter {
if (isVectorFilter(input)) {
return input;
}
const conditions: FilterCondition[] = Object.entries(input).map(([key, value]) => ({
key,
operator: 'eq',
value,
}));
return { conditions, combineWith: 'and' };
}
/** Validates operator/value pairing per condition; throws rather than silently ignoring a bad filter. */
export function assertValidFilter(filter: VectorFilter): void {
for (const condition of filter.conditions) {
assertValidCondition(condition);
}
}
function assertValidCondition(condition: FilterCondition): void {
const { key, operator, value } = condition;
if (!(FILTER_OPERATORS as readonly string[]).includes(operator)) {
throw new Error(
`Invalid filter operator "${operator}" for key "${key}". Supported operators: ${FILTER_OPERATORS.join(', ')}`,
);
}
if (operator === 'in' || operator === 'nin') {
if (!Array.isArray(value) || value.length === 0) {
throw new Error(
`Filter operator "${operator}" on key "${key}" requires a non-empty array value.`,
);
}
if (value.some((v) => typeof v !== 'string' && typeof v !== 'number')) {
throw new Error(
`Filter operator "${operator}" on key "${key}" requires array elements to be strings or numbers.`,
);
}
return;
}
// eq, ne
if (typeof value !== 'string' && typeof value !== 'number' && typeof value !== 'boolean') {
throw new Error(
`Filter operator "${operator}" on key "${key}" requires a string, number, or boolean value.`,
);
}
}
function isNonEmptyArray(arr: string[]): arr is [string, ...string[]] {
return arr.length > 0;
}
/** Builds the zod schema for the model-facing `filter` tool input, scoped to `keys` (key -> description). */
export function buildFilterInputSchema(keys: Record<string, string>) {
const keyNames = Object.keys(keys);
if (!isNonEmptyArray(keyNames)) {
throw new Error('filterableKeys must contain at least one key');
}
const keyDescriptions = keyNames.map((key) => `- ${key}: ${keys[key]}`).join('\n');
// Discriminated by `operator` so the model-facing schema rejects the same
// operator/value mismatches the runtime validator does (e.g. an array for
// "eq", or a scalar for "in"), instead of failing later during search.
return z
.array(
z.discriminatedUnion('operator', [
z.object({
key: z.enum(keyNames),
operator: z.enum(SCALAR_OPERATORS),
value: z.union([z.string(), z.number(), z.boolean()]),
}),
z.object({
key: z.enum(keyNames),
operator: z.enum(ARRAY_OPERATORS),
value: z.array(z.union([z.string(), z.number()])).min(1),
}),
]),
)
.optional()
.describe(
`Optional metadata filter conditions (combined with AND). Filterable keys:\n${keyDescriptions}`,
);
}
@@ -0,0 +1,192 @@
import type { EmbeddingModel } from 'ai';
import { z } from 'zod';
import { sanitizeToolName, Tool } from './tool';
import {
assertValidFilter,
buildFilterInputSchema,
normalizeFilterInput,
type VectorFilterInput,
} from './vector-store-filter';
import {
createEmbeddingModel,
type EmbeddingProviderOptions,
} from '../runtime/model/model-factory';
import type { BuiltVectorStoreBackend, VectorDocument, VectorQueryResult } from '../types';
import type { VectorFilter } from '../types/sdk/vector-store';
const DEFAULT_TOP_K = 4;
/**
* Pairs a vector store backend with an embedding model and owns the
* embed-then-search / embed-then-upsert orchestration. Backends operate
* purely on vectors — this is the only place text gets embedded.
*/
export class VectorStore {
private backend?: BuiltVectorStoreBackend;
private embeddingModelValue?: EmbeddingModel;
private topKValue: number = DEFAULT_TOP_K;
private descriptionValue?: string;
constructor(private readonly name: string) {}
/** Set the vector store backend (e.g. `new PgVectorStore(...)`). Required before building. */
store(backend: BuiltVectorStoreBackend): this {
this.backend = backend;
return this;
}
/**
* Set the embedding model used to embed queries and documents.
* Accepts a "provider/model" string (e.g. `openai/text-embedding-3-small`)
* resolved via {@link createEmbeddingModel}, or a pre-built AI SDK `EmbeddingModel`.
* Required before building.
*/
embeddingModel(
model: string | EmbeddingModel,
options?: string | EmbeddingProviderOptions,
): this {
this.embeddingModelValue =
typeof model === 'string' ? createEmbeddingModel(model, options) : model;
return this;
}
/** Set the default number of results returned by `.search()`. Default: 4. */
topK(k: number): this {
assertValidTopK(k);
this.topKValue = k;
return this;
}
/** Set the description used by `.asTool()` when no per-call description is given. */
description(text: string): this {
this.descriptionValue = text;
return this;
}
/** Search the store for content semantically similar to `query`. Lazy-builds on first call. */
async search(
query: string,
opts?: { topK?: number; filter?: VectorFilterInput },
): Promise<VectorQueryResult[]> {
if (opts?.topK !== undefined) {
assertValidTopK(opts.topK);
}
const { backend, embeddingModel } = this.ensureBuilt();
const { embed } = await import('ai');
const { embedding } = await embed({ model: embeddingModel, value: query });
const filter = this.resolveFilter(opts?.filter);
return await backend.query(embedding, {
topK: opts?.topK ?? this.topKValue,
...(filter ? { filter } : {}),
});
}
/** Embed and upsert documents into the store. Returns the ids used (generated when not provided). */
async addDocuments(docs: VectorDocument[]): Promise<string[]> {
if (docs.length === 0) return [];
docs.forEach((doc, index) => {
if (typeof doc.content !== 'string' || doc.content.trim() === '') {
throw new Error(`Document at index ${index} has empty content — nothing to embed.`);
}
});
const { backend, embeddingModel } = this.ensureBuilt();
const ids = docs.map((doc) => doc.id ?? crypto.randomUUID());
const { embedMany } = await import('ai');
const { embeddings } = await embedMany({
model: embeddingModel,
values: docs.map((doc) => doc.content),
});
await backend.upsert(
docs.map((doc, index) => ({
id: ids[index],
vector: embeddings[index],
content: doc.content,
metadata: doc.metadata ?? {},
})),
);
return ids;
}
/** Delete documents from the store by id. */
async deleteDocuments(ids: string[]): Promise<void> {
if (ids.length === 0) return;
const { backend } = this.ensureBuilt();
await backend.delete({ ids });
}
/**
* Expose this store as an agent tool. Pass `filterableKeys` (metadata key
* -> description) to also let the model narrow results with a filter.
*/
asTool(opts?: {
name?: string;
description?: string;
filterableKeys?: Record<string, string>;
}): Tool {
const description = opts?.description ?? this.descriptionValue;
if (!description) {
throw new Error(
`VectorStore "${this.name}" requires a description — set it via .description() or asTool({ description })`,
);
}
const toolName = opts?.name ?? sanitizeToolName(`search_${this.name}`);
if (!opts?.filterableKeys) {
return new Tool(toolName)
.description(description)
.input(z.object({ query: z.string().describe('Natural language search query') }))
.handler(async ({ query }) => ({ results: await this.search(query) }));
}
const filterSchema = buildFilterInputSchema(opts.filterableKeys);
return new Tool(toolName)
.description(description)
.input(
z.object({
query: z.string().describe('Natural language search query'),
filter: filterSchema,
}),
)
.handler(async ({ query, filter }) => ({
results: await this.search(
query,
filter && filter.length > 0
? { filter: { conditions: filter, combineWith: 'and' } }
: undefined,
),
}));
}
private ensureBuilt(): { backend: BuiltVectorStoreBackend; embeddingModel: EmbeddingModel } {
if (!this.backend) {
throw new Error(`VectorStore "${this.name}" requires a backend — set it via .store()`);
}
if (!this.embeddingModelValue) {
throw new Error(
`VectorStore "${this.name}" requires an embedding model — set it via .embeddingModel()`,
);
}
return { backend: this.backend, embeddingModel: this.embeddingModelValue };
}
/** Normalizes and validates a filter; returns `undefined` for an empty one so it's never a no-op `WHERE`. */
private resolveFilter(input?: VectorFilterInput): VectorFilter | undefined {
if (input === undefined) return undefined;
const normalized = normalizeFilterInput(input);
assertValidFilter(normalized);
return normalized.conditions.length > 0 ? normalized : undefined;
}
}
function assertValidTopK(k: number): void {
if (!Number.isInteger(k) || k < 1) {
throw new Error(`topK must be an integer >= 1, got ${k}`);
}
}
@@ -0,0 +1,32 @@
/* eslint-disable @typescript-eslint/promise-function-async */
import type {
BuiltVectorStoreBackend,
VectorFilter,
VectorQueryResult,
VectorRecord,
} from '../types/sdk/vector-store';
import type { JSONObject } from '../types/utils/json';
export abstract class BaseVectorStore<TConstructorOptions extends JSONObject = JSONObject>
implements BuiltVectorStoreBackend
{
constructor(
protected readonly name: string,
protected readonly constructorOptions: TConstructorOptions,
) {}
upsert(_records: VectorRecord[]): Promise<void> {
throw new Error('Method not implemented.');
}
query(
_vector: number[],
_opts: { topK: number; filter?: VectorFilter },
): Promise<VectorQueryResult[]> {
throw new Error('Method not implemented.');
}
delete(_opts: { ids: string[] }): Promise<void> {
throw new Error('Method not implemented.');
}
close?(): void | Promise<void>;
}
+11
View File
@@ -103,6 +103,17 @@ export type {
TitleGenerationConfig,
} from './sdk/memory';
export type {
VectorDocument,
VectorRecord,
VectorQueryResult,
BuiltVectorStoreBackend,
FilterOperator,
FilterValue,
FilterCondition,
VectorFilter,
} from './sdk/vector-store';
export type { ObservationCursor } from './sdk/observation';
export type {
@@ -0,0 +1,50 @@
import type { JSONObject } from '../utils/json';
export interface VectorDocument {
id?: string;
content: string;
metadata?: JSONObject;
}
export interface VectorRecord {
id: string;
vector: number[];
content: string;
metadata: JSONObject;
}
export interface VectorQueryResult {
id: string;
content: string;
metadata: JSONObject;
score: number;
}
/** A comparison on one metadata key. `eq`/`ne` take a scalar, `in`/`nin` a non-empty array. */
export interface FilterCondition {
key: string;
operator: FilterOperator;
value: FilterValue;
}
export type FilterOperator = 'eq' | 'ne' | 'in' | 'nin';
export type FilterValue = string | number | boolean | Array<string | number>;
/** A flat group of conditions joined by one combinator — deliberately not a nested boolean tree. */
export interface VectorFilter {
conditions: FilterCondition[];
combineWith?: 'and' | 'or';
}
/** A pluggable vector database backend — operates purely on vectors; embedding is the `VectorStore` orchestrator's job. */
export interface BuiltVectorStoreBackend {
upsert(records: VectorRecord[]): Promise<void>;
query(
vector: number[],
opts: { topK: number; filter?: VectorFilter },
): Promise<VectorQueryResult[]>;
delete(opts: { ids: string[] }): Promise<void>;
/** Close the connection pool / release resources. */
close?(): void | Promise<void>;
}
@@ -0,0 +1,196 @@
import type { Index, RecordMetadata, ScoredPineconeRecord } from '@pinecone-database/pinecone';
import { BaseVectorStore } from '../storage/base-vector-store';
import type {
FilterCondition,
VectorFilter,
VectorQueryResult,
VectorRecord,
} from '../types/sdk/vector-store';
import type { JSONObject, JSONValue } from '../types/utils/json';
/** Metadata key reserved for document content — Pinecone has no separate content column. */
const CONTENT_KEY = '_content';
/** Pinecone caps upserts at 1,000 records / 2MB per request and the client does not auto-batch. */
const UPSERT_BATCH_SIZE = 200;
export type PineconeVectorStoreOptions = {
apiKey: string;
indexName: string;
/** Pinecone namespace. Default: the index's default namespace (`''`). */
namespace?: string;
};
/**
* Pinecone backend (`@pinecone-database/pinecone` is an optional peer dependency).
* Never creates or alters the index: expects one that already exists with vector
* dimension matching the embedding model and Cosine metric.
*
* Pinecone metadata is flat only (string, number, boolean, or string-array
* values — no nested objects, no null), so document content is stored under
* a reserved `_content` metadata key alongside the caller's metadata spread
* at the top level. Metadata containing that reserved key, or a value of an
* unsupported shape, is rejected before any request is sent. Because content
* is stored as metadata, Pinecone's 40KB per-record metadata limit applies
* to the document content plus its metadata combined.
*
* Pinecone's `$ne`/`$nin` filter operators do not match records missing the
* filtered key, unlike `PgVectorStore`/`QdrantVectorStore`/`SupabaseVectorStore`
* — `ne`/`nin` are translated into an `$or` with an `$exists: false` arm so
* behavior matches the other backends.
*
* Pinecone writes and deletes are eventually consistent: a query issued
* immediately after an upsert or delete may not reflect it yet.
*
* Pinecone only guarantees score-sorted results for unfiltered queries —
* a filtered query's `matches` can come back out of order, so results are
* explicitly re-sorted by score before being returned.
*
* @example
* ```typescript
* const store = new PineconeVectorStore('product-docs', {
* apiKey: process.env.PINECONE_API_KEY!,
* indexName: 'product-docs',
* });
* ```
*/
export class PineconeVectorStore extends BaseVectorStore<PineconeVectorStoreOptions> {
private index?: Index;
async upsert(records: VectorRecord[]): Promise<void> {
if (records.length === 0) return;
const index = await this.getIndex();
const mapped = records.map((record) => ({
id: record.id,
values: record.vector,
metadata: toPineconeMetadata(record.content, record.metadata),
}));
for (let i = 0; i < mapped.length; i += UPSERT_BATCH_SIZE) {
await index.upsert(mapped.slice(i, i + UPSERT_BATCH_SIZE));
}
}
async query(
vector: number[],
opts: { topK: number; filter?: VectorFilter },
): Promise<VectorQueryResult[]> {
const index = await this.getIndex();
const filter =
opts.filter && opts.filter.conditions.length > 0
? buildPineconeFilter(opts.filter)
: undefined;
const result = await index.query({
vector,
topK: opts.topK,
includeMetadata: true,
...(filter ? { filter } : {}),
});
// Pinecone does not guarantee score-sorted results for filtered queries
// (only for unfiltered ones) — sort explicitly to match the other backends.
const sorted = [...result.matches].sort((a, b) => (b.score ?? 0) - (a.score ?? 0));
return sorted.map(toQueryResult);
}
async delete({ ids }: { ids: string[] }): Promise<void> {
if (ids.length === 0) return;
const index = await this.getIndex();
await index.deleteMany(ids);
}
close(): void {
this.index = undefined;
}
private async getIndex(): Promise<Index> {
if (!this.index) {
const { Pinecone } = await import('@pinecone-database/pinecone');
const pc = new Pinecone({ apiKey: this.constructorOptions.apiKey });
const target = pc.index(this.constructorOptions.indexName);
this.index = this.constructorOptions.namespace
? target.namespace(this.constructorOptions.namespace)
: target;
}
return this.index;
}
}
function toPineconeMetadata(content: string, metadata: JSONObject): RecordMetadata {
if (CONTENT_KEY in metadata) {
throw new Error(
`Metadata key "${CONTENT_KEY}" is reserved for the document content and cannot be set.`,
);
}
const result: RecordMetadata = { [CONTENT_KEY]: content };
for (const [key, value] of Object.entries(metadata)) {
assertValidMetadataValue(key, value);
result[key] = value;
}
return result;
}
/** Pinecone metadata values are flat: string, number, boolean, or an array of strings — no nested objects or null. */
function assertValidMetadataValue(
key: string,
value: JSONValue | undefined,
): asserts value is string | number | boolean | string[] {
if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') {
return;
}
if (Array.isArray(value) && value.every((item) => typeof item === 'string')) return;
throw new Error(
`Metadata value for key "${key}" is unsupported: Pinecone only supports string, number, boolean, and string-array metadata values.`,
);
}
function toQueryResult(match: ScoredPineconeRecord): VectorQueryResult {
const { [CONTENT_KEY]: content, ...metadata } = (match.metadata ?? {}) as JSONObject;
return {
id: String(match.id),
content: typeof content === 'string' ? content : '',
metadata,
score: match.score ?? 0,
};
}
/** Negations are compensated with `$exists: false` so missing-key rows match, like the other backends. */
function buildPineconeFilter(filter: VectorFilter): object {
const terms = filter.conditions.map(buildCondition);
return filter.combineWith === 'or' ? { $or: terms } : { $and: terms };
}
function buildCondition(condition: FilterCondition): object {
const { key, operator, value } = condition;
switch (operator) {
case 'eq':
return { [key]: { $eq: value } };
case 'ne':
return { $or: [{ [key]: { $ne: value } }, { [key]: { $exists: false } }] };
case 'in':
assertNonEmptyArray(operator, key, value);
return { [key]: { $in: value } };
case 'nin':
assertNonEmptyArray(operator, key, value);
return { $or: [{ [key]: { $nin: value } }, { [key]: { $exists: false } }] };
default:
throw new Error(`Unsupported filter operator: "${String(operator)}"`);
}
}
function assertNonEmptyArray(
operator: string,
key: string,
value: unknown,
): asserts value is Array<string | number> {
if (!Array.isArray(value) || value.length === 0) {
throw new Error(
`Filter operator "${operator}" on key "${key}" requires a non-empty array value.`,
);
}
}
@@ -0,0 +1,254 @@
import type { Pool } from 'pg';
import { BaseVectorStore } from '../storage/base-vector-store';
import type {
FilterCondition,
VectorFilter,
VectorQueryResult,
VectorRecord,
} from '../types/sdk/vector-store';
import type { JSONObject } from '../types/utils/json';
const IDENTIFIER_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/;
/** Minimum pgvector version (inclusive) that supports `hnsw.iterative_scan`. */
const ITERATIVE_SCAN_MIN_MAJOR = 0;
const ITERATIVE_SCAN_MIN_MINOR = 8;
interface PgVectorRow {
id: string;
content: string;
metadata: JSONObject;
score: number;
}
export type PgVectorStoreOptions = {
connectionString: string;
tableName: string;
};
/**
* Postgres + pgvector backend (`pg` is an optional peer dependency).
* Never creates or alters schema: expects an existing table with the
* standard pgvector layout — id (unique), content text, metadata jsonb,
* embedding vector(n). The embedding model must match the one that produced
* the stored vectors.
*
* @example
* ```typescript
* const store = new PgVectorStore('product-docs', {
* connectionString: 'postgresql://user:pass@localhost:5432/db',
* tableName: 'product_docs',
* });
* ```
*/
export class PgVectorStore extends BaseVectorStore<PgVectorStoreOptions> {
private readonly tableName: string;
private pool?: Pool;
private iterativeScanSupportedPromise?: Promise<boolean>;
constructor(name: string, options: PgVectorStoreOptions) {
super(name, options);
this.tableName = options.tableName;
if (typeof this.tableName !== 'string' || !IDENTIFIER_PATTERN.test(this.tableName)) {
throw new Error(
`Invalid PgVectorStore table name "${String(this.tableName)}": must match ${IDENTIFIER_PATTERN}`,
);
}
}
async upsert(records: VectorRecord[]): Promise<void> {
if (records.length === 0) return;
const pool = await this.getPool();
const values: string[] = [];
const params: unknown[] = [];
records.forEach((record, index) => {
const base = index * 4;
values.push(`($${base + 1}, $${base + 2}, $${base + 3}, $${base + 4}::vector)`);
params.push(
record.id,
record.content,
JSON.stringify(record.metadata),
serializeVector(record.vector),
);
});
await pool.query(
`INSERT INTO "${this.tableName}" (id, content, metadata, embedding)
VALUES ${values.join(', ')}
ON CONFLICT (id) DO UPDATE
SET content = EXCLUDED.content, metadata = EXCLUDED.metadata, embedding = EXCLUDED.embedding;`,
params,
);
}
async query(
vector: number[],
opts: { topK: number; filter?: VectorFilter },
): Promise<VectorQueryResult[]> {
const pool = await this.getPool();
const selectSql = `SELECT id, content, metadata, 1 - (embedding <=> $1::vector) AS score
FROM "${this.tableName}"`;
if (!opts.filter || opts.filter.conditions.length === 0) {
const result = await pool.query<PgVectorRow>(
`${selectSql}
ORDER BY embedding <=> $1::vector
LIMIT $2;`,
[serializeVector(vector), opts.topK],
);
return result.rows.map(toQueryResult);
}
const { whereSql, params } = this.buildFilterClause(opts.filter, 2);
const sql = `${selectSql}
WHERE ${whereSql}
ORDER BY embedding <=> $1::vector
LIMIT $2;`;
const allParams = [serializeVector(vector), opts.topK, ...params];
if (!(await this.supportsIterativeScan())) {
const result = await pool.query<PgVectorRow>(sql, allParams);
return result.rows.map(toQueryResult);
}
// Filtered HNSW scans can under-return: the index gathers candidates by
// distance alone before the filter applies, so a selective filter can
// leave fewer than `topK` rows even when matches exist elsewhere in the
// graph. Iterative scan keeps walking until LIMIT is met; `relaxed_order`
// trades strict distance ordering for that guarantee, hence the re-sort.
const client = await pool.connect();
try {
await client.query('BEGIN');
await client.query('SET LOCAL hnsw.iterative_scan = relaxed_order;');
const result = await client.query<PgVectorRow>(sql, allParams);
await client.query('COMMIT');
return result.rows.map(toQueryResult).sort((a, b) => b.score - a.score);
} catch (error) {
await client.query('ROLLBACK');
throw error;
} finally {
client.release();
}
}
async delete({ ids }: { ids: string[] }): Promise<void> {
if (ids.length === 0) return;
const pool = await this.getPool();
await pool.query(`DELETE FROM "${this.tableName}" WHERE id = ANY($1);`, [ids]);
}
async close(): Promise<void> {
await this.pool?.end();
this.pool = undefined;
}
/** Keys and values are always bind parameters; only the regex-validated table name is interpolated. */
private buildFilterClause(
filter: VectorFilter,
paramOffset: number,
): { whereSql: string; params: unknown[] } {
const params: unknown[] = [];
let paramCount = paramOffset;
const nextParam = (value: unknown): string => {
paramCount += 1;
params.push(value);
return `$${paramCount}`;
};
const conditionClauses = filter.conditions.map((condition) =>
this.buildConditionClause(condition, nextParam),
);
const joiner = filter.combineWith === 'or' ? ' OR ' : ' AND ';
return { whereSql: conditionClauses.join(joiner), params };
}
private buildConditionClause(
condition: FilterCondition,
nextParam: (value: unknown) => string,
): string {
const { key, operator, value } = condition;
switch (operator) {
case 'eq': {
const v = nextParam(JSON.stringify({ [key]: value }));
return `metadata @> ${v}::jsonb`;
}
case 'ne': {
const v = nextParam(JSON.stringify({ [key]: value }));
return `NOT (metadata @> ${v}::jsonb)`;
}
case 'in':
case 'nin': {
if (!Array.isArray(value) || value.length === 0) {
throw new Error(
`Filter operator "${operator}" on key "${key}" requires a non-empty array value.`,
);
}
// Containment per candidate (rather than `metadata->>key = ANY(text[])`) keeps
// numeric and string metadata distinct — text extraction would otherwise make
// the number 5 and the string "5" match the same filter value.
const anyMatch = value
.map(
(candidate) => `metadata @> ${nextParam(JSON.stringify({ [key]: candidate }))}::jsonb`,
)
.join(' OR ');
return operator === 'in' ? `(${anyMatch})` : `NOT (${anyMatch})`;
}
default:
throw new Error(`Unsupported filter operator: "${String(operator)}"`);
}
}
private async supportsIterativeScan(): Promise<boolean> {
if (!this.iterativeScanSupportedPromise) {
const p = this.checkIterativeScanSupport();
this.iterativeScanSupportedPromise = p;
p.catch(() => {
if (this.iterativeScanSupportedPromise === p)
this.iterativeScanSupportedPromise = undefined;
});
}
return await this.iterativeScanSupportedPromise;
}
private async checkIterativeScanSupport(): Promise<boolean> {
const pool = await this.getPool();
const result = await pool.query<{ extversion: string }>(
"SELECT extversion FROM pg_extension WHERE extname = 'vector';",
);
const version = result.rows[0]?.extversion;
if (!version) return false;
const [major, minor] = version.split('.').map((part) => parseInt(part, 10));
return (
major > ITERATIVE_SCAN_MIN_MAJOR ||
(major === ITERATIVE_SCAN_MIN_MAJOR && minor >= ITERATIVE_SCAN_MIN_MINOR)
);
}
private async getPool(): Promise<Pool> {
if (!this.pool) {
const { Pool: PoolCtor } = await import('pg');
this.pool = new PoolCtor({ connectionString: this.constructorOptions.connectionString });
}
return this.pool;
}
}
function toQueryResult(row: PgVectorRow): VectorQueryResult {
return {
id: row.id,
content: row.content,
metadata: row.metadata,
score: Number(row.score),
};
}
function serializeVector(vector: number[]): string {
return `[${vector.join(',')}]`;
}
@@ -0,0 +1,188 @@
import type { QdrantClient, Schemas } from '@qdrant/js-client-rest';
import { BaseVectorStore } from '../storage/base-vector-store';
import type {
FilterCondition,
VectorFilter,
VectorQueryResult,
VectorRecord,
} from '../types/sdk/vector-store';
import type { JSONObject } from '../types/utils/json';
// Qdrant parses point-id UUIDs with Rust's `Uuid::parse_str`, which accepts all
// four representations below — not just hyphenated — so this must match them too:
// simple `936da01f9abd4d9d80c702af85c822a8`, hyphenated `550e8400-e29b-...-440000`,
// urn `urn:uuid:550e8400-e29b-...-440000`, and braced `{550e8400-e29b-...-440000}`.
const UUID_HYPHENATED = '[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}';
const UUID_PATTERN = new RegExp(
`^(${UUID_HYPHENATED}|[0-9a-f]{32}|urn:uuid:${UUID_HYPHENATED}|\\{${UUID_HYPHENATED}\\})$`,
'i',
);
const UNSIGNED_INT_PATTERN = /^\d+$/;
interface QdrantPayload {
content: string;
metadata: JSONObject;
}
export type QdrantVectorStoreOptions = {
url: string;
apiKey?: string;
collectionName: string;
};
/**
* Qdrant backend (`@qdrant/js-client-rest` is an optional peer dependency).
* Never creates or alters the collection: expects one that already exists
* with vector size matching the embedding model and Cosine distance.
*
* Qdrant point ids must be a UUID or an unsigned integer — arbitrary string
* ids (other than the UUIDs the `VectorStore` orchestrator generates) are
* rejected with a descriptive error rather than a raw Qdrant 400.
*
* Metadata filtering requires a payload index on each filtered field
* (`metadata.<key>`) — Qdrant Cloud rejects filters on unindexed fields —
* and a field can only carry one index type at a time. Filter values are
* also more restrictive than `PgVectorStore`: Qdrant `match` only supports
* strings, integers, and booleans, so float values for `eq`/`ne` and
* mixed-type arrays for `in`/`nin` (e.g. `['billing', 5]`) are rejected with
* a descriptive error before any request is sent.
*
* @example
* ```typescript
* const store = new QdrantVectorStore('product-docs', {
* url: 'http://localhost:6333',
* collectionName: 'product_docs',
* });
* ```
*/
export class QdrantVectorStore extends BaseVectorStore<QdrantVectorStoreOptions> {
private readonly collectionName: string;
private client?: QdrantClient;
constructor(name: string, options: QdrantVectorStoreOptions) {
super(name, options);
this.collectionName = options.collectionName;
}
async upsert(records: VectorRecord[]): Promise<void> {
if (records.length === 0) return;
const client = await this.getClient();
await client.upsert(this.collectionName, {
wait: true,
points: records.map((record) => ({
id: toPointId(record.id),
vector: record.vector,
payload: { content: record.content, metadata: record.metadata },
})),
});
}
async query(
vector: number[],
opts: { topK: number; filter?: VectorFilter },
): Promise<VectorQueryResult[]> {
const client = await this.getClient();
const filter =
opts.filter && opts.filter.conditions.length > 0 ? buildQdrantFilter(opts.filter) : undefined;
const result = await client.query(this.collectionName, {
query: vector,
limit: opts.topK,
with_payload: true,
...(filter ? { filter } : {}),
});
return result.points.map(toQueryResult);
}
async delete({ ids }: { ids: string[] }): Promise<void> {
if (ids.length === 0) return;
const client = await this.getClient();
await client.delete(this.collectionName, { wait: true, points: ids.map(toPointId) });
}
close(): void {
this.client = undefined;
}
private async getClient(): Promise<QdrantClient> {
if (!this.client) {
const { QdrantClient: QdrantClientCtor } = await import('@qdrant/js-client-rest');
this.client = new QdrantClientCtor({
url: this.constructorOptions.url,
apiKey: this.constructorOptions.apiKey,
});
}
return this.client;
}
}
/** Qdrant only accepts UUID or unsigned-integer point ids. */
function toPointId(id: string): string | number {
if (UUID_PATTERN.test(id)) return id;
const numeric = Number(id);
if (UNSIGNED_INT_PATTERN.test(id) && Number.isSafeInteger(numeric) && String(numeric) === id) {
return numeric;
}
throw new Error(
`Invalid Qdrant point id "${id}": Qdrant requires ids to be a UUID or a canonical unsigned integer.`,
);
}
function toQueryResult(point: Schemas['ScoredPoint']): VectorQueryResult {
const payload = (point.payload ?? {}) as unknown as QdrantPayload;
return {
id: String(point.id),
content: payload.content,
metadata: payload.metadata ?? {},
score: point.score,
};
}
/** Negations are expressed as nested `must_not` filters so both `and`/`or` combinators work uniformly. */
function buildQdrantFilter(filter: VectorFilter): Schemas['Filter'] {
const conditions = filter.conditions.map(buildCondition);
return filter.combineWith === 'or' ? { should: conditions } : { must: conditions };
}
function buildCondition(condition: FilterCondition): Schemas['Condition'] {
const { key, operator, value } = condition;
const payloadKey = `metadata.${key}`;
switch (operator) {
case 'eq':
case 'ne': {
if (typeof value === 'number' && !Number.isInteger(value)) {
throw new Error(
`Filter operator "${operator}" on key "${key}" does not support float values: Qdrant match only supports strings, integers, and booleans.`,
);
}
const match: Schemas['Condition'] = { key: payloadKey, match: { value } };
return operator === 'eq' ? match : { must_not: [match] };
}
case 'in':
case 'nin': {
if (!Array.isArray(value) || value.length === 0) {
throw new Error(
`Filter operator "${operator}" on key "${key}" requires a non-empty array value.`,
);
}
const allStrings = value.every((v) => typeof v === 'string');
const allIntegers = value.every((v) => typeof v === 'number' && Number.isInteger(v));
if (!allStrings && !allIntegers) {
throw new Error(
`Filter operator "${operator}" on key "${key}" requires all array elements to be strings or all to be integers: Qdrant match does not support mixed-type or float values.`,
);
}
// eslint-disable-next-line id-denylist -- `any` is Qdrant's match-schema field name
const anyCondition: Schemas['Condition'] = { key: payloadKey, match: { any: value } };
return operator === 'in' ? anyCondition : { must_not: [anyCondition] };
}
default:
throw new Error(`Unsupported filter operator: "${String(operator)}"`);
}
}
@@ -0,0 +1,256 @@
import type { SupabaseClient } from '@supabase/supabase-js';
import { BaseVectorStore } from '../storage/base-vector-store';
import type {
FilterCondition,
VectorFilter,
VectorQueryResult,
VectorRecord,
} from '../types/sdk/vector-store';
import type { JSONObject } from '../types/utils/json';
interface SupabaseMatchRow {
id: string;
content: string;
metadata: JSONObject;
similarity: number;
}
/**
* Explicit RPC function shape passed to `client.rpc<FnName, Fn>()` — without it,
* TypeScript can't resolve `Fn` from the untyped (no generated `Database` type)
* client, and `Fn['Returns']` collapses to `any`, which trips up `.returns()`'s
* array-vs-object validation. Providing this directly gives the whole
* `rpc()` → `.filter()`/`.limit()` chain a concrete result type.
*/
interface MatchDocumentsFn {
Args: { query_embedding: number[] };
Returns: SupabaseMatchRow[];
}
/**
* Structural subset of `@supabase/postgrest-js`'s `PostgrestFilterBuilder` —
* declared locally so filter-building stays generic over the real (highly
* parameterized) builder type without depending on postgrest-js directly.
* Every method returns `Self`, matching the real builder's `this`-returning
* chain.
*/
interface PostgrestFilterChain<Self> {
filter(column: string, operator: string, value: unknown): Self;
not(column: string, operator: string, value: unknown): Self;
or(filters: string): Self;
}
export type SupabaseVectorStoreOptions = {
url: string;
apiKey: string;
tableName: string;
/** Name of the similarity-search RPC function. Default: `match_documents`. */
queryName?: string;
};
/**
* Supabase backend (`@supabase/supabase-js` is an optional peer dependency),
* built on PostgREST — never creates or alters schema. Expects an existing
* table with the standard pgvector layout (id text primary key, content
* text, metadata jsonb, embedding vector(n)) and a Postgres RPC function
* (default name `match_documents`) that takes a `query_embedding` parameter
* and returns rows shaped `{ id, content, metadata, similarity }` ordered by
* distance:
*
* ```sql
* create function match_documents(query_embedding vector(n))
* returns table (id text, content text, metadata jsonb, similarity float)
* language sql stable as $$
* select id, content, metadata, 1 - (embedding <=> query_embedding) as similarity
* from "<tableName>"
* order by embedding <=> query_embedding;
* $$;
* ```
*
* `match_count` is deliberately never passed to the RPC — PostgREST applies
* `.filter()`/`.or()`/`.limit()` chained onto an `rpc()` call as a wrapping
* subquery, so metadata filters are applied before `topK` truncates the
* results rather than after, matching `PgVectorStore` semantics.
*
* On tables with an HNSW index, a selective metadata filter can make the
* index scan return fewer than `topK` rows even when more matches exist.
* `PgVectorStore` works around this with a per-query
* `hnsw.iterative_scan` setting, which PostgREST cannot set — if you use
* an HNSW index, set it at the role level instead:
* `ALTER ROLE authenticator SET hnsw.iterative_scan = relaxed_order;`
*
* Filtering uses jsonb containment (`cs`) on the `metadata` column, so
* `eq`/`in` are type-correct (number `5` does not match string `"5"`) and
* `ne`/`nin` match rows that never had the filtered key.
*
* @example
* ```typescript
* const store = new SupabaseVectorStore('product-docs', {
* url: 'https://xyzcompany.supabase.co',
* apiKey: process.env.SUPABASE_SECRET_KEY!,
* tableName: 'product_docs',
* });
* ```
*/
export class SupabaseVectorStore extends BaseVectorStore<SupabaseVectorStoreOptions> {
private readonly tableName: string;
private readonly queryName: string;
private client?: SupabaseClient;
constructor(name: string, options: SupabaseVectorStoreOptions) {
super(name, options);
this.tableName = options.tableName;
this.queryName = options.queryName ?? 'match_documents';
}
async upsert(records: VectorRecord[]): Promise<void> {
if (records.length === 0) return;
const client = await this.getClient();
const { error } = await client.from(this.tableName).upsert(
records.map((record) => ({
id: record.id,
content: record.content,
metadata: record.metadata,
embedding: record.vector,
})),
{ onConflict: 'id' },
);
if (error) throw new Error(`Supabase upsert failed: ${error.message}`);
}
async query(
vector: number[],
opts: { topK: number; filter?: VectorFilter },
): Promise<VectorQueryResult[]> {
const client = await this.getClient();
const rpcCall = client.rpc<string, MatchDocumentsFn>(this.queryName, {
query_embedding: vector,
});
const filtered =
opts.filter && opts.filter.conditions.length > 0
? applySupabaseFilter(rpcCall, opts.filter)
: rpcCall;
const { data, error } = await filtered.limit(opts.topK);
if (error) throw new Error(`Supabase query failed: ${error.message}`);
return (data ?? []).map(toQueryResult);
}
async delete({ ids }: { ids: string[] }): Promise<void> {
if (ids.length === 0) return;
const client = await this.getClient();
const { error } = await client.from(this.tableName).delete().in('id', ids);
if (error) throw new Error(`Supabase delete failed: ${error.message}`);
}
close(): void {
this.client = undefined;
}
private async getClient(): Promise<SupabaseClient> {
if (!this.client) {
const { createClient } = await import('@supabase/supabase-js');
this.client = createClient(this.constructorOptions.url, this.constructorOptions.apiKey);
}
return this.client;
}
}
function toQueryResult(row: SupabaseMatchRow): VectorQueryResult {
return {
id: String(row.id),
content: row.content,
metadata: row.metadata ?? {},
score: row.similarity,
};
}
/** Negations are expressed as chained `.not()` filters so both `and`/`or` combinators work uniformly. */
function applySupabaseFilter<Builder extends PostgrestFilterChain<Builder>>(
builder: Builder,
filter: VectorFilter,
): Builder {
if (filter.combineWith === 'or') {
return builder.or(filter.conditions.map(toOrLogicTerm).join(','));
}
return filter.conditions.reduce(applyAndCondition, builder);
}
function applyAndCondition<Builder extends PostgrestFilterChain<Builder>>(
builder: Builder,
condition: FilterCondition,
): Builder {
const { key, operator, value } = condition;
switch (operator) {
case 'eq':
return builder.filter('metadata', 'cs', containmentJson(key, value));
case 'ne':
return builder.not('metadata', 'cs', containmentJson(key, value));
case 'in': {
assertNonEmptyArray(operator, key, value);
return builder.or(
value
.map((candidate) => `metadata.cs.${quoteOrValue(containmentJson(key, candidate))}`)
.join(','),
);
}
case 'nin': {
assertNonEmptyArray(operator, key, value);
return value.reduce(
(b, candidate) => b.not('metadata', 'cs', containmentJson(key, candidate)),
builder,
);
}
default:
throw new Error(`Unsupported filter operator: "${String(operator)}"`);
}
}
function toOrLogicTerm(condition: FilterCondition): string {
const { key, operator, value } = condition;
switch (operator) {
case 'eq':
return `metadata.cs.${quoteOrValue(containmentJson(key, value))}`;
case 'ne':
return `metadata.not.cs.${quoteOrValue(containmentJson(key, value))}`;
case 'in': {
assertNonEmptyArray(operator, key, value);
return `or(${value.map((candidate) => `metadata.cs.${quoteOrValue(containmentJson(key, candidate))}`).join(',')})`;
}
case 'nin': {
assertNonEmptyArray(operator, key, value);
return `and(${value.map((candidate) => `metadata.not.cs.${quoteOrValue(containmentJson(key, candidate))}`).join(',')})`;
}
default:
throw new Error(`Unsupported filter operator: "${String(operator)}"`);
}
}
function containmentJson(key: string, value: unknown): string {
return JSON.stringify({ [key]: value });
}
function assertNonEmptyArray(
operator: string,
key: string,
value: unknown,
): asserts value is Array<string | number> {
if (!Array.isArray(value) || value.length === 0) {
throw new Error(
`Filter operator "${operator}" on key "${key}" requires a non-empty array value.`,
);
}
}
/** PostgREST logic-string values containing reserved characters must be double-quoted. */
function quoteOrValue(value: string): string {
return `"${value.replaceAll('\\', '\\\\').replaceAll('"', '\\"')}"`;
}
+2 -2
View File
@@ -284,8 +284,8 @@
"@n8n/typescript-config": "workspace:*",
"@n8n/utils": "workspace:*",
"@oracle/langchain-oracledb": "0.2.0",
"@pinecone-database/pinecone": "^5.0.2",
"@qdrant/js-client-rest": "^1.16.2",
"@pinecone-database/pinecone": "catalog:",
"@qdrant/js-client-rest": "catalog:",
"@smithy/node-http-handler": "4.5.0",
"@smithy/types": "4.13.1",
"@supabase/supabase-js": "catalog:",
+26 -2
View File
@@ -147,6 +147,12 @@ catalogs:
'@openrouter/ai-sdk-provider':
specifier: ^2.8.0
version: 2.9.0
'@pinecone-database/pinecone':
specifier: ^5.0.2
version: 5.1.2
'@qdrant/js-client-rest':
specifier: ^1.16.2
version: 1.16.2
'@rudderstack/rudder-sdk-node':
specifier: 3.0.5
version: 3.0.5
@@ -978,9 +984,21 @@ importers:
'@n8n/vitest-config':
specifier: workspace:*
version: link:../vitest-config
'@pinecone-database/pinecone':
specifier: 'catalog:'
version: 5.1.2
'@qdrant/js-client-rest':
specifier: 'catalog:'
version: 1.16.2(typescript@6.0.2)
'@supabase/supabase-js':
specifier: 'catalog:'
version: 2.50.0(bufferutil@4.0.9)(utf-8-validate@5.0.10)
'@types/json-schema':
specifier: 'catalog:'
version: 7.0.15
'@types/pg':
specifier: ^8.15.6
version: 8.20.0
'@vitest/coverage-v8':
specifier: 'catalog:'
version: 4.1.9(@vitest/browser@4.1.9)(vitest@4.1.9)
@@ -993,6 +1011,12 @@ importers:
nock:
specifier: 'catalog:'
version: 14.0.14
pg:
specifier: 'catalog:'
version: 8.17.0(pg-native@3.8.0)
tsx:
specifier: 'catalog:'
version: 4.19.3
vite:
specifier: 'catalog:'
version: 8.0.2(@types/node@20.19.41)(esbuild@0.28.1)(jiti@2.6.1)(sass-embedded@1.98.0)(sass@1.98.0)(terser@5.16.1)(tsx@4.19.3)(yaml@2.8.3)
@@ -2957,10 +2981,10 @@ importers:
specifier: 0.2.0
version: 0.2.0(@langchain/core@1.2.0(@opentelemetry/api@1.9.0)(@opentelemetry/exporter-trace-otlp-proto@0.217.0(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.7.1(@opentelemetry/api@1.9.0))(openai@6.34.0(ws@8.21.0(bufferutil@4.0.9)(utf-8-validate@5.0.10))(zod@3.25.67))(ws@8.21.0(bufferutil@4.0.9)(utf-8-validate@5.0.10)))(@langchain/textsplitters@1.0.1(@langchain/core@1.2.0(@opentelemetry/api@1.9.0)(@opentelemetry/exporter-trace-otlp-proto@0.217.0(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.7.1(@opentelemetry/api@1.9.0))(openai@6.34.0(ws@8.21.0(bufferutil@4.0.9)(utf-8-validate@5.0.10))(zod@3.25.67))(ws@8.21.0(bufferutil@4.0.9)(utf-8-validate@5.0.10))))(oracledb@6.10.0)
'@pinecone-database/pinecone':
specifier: ^5.0.2
specifier: 'catalog:'
version: 5.1.2
'@qdrant/js-client-rest':
specifier: ^1.16.2
specifier: 'catalog:'
version: 1.16.2(typescript@6.0.2)
'@smithy/node-http-handler':
specifier: 4.5.0
+2
View File
@@ -58,6 +58,8 @@ catalog:
'@mozilla/readability': ^0.6.0
'@n8n_io/ai-assistant-sdk': 1.24.0
'@openrouter/ai-sdk-provider': ^2.8.0
'@pinecone-database/pinecone': ^5.0.2
'@qdrant/js-client-rest': ^1.16.2
'@rudderstack/rudder-sdk-node': 3.0.5
'@sentry/node': ^10.55.0
'@stryker-mutator/core': 9.6.1