mirror of
https://github.com/n8n-io/n8n.git
synced 2026-08-30 18:01:23 +08:00
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:
@@ -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 }]);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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];
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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('"', '\\"')}"`;
|
||||
}
|
||||
@@ -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:",
|
||||
|
||||
Generated
+26
-2
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user