first commit
Security: Sync from Public / sync-from-public (push) Has been cancelled
Test: Benchmark Nightly / build (push) Has been cancelled
Test: Benchmark Nightly / Notify Cats on failure (push) Has been cancelled
CI: Python / Checks (push) Has been cancelled
Test: Evals Python / Workflow Comparison Python (push) Has been cancelled
Util: Check Docs URLs / check-docs-urls (push) Has been cancelled
Test: Visual Storybook / Cloudflare Pages (push) Has been cancelled
Test: E2E Performance / build-and-test-performance (push) Has been cancelled
Test: Workflows Nightly / Run Workflow Tests (push) Has been cancelled
Util: Cleanup CI Docker Images / Delete stale CI images (push) Has been cancelled
Test: Benchmark Destroy Env / build (push) Has been cancelled
Util: Update Node Popularity / update-popularity (push) Has been cancelled
Test: E2E Coverage Weekly / Coverage Tests (push) Has been cancelled

This commit is contained in:
2026-03-17 16:22:57 +03:30
commit 3d5eaf9445
15349 changed files with 2847338 additions and 0 deletions
@@ -0,0 +1,393 @@
import { AzureKeyCredential, SearchIndexClient } from '@azure/search-documents';
import { AzureAISearchVectorStore } from '@langchain/community/vectorstores/azure_aisearch';
import { mock } from 'jest-mock-extended';
import type {
ISupplyDataFunctions,
ILoadOptionsFunctions,
INode,
IExecuteFunctions,
} from 'n8n-workflow';
import {
VectorStoreAzureAISearch,
getIndexName,
clearAzureSearchIndex,
transformDocumentsForAzure,
} from './VectorStoreAzureAISearch.node';
jest.mock('@langchain/community/vectorstores/azure_aisearch');
jest.mock('@azure/identity');
jest.mock('@azure/search-documents');
const MockedSearchIndexClient = SearchIndexClient as jest.MockedClass<typeof SearchIndexClient>;
describe('VectorStoreAzureAISearch', () => {
const vectorStore = new VectorStoreAzureAISearch();
const helpers = mock<ISupplyDataFunctions['helpers']>();
const executeFunctions = mock<ISupplyDataFunctions>({ helpers });
beforeEach(() => {
jest.resetAllMocks();
executeFunctions.addInputData.mockReturnValue({ index: 0 });
});
it('should get vector store client with API key authentication', async () => {
executeFunctions.getNodeParameter.mockImplementation((paramName: string) => {
switch (paramName) {
case 'mode':
return 'retrieve';
case 'indexName':
return 'test-index';
case 'options':
return {};
default:
return undefined;
}
});
executeFunctions.getCredentials.mockResolvedValue({
authType: 'apiKey',
endpoint: 'https://test-search.search.windows.net',
apiKey: 'test-api-key',
});
const mockEmbeddings = {};
executeFunctions.getInputConnectionData.mockResolvedValue(mockEmbeddings);
const { response } = await vectorStore.supplyData.call(executeFunctions, 0);
expect(response).toBeDefined();
expect(AzureAISearchVectorStore).toHaveBeenCalledWith(mockEmbeddings, {
endpoint: 'https://test-search.search.windows.net',
indexName: 'test-index',
credentials: expect.any(AzureKeyCredential),
search: {
type: expect.any(String),
},
clientOptions: {
userAgentOptions: { userAgentPrefix: 'n8n-azure-ai-search' },
},
});
});
it('should configure search options with filter and semantic configuration', async () => {
executeFunctions.getNodeParameter.mockImplementation((paramName: string) => {
switch (paramName) {
case 'mode':
return 'retrieve';
case 'indexName':
return 'test-index';
case 'options':
return {
queryType: 'semanticHybrid',
filter: "category eq 'tech'",
semanticConfiguration: 'test-semantic-config',
};
default:
return undefined;
}
});
executeFunctions.getCredentials.mockResolvedValue({
authType: 'apiKey',
endpoint: 'https://test-search.search.windows.net',
apiKey: 'test-api-key',
});
const mockEmbeddings = {};
executeFunctions.getInputConnectionData.mockResolvedValue(mockEmbeddings);
const { response } = await vectorStore.supplyData.call(executeFunctions, 0);
expect(response).toBeDefined();
expect(AzureAISearchVectorStore).toHaveBeenCalledWith(mockEmbeddings, {
endpoint: 'https://test-search.search.windows.net',
indexName: 'test-index',
credentials: expect.any(AzureKeyCredential),
search: {
type: expect.any(String),
filter: "category eq 'tech'",
semanticConfigurationName: 'test-semantic-config',
},
clientOptions: {
userAgentOptions: { userAgentPrefix: 'n8n-azure-ai-search' },
},
});
});
it('should handle ILoadOptionsFunctions context correctly', () => {
const loadOptionsFunctions = mock<ILoadOptionsFunctions>();
const mockNode = mock<INode>();
loadOptionsFunctions.getNode.mockReturnValue(mockNode);
loadOptionsFunctions.getNodeParameter.mockImplementation((paramName: string) => {
if (paramName === 'indexName') {
return 'test-load-options-index';
}
return undefined;
});
const indexName = getIndexName(loadOptionsFunctions, 0);
expect(indexName).toBe('test-load-options-index');
// Verify getNodeParameter was called with correct signature (no itemIndex)
expect(loadOptionsFunctions.getNodeParameter).toHaveBeenCalledWith('indexName', '', {
extractValue: true,
});
});
describe('clearIndex functionality', () => {
const mockDeleteIndex = jest.fn().mockResolvedValue(undefined);
const mockContext = mock<IExecuteFunctions>();
const mockLogger = { debug: jest.fn() };
beforeEach(() => {
jest.clearAllMocks();
// Setup mock for SearchIndexClient
MockedSearchIndexClient.mockImplementation(
() =>
({
deleteIndex: mockDeleteIndex,
}) as unknown as SearchIndexClient,
);
// Setup common mocks for context
mockContext.getCredentials.mockResolvedValue({
endpoint: 'https://test-search.search.windows.net',
apiKey: 'test-api-key',
});
mockContext.getNode.mockReturnValue({
id: 'test-node-id',
name: 'Azure AI Search',
type: 'vectorStoreAzureAISearch',
typeVersion: 1,
position: [0, 0],
parameters: {},
});
mockContext.logger = mockLogger as unknown as IExecuteFunctions['logger'];
});
it('should delete index when clearIndex is true', async () => {
mockContext.getNodeParameter.mockImplementation((paramName: string) => {
switch (paramName) {
case 'indexName':
return 'test-index';
case 'options':
return { clearIndex: true };
default:
return undefined;
}
});
const result = await clearAzureSearchIndex(mockContext, 0);
// Verify the function returned true (index was deleted)
expect(result).toBe(true);
// Verify SearchIndexClient was instantiated with correct credentials
expect(MockedSearchIndexClient).toHaveBeenCalledWith(
'https://test-search.search.windows.net',
expect.any(AzureKeyCredential),
);
// Verify deleteIndex was called with the correct index name
expect(mockDeleteIndex).toHaveBeenCalledWith('test-index');
// Verify debug log was called
expect(mockLogger.debug).toHaveBeenCalledWith('Deleted Azure AI Search index: test-index');
});
it('should NOT delete index when clearIndex is false', async () => {
mockContext.getNodeParameter.mockImplementation((paramName: string) => {
switch (paramName) {
case 'indexName':
return 'test-index';
case 'options':
return { clearIndex: false };
default:
return undefined;
}
});
const result = await clearAzureSearchIndex(mockContext, 0);
// Verify the function returned false (index was not deleted)
expect(result).toBe(false);
// Verify SearchIndexClient was NOT instantiated
expect(MockedSearchIndexClient).not.toHaveBeenCalled();
// Verify deleteIndex was NOT called
expect(mockDeleteIndex).not.toHaveBeenCalled();
});
it('should NOT delete index when clearIndex option is not provided', async () => {
mockContext.getNodeParameter.mockImplementation((paramName: string) => {
switch (paramName) {
case 'indexName':
return 'test-index';
case 'options':
return {};
default:
return undefined;
}
});
const result = await clearAzureSearchIndex(mockContext, 0);
// Verify the function returned false (index was not deleted)
expect(result).toBe(false);
// Verify SearchIndexClient was NOT instantiated
expect(MockedSearchIndexClient).not.toHaveBeenCalled();
// Verify deleteIndex was NOT called
expect(mockDeleteIndex).not.toHaveBeenCalled();
});
it('should return false and log error when deleteIndex fails', async () => {
mockContext.getNodeParameter.mockImplementation((paramName: string) => {
switch (paramName) {
case 'indexName':
return 'test-index';
case 'options':
return { clearIndex: true };
default:
return undefined;
}
});
// Make deleteIndex throw an error
mockDeleteIndex.mockRejectedValueOnce(new Error('Index not found'));
const result = await clearAzureSearchIndex(mockContext, 0);
// Verify the function returned false (deletion failed gracefully)
expect(result).toBe(false);
// Verify the error was logged
expect(mockLogger.debug).toHaveBeenCalledWith('Error deleting index (may not exist):', {
message: 'Index not found',
});
});
});
describe('transformDocumentsForAzure', () => {
it('should transform metadata into attributes array with specified keys', () => {
const documents = [
{
pageContent: 'test content',
metadata: {
source: 'test.pdf',
author: 'John Doe',
category: 'tech',
unused: 'field',
},
},
];
const result = transformDocumentsForAzure(documents, ['source', 'author']);
expect(result[0].metadata.attributes).toEqual([
{ key: 'source', value: 'test.pdf' },
{ key: 'author', value: 'John Doe' },
]);
// Original metadata should be preserved
expect(result[0].metadata.source).toBe('test.pdf');
expect(result[0].metadata.author).toBe('John Doe');
expect(result[0].metadata.category).toBe('tech');
expect(result[0].metadata.unused).toBe('field');
});
it('should include all metadata keys when metadataKeysToInclude is empty', () => {
const documents = [
{
pageContent: 'test content',
metadata: { source: 'test.pdf', page: 1 },
},
];
const result = transformDocumentsForAzure(documents, []);
expect(result[0].metadata.attributes).toHaveLength(2);
expect(result[0].metadata.attributes).toContainEqual({ key: 'source', value: 'test.pdf' });
expect(result[0].metadata.attributes).toContainEqual({ key: 'page', value: '1' });
});
it('should filter out null and undefined values', () => {
const documents = [
{
pageContent: 'test content',
metadata: { source: 'test.pdf', author: null, page: undefined },
},
];
const result = transformDocumentsForAzure(documents, []);
expect(result[0].metadata.attributes).toEqual([{ key: 'source', value: 'test.pdf' }]);
});
it('should convert non-string values to strings', () => {
const documents = [
{
pageContent: 'test content',
metadata: { page: 42, isPublic: true, score: 0.95 },
},
];
const result = transformDocumentsForAzure(documents, []);
expect(result[0].metadata.attributes).toContainEqual({ key: 'page', value: '42' });
expect(result[0].metadata.attributes).toContainEqual({ key: 'isPublic', value: 'true' });
expect(result[0].metadata.attributes).toContainEqual({ key: 'score', value: '0.95' });
});
it('should skip keys that do not exist in metadata', () => {
const documents = [
{
pageContent: 'test content',
metadata: { source: 'test.pdf' },
},
];
const result = transformDocumentsForAzure(documents, ['source', 'nonexistent']);
expect(result[0].metadata.attributes).toEqual([{ key: 'source', value: 'test.pdf' }]);
});
it('should not mutate original documents', () => {
const originalMetadata = { source: 'test.pdf' };
const originalDoc = {
pageContent: 'test content',
metadata: originalMetadata,
};
const documents = [originalDoc];
transformDocumentsForAzure(documents, []);
expect(originalDoc.metadata).not.toHaveProperty('attributes');
expect(originalMetadata).not.toHaveProperty('attributes');
});
it('should handle empty documents array', () => {
const result = transformDocumentsForAzure([], []);
expect(result).toEqual([]);
});
it('should handle documents with empty metadata', () => {
const documents = [
{
pageContent: 'test content',
metadata: {},
},
];
const result = transformDocumentsForAzure(documents, []);
expect(result[0].metadata.attributes).toEqual([]);
});
});
});
@@ -0,0 +1,556 @@
import { AzureKeyCredential, SearchIndexClient } from '@azure/search-documents';
import {
AzureAISearchVectorStore,
AzureAISearchQueryType,
} from '@langchain/community/vectorstores/azure_aisearch';
import type { Document } from '@langchain/core/documents';
import type { EmbeddingsInterface } from '@langchain/core/embeddings';
import {
NodeOperationError,
type IDataObject,
type ILoadOptionsFunctions,
type INodeProperties,
type IExecuteFunctions,
type ISupplyDataFunctions,
} from 'n8n-workflow';
import { createVectorStoreNode } from '@n8n/ai-utilities';
// User agent for usage tracking
const USER_AGENT_PREFIX = 'n8n-azure-ai-search';
export const AZURE_AI_SEARCH_CREDENTIALS = 'azureAiSearchApi';
export const INDEX_NAME = 'indexName';
export const QUERY_TYPE = 'queryType';
export const FILTER = 'filter';
export const SEMANTIC_CONFIGURATION = 'semanticConfiguration';
const indexNameField: INodeProperties = {
displayName: 'Index Name',
name: INDEX_NAME,
type: 'string',
default: 'n8n-vectorstore',
description:
'The name of the Azure AI Search index. Will be created automatically if it does not exist.',
required: true,
};
const queryTypeField: INodeProperties = {
displayName: 'Query Type',
name: QUERY_TYPE,
type: 'options',
default: 'hybrid',
description: 'The type of search query to perform',
options: [
{
name: 'Vector',
value: 'vector',
description: 'Vector similarity search only',
},
{
name: 'Hybrid',
value: 'hybrid',
description: 'Combines vector and keyword search (recommended)',
},
{
name: 'Semantic Hybrid',
value: 'semanticHybrid',
description: 'Hybrid search with semantic ranking (requires Basic tier or higher)',
},
],
};
const filterField: INodeProperties = {
displayName: 'Filter',
name: FILTER,
type: 'string',
default: '',
description:
'Filter results using OData syntax. Use metadata/fieldName for metadata fields. <a href="https://learn.microsoft.com/en-us/azure/search/search-query-odata-filter" target="_blank">Learn more</a>.',
placeholder: "metadata/category eq 'technology' and metadata/author eq 'John'",
};
const semanticConfigurationField: INodeProperties = {
displayName: 'Semantic Configuration',
name: SEMANTIC_CONFIGURATION,
type: 'string',
default: '',
description: 'Name of the semantic configuration for semantic ranking (optional)',
displayOptions: {
show: {
[QUERY_TYPE]: ['semanticHybrid'],
},
},
};
const sharedFields: INodeProperties[] = [indexNameField];
const retrieveFields: INodeProperties[] = [
{
displayName: 'Options',
name: 'options',
type: 'collection',
placeholder: 'Add Option',
default: {},
options: [queryTypeField, filterField, semanticConfigurationField],
},
];
const insertFields: INodeProperties[] = [
{
displayName: 'Options',
name: 'options',
type: 'collection',
placeholder: 'Add Option',
default: {},
options: [
{
displayName: 'Clear Index',
name: 'clearIndex',
type: 'boolean',
default: false,
description:
'Whether to delete and recreate the index before inserting new data. Warning: This will reset any custom index configuration (semantic ranking, analyzers, etc.) to defaults.',
},
{
displayName: 'Metadata Keys to Insert',
name: 'metadataKeysToInsert',
type: 'string',
default: '',
placeholder: 'e.g., source,author,category',
description:
'Comma-separated list of metadata keys to store in Azure AI Search. Leave empty to include all metadata. Azure AI Search stores metadata in an "attributes" array format.',
},
],
},
];
type IFunctionsContext = IExecuteFunctions | ISupplyDataFunctions | ILoadOptionsFunctions;
function isExecutionContext(
context: IFunctionsContext,
): context is IExecuteFunctions | ISupplyDataFunctions {
// IExecuteFunctions and ISupplyDataFunctions have addInputData method
// ILoadOptionsFunctions does not
return 'addInputData' in context;
}
function getParameter(key: string, context: IFunctionsContext, itemIndex: number): string {
let value: unknown;
if (isExecutionContext(context)) {
// Execution context: includes itemIndex parameter
value = context.getNodeParameter(key, itemIndex, '', { extractValue: true });
} else {
// Load options context: no itemIndex parameter
value = context.getNodeParameter(key, '', { extractValue: true });
}
if (typeof value !== 'string') {
throw new NodeOperationError(context.getNode(), `Parameter ${key} must be a string`);
}
return value;
}
export const getIndexName = getParameter.bind(null, INDEX_NAME);
function getOptionValue<T>(
name: string,
context: IExecuteFunctions | ISupplyDataFunctions,
itemIndex: number,
defaultValue?: T,
): T | undefined {
const options: IDataObject = context.getNodeParameter('options', itemIndex, {});
return options[name] !== undefined ? (options[name] as T) : defaultValue;
}
interface ValidatedCredentials {
endpoint: string;
apiKey: string;
}
async function getValidatedCredentials(
context: IFunctionsContext,
itemIndex: number,
): Promise<ValidatedCredentials> {
const credentials = await context.getCredentials(AZURE_AI_SEARCH_CREDENTIALS);
if (!credentials.endpoint || typeof credentials.endpoint !== 'string') {
throw new NodeOperationError(
context.getNode(),
'Azure AI Search endpoint is missing or invalid',
{ itemIndex },
);
}
if (!credentials.apiKey || typeof credentials.apiKey !== 'string') {
throw new NodeOperationError(context.getNode(), 'API Key is required for authentication', {
itemIndex,
});
}
return {
endpoint: credentials.endpoint,
apiKey: credentials.apiKey,
};
}
/**
* Deletes an Azure AI Search index if clearIndex option is enabled.
* Exported for testing purposes.
*/
export async function clearAzureSearchIndex(
context: IFunctionsContext,
itemIndex: number,
): Promise<boolean> {
const options = context.getNodeParameter('options', itemIndex, {}) as {
clearIndex?: boolean;
};
if (!options.clearIndex) {
return false;
}
const credentials = await getValidatedCredentials(context, itemIndex);
const indexName = getIndexName(context, itemIndex);
try {
const indexClient = new SearchIndexClient(
credentials.endpoint,
new AzureKeyCredential(credentials.apiKey),
);
await indexClient.deleteIndex(indexName);
context.logger.debug(`Deleted Azure AI Search index: ${indexName}`);
return true;
} catch (deleteError) {
// Log the error but don't fail - index might not exist yet
context.logger.debug('Error deleting index (may not exist):', {
message: deleteError instanceof Error ? deleteError.message : String(deleteError),
});
return false;
}
}
async function getAzureAISearchClient(
context: IFunctionsContext,
embeddings: EmbeddingsInterface,
itemIndex: number,
): Promise<AzureAISearchVectorStore> {
const credentials = await getValidatedCredentials(context, itemIndex);
try {
const indexName = getIndexName(context, itemIndex);
const azureCredentials = new AzureKeyCredential(credentials.apiKey);
// Pass endpoint, indexName, and credentials to enable automatic index creation
// LangChain will create the index automatically if it doesn't exist
const config: any = {
endpoint: credentials.endpoint,
indexName,
credentials: azureCredentials,
search: {},
// Add custom user agent for usage tracking
clientOptions: {
userAgentOptions: { userAgentPrefix: USER_AGENT_PREFIX },
},
};
// Set search configuration options only for execution contexts
if (isExecutionContext(context)) {
const queryType = getQueryType(context, itemIndex);
const semanticConfiguration = getOptionValue<string>(
'semanticConfiguration',
context,
itemIndex,
);
const filter = getOptionValue<string>('filter', context, itemIndex);
config.search.type = queryType;
if (filter) {
config.search.filter = filter;
}
if (queryType === AzureAISearchQueryType.SemanticHybrid && semanticConfiguration) {
config.search.semanticConfigurationName = semanticConfiguration;
}
}
return new AzureAISearchVectorStore(embeddings, config);
} catch (error) {
if (error instanceof NodeOperationError) {
throw error;
}
// Log the full error for debugging
context.logger.debug('Azure AI Search connection error:', {
message: error instanceof Error ? error.message : String(error),
code: (error as any).code,
statusCode: (error as any).statusCode,
details: (error as any).details,
});
// Check for authentication errors
if (
error.message?.includes('401') ||
error.message?.includes('Unauthorized') ||
error.message?.includes('authentication failed')
) {
throw new NodeOperationError(
context.getNode(),
'Authentication failed - invalid API key or endpoint.',
{
itemIndex,
description:
'Please verify your API Key and Search Endpoint are correct in the credentials configuration.',
},
);
}
// Check for authorization errors (403)
if (error.message?.includes('403') || error.message?.includes('Forbidden')) {
throw new NodeOperationError(
context.getNode(),
'Authorization failed - insufficient permissions.',
{
itemIndex,
description:
'The API Key does not have sufficient permissions. Ensure the key has the required access level for this operation.',
},
);
}
const errorMessage = error instanceof Error ? error.message : String(error);
throw new NodeOperationError(context.getNode(), `Error: ${errorMessage}`, {
itemIndex,
description: 'Please check your Azure AI Search connection details',
});
}
}
function getQueryType(
context: IExecuteFunctions | ISupplyDataFunctions,
itemIndex: number,
): AzureAISearchQueryType {
const queryType = getOptionValue<string>('queryType', context, itemIndex, 'hybrid');
switch (queryType) {
case 'vector':
return AzureAISearchQueryType.Similarity;
case 'hybrid':
return AzureAISearchQueryType.SimilarityHybrid;
case 'semanticHybrid':
return AzureAISearchQueryType.SemanticHybrid;
default:
return AzureAISearchQueryType.SimilarityHybrid;
}
}
interface AzureMetadataAttribute {
key: string;
value: string;
}
/**
* Transforms document metadata into Azure AI Search's expected format.
* Azure AI Search requires metadata to be stored in an 'attributes' array
* with {key, value} pairs where values are strings.
*
* @param documents - Array of documents to transform
* @param metadataKeysToInclude - Optional array of specific keys to include. If empty, includes all keys.
* @returns Documents with transformed metadata
*/
export function transformDocumentsForAzure(
documents: Array<Document<Record<string, unknown>>>,
metadataKeysToInclude: string[],
): Array<Document<Record<string, unknown>>> {
return documents.map((doc) => {
const originalMetadata = doc.metadata;
const keysToProcess =
metadataKeysToInclude.length > 0 ? metadataKeysToInclude : Object.keys(originalMetadata);
const attributes: AzureMetadataAttribute[] = keysToProcess
.filter(
(key) =>
Object.prototype.hasOwnProperty.call(originalMetadata, key) &&
originalMetadata[key] !== null &&
originalMetadata[key] !== undefined,
)
.map((key) => ({
key,
value: String(originalMetadata[key]),
}));
return {
...doc,
metadata: {
...originalMetadata,
attributes,
},
};
});
}
export class VectorStoreAzureAISearch extends createVectorStoreNode({
meta: {
displayName: 'Azure AI Search Vector Store',
name: 'vectorStoreAzureAISearch',
description: 'Work with your data in Azure AI Search Vector Store',
icon: { light: 'file:azure-aisearch.svg', dark: 'file:azure-aisearch.svg' },
docsUrl:
'https://docs.n8n.io/integrations/builtin/cluster-nodes/root-nodes/n8n-nodes-langchain.vectorstoreazureaisearch/',
credentials: [
{
name: 'azureAiSearchApi',
required: true,
},
],
operationModes: ['load', 'insert', 'retrieve', 'update', 'retrieve-as-tool'],
},
sharedFields,
retrieveFields,
loadFields: retrieveFields,
insertFields,
async beforeInsert(context, _embeddings, itemIndex) {
await clearAzureSearchIndex(context, itemIndex);
},
async getVectorStoreClient(context, _filter, embeddings, itemIndex) {
const vectorStore = await getAzureAISearchClient(context, embeddings, itemIndex);
// Apply OData filter to search methods if specified in options
if (isExecutionContext(context)) {
const filter = getOptionValue<string>('filter', context, itemIndex);
if (filter) {
// Per LangChain docs, pass filter as 3rd parameter with filterExpression
const filterObject = { filterExpression: filter };
// Override similaritySearchVectorWithScore - this is the method called by n8n base node
const originalSearchVectorWithScore =
vectorStore.similaritySearchVectorWithScore.bind(vectorStore);
vectorStore.similaritySearchVectorWithScore = async (
query: number[],
k: number,
additionalFilter?: any,
) => {
// Merge our OData filter with any additional filter passed by the caller
const mergedFilter = additionalFilter
? { ...filterObject, ...additionalFilter }
: filterObject;
return await originalSearchVectorWithScore(query, k, mergedFilter);
};
// Override similaritySearch to pass filter as 3rd parameter
const originalSearch = vectorStore.similaritySearch.bind(vectorStore);
vectorStore.similaritySearch = async (query: string, k?: number) => {
return await originalSearch(query, k, filterObject);
};
// Override similaritySearchWithScore to pass filter as 3rd parameter
const originalSearchWithScore = vectorStore.similaritySearchWithScore.bind(vectorStore);
vectorStore.similaritySearchWithScore = async (query: string, k?: number) => {
return await originalSearchWithScore(query, k, filterObject);
};
// Override asRetriever to inject filter into retriever options
const originalAsRetriever = vectorStore.asRetriever.bind(vectorStore);
vectorStore.asRetriever = (kwargs?: any) => {
return originalAsRetriever({
...kwargs,
filter: filterObject,
});
};
}
}
return vectorStore;
},
async populateVectorStore(context, embeddings, documents, itemIndex) {
try {
const metadataKeysToInsertRaw = getOptionValue<string>(
'metadataKeysToInsert',
context,
itemIndex,
);
const metadataKeysToInsert = metadataKeysToInsertRaw
? metadataKeysToInsertRaw
.split(',')
.map((k) => k.trim())
.filter((k) => k.length > 0)
: [];
const transformedDocuments = transformDocumentsForAzure(documents, metadataKeysToInsert);
// Get vector store client (will auto-create index if it doesn't exist)
const vectorStore = await getAzureAISearchClient(context, embeddings, itemIndex);
// Add documents to Azure AI Search (framework handles batching)
await vectorStore.addDocuments(transformedDocuments);
} catch (error) {
// Log the full error for debugging
context.logger.debug('Azure AI Search error details:', {
message: error instanceof Error ? error.message : String(error),
code: (error as any).code,
statusCode: (error as any).statusCode,
details: (error as any).details,
stack: error instanceof Error ? error.stack : undefined,
});
// Check for authentication errors
if (
error.message?.includes('401') ||
error.message?.includes('Unauthorized') ||
error.message?.includes('authentication failed')
) {
throw new NodeOperationError(
context.getNode(),
'Authentication failed during document upload - invalid API key or endpoint.',
{
itemIndex,
description:
'Please verify your API Key and Search Endpoint are correct in the credentials configuration.',
},
);
}
// Check for authorization errors
if (
error.message?.includes('403') ||
error.message?.includes('Forbidden') ||
(error as any).statusCode === 403
) {
throw new NodeOperationError(
context.getNode(),
'Authorization failed - insufficient permissions for document upload.',
{
itemIndex,
description:
'The API Key does not have sufficient permissions for write operations. Ensure the key has the required access level.',
},
);
}
// Check for RestError (common Azure SDK error)
if ((error as any).name === 'RestError' || error.message?.includes('RestError')) {
const statusCode = (error as any).statusCode || 'unknown';
const errorCode = (error as any).code || 'unknown';
const errorMessage = error instanceof Error ? error.message : String(error);
throw new NodeOperationError(
context.getNode(),
`Azure AI Search API error (${statusCode}): ${errorMessage}`,
{
itemIndex,
description: `Error code: ${errorCode}\n\nCommon causes:\n- Invalid endpoint URL\n- Index doesn't exist\n- Authentication/authorization issues\n- API version mismatch\n\nCheck the console logs for detailed error information.`,
},
);
}
const errorMessage = error instanceof Error ? error.message : String(error);
throw new NodeOperationError(context.getNode(), `Error: ${errorMessage}`, {
itemIndex,
description: 'Please check your Azure AI Search connection details and index configuration',
});
}
},
}) {}
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 18 18"><defs><linearGradient id="a" x1="9" y1=".36" x2="9" y2="18.31" gradientUnits="userSpaceOnUse"><stop offset=".18" stop-color="#5ea0ef"/><stop offset="1" stop-color="#0078d4"/></linearGradient></defs><path d="M18 11.32a4.12 4.12 0 00-3.51-4 5.15 5.15 0 00-5.25-5 5.25 5.25 0 00-5 3.49A4.86 4.86 0 000 10.59a5 5 0 005.07 4.82h8.65a.78.78 0 00.22 0A4.13 4.13 0 0018 11.32z" fill="url(#a)"/><path d="M12.33 6.59a3.07 3.07 0 00-5.61.85 3.16 3.16 0 00.33 2.27l-2.34 2.37a.79.79 0 000 1.12.78.78 0 00.56.23.76.76 0 00.56-.23l2.33-2.36a3.14 3.14 0 00.81.33 3.08 3.08 0 003.36-4.58zm-.54 2.1a2.16 2.16 0 01-2.09 1.65 1.79 1.79 0 01-.51-.07 1.87 1.87 0 01-.7-.32 2.13 2.13 0 01-.56-.56 2.17 2.17 0 01-.31-1.73A2.14 2.14 0 019.7 6a2.31 2.31 0 01.52.06 2.18 2.18 0 011.32 1 2.13 2.13 0 01.25 1.63z" fill="#f2f2f2"/><ellipse cx="9.69" cy="8.18" rx="2.15" ry="2.16" fill="#83b9f9"/></svg>

After

Width:  |  Height:  |  Size: 933 B

@@ -0,0 +1,294 @@
import { mock } from 'jest-mock-extended';
import { ChromaClient, CloudClient } from 'chromadb';
import { Chroma } from '@langchain/community/vectorstores/chroma';
import type { ISupplyDataFunctions } from 'n8n-workflow';
import * as ChromaNode from './VectorStoreChromaDB.node';
// Mock external modules
jest.mock('chromadb', () => {
return {
ChromaClient: jest.fn(),
CloudClient: jest.fn(),
};
});
jest.mock('@langchain/community/vectorstores/chroma', () => {
const state: { ctorArgs?: unknown[] } = { ctorArgs: undefined };
class Chroma {
static fromDocuments = jest.fn();
static fromExistingCollection = jest.fn();
similaritySearchVectorWithScore = jest.fn();
constructor(...args: unknown[]) {
state.ctorArgs = args;
}
}
return { Chroma, __state: state };
});
jest.mock(
'@n8n/ai-utilities',
() => ({
createVectorStoreNode: (config: {
getVectorStoreClient: (...args: unknown[]) => unknown;
populateVectorStore: (...args: unknown[]) => unknown;
methods: {
listSearch: {
chromaCollectionsSearch: (
this: ISupplyDataFunctions,
) => Promise<{ results: Array<{ name: string; value: string }> }>;
};
};
}) =>
class BaseNode {
async getVectorStoreClient(...args: unknown[]) {
return config.getVectorStoreClient.apply(config, args);
}
async populateVectorStore(...args: unknown[]) {
return config.populateVectorStore.apply(config, args);
}
async chromaCollectionsSearch(...args: unknown[]) {
return await config.methods.listSearch.chromaCollectionsSearch.apply(
this as any,
args as any,
);
}
},
metadataFilterField: {},
}),
{ virtual: true },
);
jest.mock('../shared/descriptions', () => ({ chromaCollectionRLC: {} }), { virtual: true });
const MockChromaClient = ChromaClient as jest.MockedClass<typeof ChromaClient>;
const MockCloudClient = CloudClient as jest.MockedClass<typeof CloudClient>;
const MockChroma = Chroma as jest.MockedClass<typeof Chroma>;
describe('VectorStoreChromaDB.node', () => {
const helpers = mock<ISupplyDataFunctions['helpers']>();
const dataFunctions = mock<ISupplyDataFunctions>({ helpers });
dataFunctions.logger = {
info: jest.fn(),
debug: jest.fn(),
error: jest.fn(),
warn: jest.fn(),
verbose: jest.fn(),
} as unknown as ISupplyDataFunctions['logger'];
const selfHostedCredentials = {
authentication: 'apiKey',
baseUrl: 'http://localhost:8000',
apiKey: 'test-api-key',
};
const cloudCredentials = {
authentication: 'chromaCloudApi',
apiKey: 'cloud-api-key',
tenant: 'test-tenant',
database: 'test-database',
};
const mockChromaClientInstance = {
deleteCollection: jest.fn(),
listCollections: jest.fn(),
};
const mockCloudClientInstance = {
deleteCollection: jest.fn(),
listCollections: jest.fn(),
};
beforeEach(() => {
jest.resetAllMocks();
MockChromaClient.mockReturnValue(mockChromaClientInstance as unknown as ChromaClient);
MockCloudClient.mockReturnValue(mockCloudClientInstance as unknown as CloudClient);
});
describe('getVectorStoreClient', () => {
it('should create self-hosted client correctly', async () => {
const mockEmbeddings = {};
const mockVectorStore = {
similaritySearchVectorWithScore: jest.fn(),
};
MockChroma.fromExistingCollection = jest.fn().mockResolvedValue(mockVectorStore);
const context = {
getCredentials: jest.fn().mockResolvedValue(selfHostedCredentials),
getNodeParameter: jest.fn((name: string) => {
if (name === 'chromaCollection') return 'test-collection';
if (name === 'authentication') return 'chromaSelfHostedApi';
return undefined;
}),
getNode: () => ({
name: 'VectorStoreChromaDB',
credentials: { chromaSelfHostedApi: {} },
}),
logger: dataFunctions.logger,
} as never;
const node = new ChromaNode.VectorStoreChromaDB();
await (node as any).getVectorStoreClient(context, undefined, mockEmbeddings, 0);
expect(MockChroma.fromExistingCollection).toHaveBeenCalledWith(
mockEmbeddings,
expect.objectContaining({
collectionName: 'test-collection',
clientParams: expect.objectContaining({
host: 'localhost',
port: 8000,
ssl: false,
headers: { Authorization: 'Bearer test-api-key' },
}),
}),
);
});
it('should create cloud client correctly', async () => {
const mockEmbeddings = {};
const mockVectorStore = {};
MockChroma.fromExistingCollection = jest.fn().mockResolvedValue(mockVectorStore);
const context = {
getCredentials: jest.fn().mockResolvedValue(cloudCredentials),
getNodeParameter: jest.fn((name: string) => {
if (name === 'chromaCollection') return 'test-collection';
if (name === 'authentication') return 'chromaCloudApi';
return undefined;
}),
getNode: () => ({
name: 'VectorStoreChromaDB',
credentials: { chromaCloudApi: {} },
}),
logger: dataFunctions.logger,
} as never;
const node = new ChromaNode.VectorStoreChromaDB();
await (node as any).getVectorStoreClient(context, undefined, mockEmbeddings, 0);
expect(MockChroma.fromExistingCollection).toHaveBeenCalledWith(
mockEmbeddings,
expect.objectContaining({
collectionName: 'test-collection',
clientParams: {
apiKey: 'cloud-api-key',
tenant: 'test-tenant',
database: 'test-database',
},
}),
);
});
});
describe('populateVectorStore', () => {
it('should populate vector store and clear collection if requested', async () => {
const mockEmbeddings = {};
const mockDocuments = [{ pageContent: 'test', metadata: {} }];
MockChroma.fromDocuments = jest.fn().mockResolvedValue(undefined);
const context = {
getCredentials: jest.fn().mockResolvedValue(selfHostedCredentials),
getNodeParameter: jest.fn((name: string) => {
if (name === 'chromaCollection') return 'test-collection';
if (name === 'options') return { clearCollection: true };
if (name === 'authentication') return 'chromaSelfHostedApi';
return undefined;
}),
getNode: () => ({
name: 'VectorStoreChromaDB',
credentials: { chromaSelfHostedApi: {} },
}),
logger: dataFunctions.logger,
} as never;
const node = new ChromaNode.VectorStoreChromaDB();
await (node as any).populateVectorStore(context, mockEmbeddings, mockDocuments, 0);
expect(MockChromaClient).toHaveBeenCalled();
expect(mockChromaClientInstance.deleteCollection).toHaveBeenCalledWith({
name: 'test-collection',
});
expect(MockChroma.fromDocuments).toHaveBeenCalled();
});
it('should not clear collection if not requested', async () => {
const mockEmbeddings = {};
const mockDocuments = [{ pageContent: 'test', metadata: {} }];
MockChroma.fromDocuments = jest.fn().mockResolvedValue(undefined);
const context = {
getCredentials: jest.fn().mockResolvedValue(selfHostedCredentials),
getNodeParameter: jest.fn((name: string) => {
if (name === 'chromaCollection') return 'test-collection';
if (name === 'options') return { clearCollection: false };
if (name === 'authentication') return 'chromaSelfHostedApi';
return undefined;
}),
getNode: () => ({
name: 'VectorStoreChromaDB',
credentials: { chromaSelfHostedApi: {} },
}),
logger: dataFunctions.logger,
} as never;
const node = new ChromaNode.VectorStoreChromaDB();
await (node as any).populateVectorStore(context, mockEmbeddings, mockDocuments, 0);
expect(mockChromaClientInstance.deleteCollection).not.toHaveBeenCalled();
expect(MockChroma.fromDocuments).toHaveBeenCalled();
});
});
describe('listSearch', () => {
it('should list collections for self-hosted', async () => {
const collections = [{ name: 'Collection1' }, { name: 'Collection2' }];
mockChromaClientInstance.listCollections.mockResolvedValue(collections as any);
const context = {
getCredentials: jest.fn().mockResolvedValue(selfHostedCredentials),
getNodeParameter: jest.fn((name: string) => {
if (name === 'authentication') return 'chromaSelfHostedApi';
return undefined;
}),
getNode: () => ({
name: 'VectorStoreChromaDB',
credentials: { chromaSelfHostedApi: {} },
}),
} as never;
const node = new ChromaNode.VectorStoreChromaDB();
const result = await (node as any).chromaCollectionsSearch.call(context);
expect(result).toEqual({
results: [
{ name: 'Collection1', value: 'Collection1' },
{ name: 'Collection2', value: 'Collection2' },
],
});
});
it('should handle authentication errors', async () => {
mockChromaClientInstance.listCollections.mockRejectedValue(new Error('401 Unauthorized'));
const context = {
getCredentials: jest.fn().mockResolvedValue(selfHostedCredentials),
getNodeParameter: jest.fn((name: string) => {
if (name === 'authentication') return 'chromaSelfHostedApi';
return undefined;
}),
getNode: () => ({
name: 'VectorStoreChromaDB',
credentials: { chromaSelfHostedApi: {} },
}),
} as never;
const node = new ChromaNode.VectorStoreChromaDB();
await expect((node as any).chromaCollectionsSearch.call(context)).rejects.toThrow(
'Authentication failed',
);
});
});
});
@@ -0,0 +1,466 @@
import type { ChromaLibArgs } from '@langchain/community/vectorstores/chroma';
import { Chroma } from '@langchain/community/vectorstores/chroma';
import type { Document } from '@langchain/core/documents';
import { ChromaClient, CloudClient, type Collection } from 'chromadb';
import {
NodeOperationError,
type INodeProperties,
type IExecuteFunctions,
type ILoadOptionsFunctions,
type ISupplyDataFunctions,
NodeApiError,
ApplicationError,
} from 'n8n-workflow';
import { metadataFilterField, createVectorStoreNode } from '@n8n/ai-utilities';
import { chromaCollectionRLC } from '../shared/descriptions';
interface ChromaError extends Error {
response?: {
data?: {
detail?: string;
};
};
}
/**
* Gets the credential type based on what credentials are actually configured on the node
* Falls back to the authentication parameter if no credentials are found
*/
function getCredentialType(
context: IExecuteFunctions | ILoadOptionsFunctions | ISupplyDataFunctions,
): string {
try {
const authentication = context.getNodeParameter('authentication', 0);
if (typeof authentication === 'string') {
return authentication;
}
} catch (error) {
// Fallback to credentials if parameter retrieval fails
}
const node = context.getNode();
// Check which credential type is actually configured on the node
if (node.credentials?.chromaCloudApi) {
return 'chromaCloudApi';
}
if (node.credentials?.chromaSelfHostedApi) {
return 'chromaSelfHostedApi';
}
return 'chromaSelfHostedApi';
}
/**
* Gets ChromaDB client configuration from credentials
* Returns either ChromaClient or CloudClient based on credential type
*/
async function getChromaClient(
context: IExecuteFunctions | ILoadOptionsFunctions | ISupplyDataFunctions,
itemIndex?: number,
): Promise<ChromaClient | CloudClient> {
const credentialType = getCredentialType(context);
const credentials = await context.getCredentials(credentialType, itemIndex);
if (credentialType === 'chromaCloudApi') {
// Use CloudClient for Chroma Cloud
const apiKey = typeof credentials.apiKey === 'string' ? credentials.apiKey : '';
const config: {
apiKey: string;
tenant?: string;
database?: string;
} = {
apiKey,
};
// Add optional tenant and database if provided
if (typeof credentials.tenant === 'string') {
config.tenant = credentials.tenant;
}
if (typeof credentials.database === 'string') {
config.database = credentials.database;
}
return new CloudClient(config);
} else {
// Use ChromaClient for self-hosted instances
const baseUrl = typeof credentials.baseUrl === 'string' ? credentials.baseUrl : '';
const authentication =
typeof credentials.authentication === 'string' ? credentials.authentication : '';
const url = new URL(baseUrl);
const config: {
host: string;
port: number;
ssl: boolean;
headers?: Record<string, string>;
} = {
host: url.hostname,
port: url.port ? parseInt(url.port, 10) : 8000,
ssl: url.protocol === 'https:',
};
if (authentication === 'apiKey' && typeof credentials.apiKey === 'string') {
config.headers = {
Authorization: `Bearer ${credentials.apiKey}`,
};
} else if (authentication === 'token' && typeof credentials.token === 'string') {
config.headers = {
'X-Chroma-Token': credentials.token,
};
}
return new ChromaClient(config);
}
}
/*
* Returns the config for the Langchain ChromaDB
*/
async function getChromaLibConfig(
context: IExecuteFunctions | ILoadOptionsFunctions | ISupplyDataFunctions,
collectionName: string,
itemIndex?: number,
): Promise<ChromaLibArgs> {
const credentialType = getCredentialType(context);
const credentials = await context.getCredentials(credentialType, itemIndex);
if (credentialType === 'chromaCloudApi') {
// Configuration for Chroma Cloud
const cloudClientParams: {
apiKey: string;
tenant?: string;
database?: string;
} = {
apiKey: typeof credentials.apiKey === 'string' ? credentials.apiKey : '',
};
if (typeof credentials.tenant === 'string') {
cloudClientParams.tenant = credentials.tenant;
}
if (typeof credentials.database === 'string') {
cloudClientParams.database = credentials.database;
}
const config: ChromaLibArgs = {
collectionName,
clientParams: cloudClientParams as ChromaLibArgs['clientParams'],
};
return config;
} else {
// Configuration for self-hosted ChromaDB
const baseUrl = typeof credentials.baseUrl === 'string' ? credentials.baseUrl : '';
const authentication =
typeof credentials.authentication === 'string' ? credentials.authentication : '';
const url = new URL(baseUrl);
const clientParams: {
host: string;
port: number;
ssl: boolean;
headers?: Record<string, string>;
} = {
host: url.hostname,
port: url.port ? parseInt(url.port, 10) : 8000,
ssl: url.protocol === 'https:',
};
if (authentication === 'apiKey' && typeof credentials.apiKey === 'string') {
clientParams.headers = {
Authorization: `Bearer ${credentials.apiKey}`,
};
} else if (authentication === 'token' && typeof credentials.token === 'string') {
clientParams.headers = {
'X-Chroma-Token': credentials.token,
};
}
const config: ChromaLibArgs = {
collectionName,
clientParams,
};
return config;
}
}
class ExtendedChroma extends Chroma {
async ensureCollection(): Promise<Collection> {
if (!this.collection) {
if (!this.index) {
const clientParams = this.clientParams ?? {};
// Check if this is a Cloud
if ('apiKey' in clientParams && typeof clientParams.apiKey === 'string') {
// Use CloudClient for Chroma Cloud
this.index = new CloudClient({
apiKey: clientParams.apiKey,
tenant: typeof clientParams.tenant === 'string' ? clientParams.tenant : undefined,
database: typeof clientParams.database === 'string' ? clientParams.database : undefined,
});
} else {
// Use ChromaClient for self-hosted instances
const { ChromaClient } = await ExtendedChroma.imports();
const clientConfig = this.url ? { path: this.url, ...clientParams } : clientParams;
this.index = new ChromaClient(clientConfig);
}
}
try {
this.collection = await this.index.getOrCreateCollection({
name: this.collectionName,
...(this.collectionMetadata && { metadata: this.collectionMetadata }),
embeddingFunction: null,
});
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
throw new ApplicationError(`Chroma getOrCreateCollection error: ${message}`);
}
}
if (!this.collection) {
throw new ApplicationError('Failed to initialize Chroma collection');
}
return this.collection;
}
async similaritySearchVectorWithScore(
query: number[],
k: number,
filter?: this['FilterType'],
): Promise<Array<[Document, number]>> {
// Handle the case where query might actually be a nested array which is usually the case.
let flatQuery: number[] = [];
if (query.length > 0 && Array.isArray(query[0])) {
// If the first element is an array, we need to flatten
for (const element of query) {
if (Array.isArray(element)) {
flatQuery.push.apply(flatQuery, element);
} else {
flatQuery.push(element);
}
}
} else {
flatQuery = query;
}
return await super.similaritySearchVectorWithScore(flatQuery, k, filter);
}
}
const authenticationProperty: INodeProperties = {
displayName: 'Authentication',
name: 'authentication',
type: 'options',
options: [
{
name: 'Self-Hosted',
value: 'chromaSelfHostedApi',
description: 'Connect to a self-hosted ChromaDB instance',
},
{
name: 'Cloud',
value: 'chromaCloudApi',
description: 'Connect to Chroma Cloud',
},
],
default: 'chromaSelfHostedApi',
};
const sharedFields: INodeProperties[] = [authenticationProperty, chromaCollectionRLC];
const retrieveFields: INodeProperties[] = [
{
displayName: 'Options',
name: 'options',
type: 'collection',
placeholder: 'Add Option',
default: {},
options: [metadataFilterField],
},
];
const insertFields: INodeProperties[] = [
{
displayName: 'Options',
name: 'options',
type: 'collection',
placeholder: 'Add Option',
default: {},
options: [
{
displayName: 'Clear Collection',
name: 'clearCollection',
type: 'boolean',
default: false,
description: 'Whether to clear the collection before inserting new data',
},
],
},
];
export class VectorStoreChromaDB extends createVectorStoreNode<ExtendedChroma>({
meta: {
displayName: 'Chroma Vector Store',
name: 'vectorStoreChromaDB',
description: 'Work with your data in Chroma Vector Store',
icon: { light: 'file:chroma.svg', dark: 'file:chroma.svg' },
docsUrl:
'https://docs.n8n.io/integrations/builtin/cluster-nodes/root-nodes/n8n-nodes-langchain.vectorstorechromadb/',
credentials: [
{
name: 'chromaSelfHostedApi',
required: true,
displayOptions: {
show: {
authentication: ['chromaSelfHostedApi'],
},
},
},
{
name: 'chromaCloudApi',
required: true,
displayOptions: {
show: {
authentication: ['chromaCloudApi'],
},
},
},
],
operationModes: ['load', 'insert', 'retrieve', 'retrieve-as-tool'],
},
methods: {
listSearch: {
async chromaCollectionsSearch(this: ILoadOptionsFunctions) {
try {
const client = await getChromaClient(this);
const collections = await client.listCollections();
if (Array.isArray(collections)) {
const results = collections.map((collection: Collection) => ({
name: collection.name,
value: collection.name,
}));
return { results };
}
return { results: [] };
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
// Check for connection errors
if (errorMessage.includes('ECONNREFUSED') || errorMessage.includes('Failed to connect')) {
throw new NodeApiError(this.getNode(), {
message:
'Cannot connect to ChromaDB. Please ensure ChromaDB is running and accessible at the configured URL.',
});
}
// Check for authentication errors
if (
errorMessage.includes('Unauthorized') ||
errorMessage.includes('401') ||
errorMessage.includes('403')
) {
throw new NodeApiError(this.getNode(), {
message:
'Authentication failed. Please check your API key or token in the credentials',
});
}
throw new NodeApiError(this.getNode(), {
message: `Failed to list ChromaDB collections: ${errorMessage}`,
});
}
},
},
},
retrieveFields,
loadFields: retrieveFields,
insertFields,
sharedFields,
async getVectorStoreClient(context, _filter, embeddings, itemIndex) {
const collection = context.getNodeParameter('chromaCollection', itemIndex, '', {
extractValue: true,
});
if (typeof collection !== 'string') {
throw new NodeOperationError(context.getNode(), 'Collection must be a string');
}
try {
const config = await getChromaLibConfig(context, collection, itemIndex);
return await ExtendedChroma.fromExistingCollection(embeddings, config);
} catch (error) {
const message = error instanceof Error ? error.message : 'Unknown error';
throw new NodeOperationError(context.getNode(), `Error connecting to ChromaDB: ${message}`, {
itemIndex,
});
}
},
async populateVectorStore(context, embeddings, documents, itemIndex) {
const collection = context.getNodeParameter('chromaCollection', itemIndex, '', {
extractValue: true,
});
if (typeof collection !== 'string') {
throw new NodeOperationError(context.getNode(), 'Collection must be a string');
}
const options = context.getNodeParameter('options', itemIndex, {});
const clearCollection = options.clearCollection === true;
if (clearCollection) {
try {
const client = await getChromaClient(context, itemIndex);
await client.deleteCollection({ name: collection });
context.logger.info(`Collection ${collection} deleted`);
} catch (error) {
context.logger.info(
`Collection ${collection} does not exist yet or could not be deleted (continuing)`,
);
}
}
try {
const config = await getChromaLibConfig(context, collection, itemIndex);
await ExtendedChroma.fromDocuments(documents, embeddings, config);
} catch (error) {
const chromaError = error as ChromaError;
const errorMessage = chromaError.message ?? 'Unknown error';
const detailMessage = chromaError.response?.data?.detail;
// Handle dimension mismatch error specifically
if (
errorMessage.includes('embedding with dimension') ||
detailMessage?.includes('embedding with dimension')
) {
const displayMessage = detailMessage ?? errorMessage;
throw new NodeOperationError(
context.getNode(),
`ChromaDB embedding dimension mismatch: ${displayMessage}`,
{
itemIndex,
description:
'The collection expects embeddings with different dimensions. Enable "Clear Collection" option to recreate the collection with correct dimensions, or use a different collection name.',
},
);
}
throw new NodeOperationError(
context.getNode(),
`Error inserting documents into ChromaDB: ${errorMessage}`,
{ itemIndex },
);
}
},
}) {}
@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg width="256px" height="164px" viewBox="0 0 256 164" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" preserveAspectRatio="xMidYMid">
<title>Chroma</title>
<g>
<ellipse fill="#FFDE2D" cx="170.666795" cy="81.9198362" rx="85.3332053" ry="81.9198362"></ellipse>
<ellipse fill="#327EFF" cx="85.3332053" cy="81.9198362" rx="85.3332053" ry="81.9198362"></ellipse>
<path d="M170.666795,81.9199642 C170.666795,127.163394 132.461431,163.83916 85.3330773,163.83916 L85.3330773,81.9199642 L170.666795,81.9199642 Z M85.3332053,81.9198362 C85.3332053,36.6767906 123.538185,8.95998209e-05 170.666795,8.95998209e-05 L170.666795,81.9198362 L85.3332053,81.9198362 Z" fill="#FF6446"></path>
</g>
</svg>

After

Width:  |  Height:  |  Size: 807 B

@@ -0,0 +1,199 @@
import type { Embeddings } from '@langchain/core/embeddings';
import type { MemoryVectorStore } from '@langchain/classic/vectorstores/memory';
import {
type INodeProperties,
type ILoadOptionsFunctions,
type INodeListSearchResult,
type IDataObject,
type NodeParameterValueType,
type IExecuteFunctions,
type ISupplyDataFunctions,
ApplicationError,
} from 'n8n-workflow';
import { createVectorStoreNode, MemoryVectorStoreManager } from '@n8n/ai-utilities';
const warningBanner: INodeProperties = {
displayName:
'<strong>For experimental use only</strong>: Data is stored in memory and will be lost if n8n restarts. Data may also be cleared if available memory gets low, and is accessible to all users of this instance. <a href="https://docs.n8n.io/integrations/builtin/cluster-nodes/root-nodes/n8n-nodes-langchain.vectorstoreinmemory/">More info</a>',
name: 'notice',
type: 'notice',
default: '',
};
const insertFields: INodeProperties[] = [
{
displayName: 'Clear Store',
name: 'clearStore',
type: 'boolean',
default: false,
description: 'Whether to clear the store before inserting new data',
},
warningBanner,
];
const DEFAULT_MEMORY_KEY = 'vector_store_key';
function getMemoryKey(context: IExecuteFunctions | ISupplyDataFunctions, itemIndex: number) {
const node = context.getNode();
if (node.typeVersion <= 1.1) {
const memoryKeyParam = context.getNodeParameter('memoryKey', itemIndex) as string;
const workflowId = context.getWorkflow().id;
return `${workflowId}__${memoryKeyParam}`;
} else {
const memoryKeyParam = context.getNodeParameter('memoryKey', itemIndex) as {
mode: string;
value: string;
};
return memoryKeyParam.value;
}
}
export class VectorStoreInMemory extends createVectorStoreNode<MemoryVectorStore>({
meta: {
displayName: 'Simple Vector Store',
name: 'vectorStoreInMemory',
description: 'The easiest way to experiment with vector stores, without external setup.',
icon: 'fa:database',
iconColor: 'black',
docsUrl:
'https://docs.n8n.io/integrations/builtin/cluster-nodes/root-nodes/n8n-nodes-langchain.vectorstoreinmemory/',
categories: ['AI'],
subcategories: {
AI: ['Vector Stores', 'Tools', 'Root Nodes'],
'Vector Stores': ['For Beginners'],
Tools: ['Other Tools'],
},
builderHint: {
relatedNodes: [
{
nodeType: '@n8n/n8n-nodes-langchain.retrieverVectorStore',
relationHint:
'Connect to enable retrieval-augmented generation (RAG) for AI Agent workflows',
},
],
},
},
sharedFields: [
{
displayName: 'Memory Key',
name: 'memoryKey',
type: 'string',
default: DEFAULT_MEMORY_KEY,
description:
'The key to use to store the vector memory in the workflow data. The key will be prefixed with the workflow ID to avoid collisions.',
displayOptions: {
show: {
'@version': [{ _cnd: { lte: 1.1 } }],
},
},
},
{
displayName: 'Memory Key',
name: 'memoryKey',
type: 'resourceLocator',
required: true,
default: { mode: 'list', value: DEFAULT_MEMORY_KEY },
description:
'The key to use to store the vector memory in the workflow data. These keys are shared between workflows.',
displayOptions: {
show: {
'@version': [{ _cnd: { gte: 1.2 } }],
},
},
modes: [
{
displayName: 'From List',
name: 'list',
type: 'list',
typeOptions: {
searchListMethod: 'vectorStoresSearch',
searchable: true,
allowNewResource: {
label: 'resourceLocator.mode.list.addNewResource.vectorStoreInMemory',
defaultName: DEFAULT_MEMORY_KEY,
method: 'createVectorStore',
},
},
},
{
displayName: 'Manual',
name: 'id',
type: 'string',
placeholder: DEFAULT_MEMORY_KEY,
},
],
},
],
methods: {
listSearch: {
async vectorStoresSearch(
this: ILoadOptionsFunctions,
filter?: string,
): Promise<INodeListSearchResult> {
const vectorStoreSingleton = MemoryVectorStoreManager.getInstance(
{} as Embeddings, // Real Embeddings are provided when executing the node
this.logger,
);
const searchOptions: INodeListSearchResult['results'] = vectorStoreSingleton
.getMemoryKeysList()
.map((key) => {
return {
name: key,
value: key,
};
});
let results = searchOptions;
if (filter) {
results = results.filter((option) => option.name.includes(filter));
}
return {
results,
};
},
},
actionHandler: {
async createVectorStore(
this: ILoadOptionsFunctions,
payload: string | IDataObject | undefined,
): Promise<NodeParameterValueType> {
if (!payload || typeof payload === 'string') {
throw new ApplicationError('Invalid payload type');
}
const { name } = payload;
const vectorStoreSingleton = MemoryVectorStoreManager.getInstance(
{} as Embeddings, // Real Embeddings are provided when executing the node
this.logger,
);
const memoryKey = name ? (name as string) : DEFAULT_MEMORY_KEY;
await vectorStoreSingleton.getVectorStore(memoryKey);
return memoryKey;
},
},
},
insertFields,
loadFields: [warningBanner],
retrieveFields: [warningBanner],
async getVectorStoreClient(context, _filter, embeddings, itemIndex) {
const memoryKey = getMemoryKey(context, itemIndex);
const vectorStoreSingleton = MemoryVectorStoreManager.getInstance(embeddings, context.logger);
return await vectorStoreSingleton.getVectorStore(memoryKey);
},
async populateVectorStore(context, embeddings, documents, itemIndex) {
const memoryKey = getMemoryKey(context, itemIndex);
const clearStore = context.getNodeParameter('clearStore', itemIndex) as boolean;
const vectorStoreInstance = MemoryVectorStoreManager.getInstance(embeddings, context.logger);
await vectorStoreInstance.addDocuments(memoryKey, documents, clearStore);
},
}) {}
@@ -0,0 +1,111 @@
import type { Embeddings } from '@langchain/core/embeddings';
import type { Document } from '@langchain/classic/document';
import {
NodeConnectionTypes,
type INodeExecutionData,
type IExecuteFunctions,
type INodeType,
type INodeTypeDescription,
} from 'n8n-workflow';
import { MemoryVectorStoreManager, processDocuments, type N8nJsonLoader } from '@n8n/ai-utilities';
// This node is deprecated. Use VectorStoreInMemory instead.
export class VectorStoreInMemoryInsert implements INodeType {
description: INodeTypeDescription = {
displayName: 'In Memory Vector Store Insert',
name: 'vectorStoreInMemoryInsert',
icon: 'fa:database',
group: ['transform'],
version: 1,
hidden: true,
description: 'Insert data into an in-memory vector store',
defaults: {
name: 'In Memory Vector Store Insert',
},
codex: {
categories: ['AI'],
subcategories: {
AI: ['Vector Stores'],
},
resources: {
primaryDocumentation: [
{
url: 'https://docs.n8n.io/integrations/builtin/cluster-nodes/root-nodes/n8n-nodes-langchain.vectorstoreinmemory/',
},
],
},
},
inputs: [
NodeConnectionTypes.Main,
{
displayName: 'Document',
maxConnections: 1,
type: NodeConnectionTypes.AiDocument,
required: true,
},
{
displayName: 'Embedding',
maxConnections: 1,
type: NodeConnectionTypes.AiEmbedding,
required: true,
},
],
outputs: [NodeConnectionTypes.Main],
properties: [
{
displayName:
'The embbded data are stored in the server memory, so they will be lost when the server is restarted. Additionally, if the amount of data is too large, it may cause the server to crash due to insufficient memory.',
name: 'notice',
type: 'notice',
default: '',
},
{
displayName: 'Clear Store',
name: 'clearStore',
type: 'boolean',
default: false,
description: 'Whether to clear the store before inserting new data',
},
{
displayName: 'Memory Key',
name: 'memoryKey',
type: 'string',
default: 'vector_store_key',
description:
'The key to use to store the vector memory in the workflow data. The key will be prefixed with the workflow ID to avoid collisions.',
},
],
};
async execute(this: IExecuteFunctions): Promise<INodeExecutionData[][]> {
const items = this.getInputData(0);
const embeddings = (await this.getInputConnectionData(
NodeConnectionTypes.AiEmbedding,
0,
)) as Embeddings;
const memoryKey = this.getNodeParameter('memoryKey', 0) as string;
const clearStore = this.getNodeParameter('clearStore', 0) as boolean;
const documentInput = (await this.getInputConnectionData(NodeConnectionTypes.AiDocument, 0)) as
| N8nJsonLoader
| Array<Document<Record<string, unknown>>>;
const { processedDocuments, serializedDocuments } = await processDocuments(
documentInput,
items,
);
const workflowId = this.getWorkflow().id;
const vectorStoreInstance = MemoryVectorStoreManager.getInstance(embeddings, this.logger);
await vectorStoreInstance.addDocuments(
`${workflowId}__${memoryKey}`,
processedDocuments,
clearStore,
);
return [serializedDocuments];
}
}
@@ -0,0 +1,79 @@
import type { Embeddings } from '@langchain/core/embeddings';
import {
NodeConnectionTypes,
type INodeType,
type INodeTypeDescription,
type ISupplyDataFunctions,
type SupplyData,
} from 'n8n-workflow';
import { logWrapper, MemoryVectorStoreManager } from '@n8n/ai-utilities';
// This node is deprecated. Use VectorStoreInMemory instead.
export class VectorStoreInMemoryLoad implements INodeType {
description: INodeTypeDescription = {
displayName: 'In Memory Vector Store Load',
name: 'vectorStoreInMemoryLoad',
icon: 'fa:database',
group: ['transform'],
version: 1,
hidden: true,
description: 'Load embedded data from an in-memory vector store',
defaults: {
name: 'In Memory Vector Store Load',
},
codex: {
categories: ['AI'],
subcategories: {
AI: ['Vector Stores'],
},
resources: {
primaryDocumentation: [
{
url: 'https://docs.n8n.io/integrations/builtin/cluster-nodes/root-nodes/n8n-nodes-langchain.vectorstoreinmemory/',
},
],
},
},
inputs: [
{
displayName: 'Embedding',
maxConnections: 1,
type: NodeConnectionTypes.AiEmbedding,
required: true,
},
],
outputs: [NodeConnectionTypes.AiVectorStore],
outputNames: ['Vector Store'],
properties: [
{
displayName: 'Memory Key',
name: 'memoryKey',
type: 'string',
default: 'vector_store_key',
description:
'The key to use to store the vector memory in the workflow data. The key will be prefixed with the workflow ID to avoid collisions.',
},
],
};
async supplyData(this: ISupplyDataFunctions, itemIndex: number): Promise<SupplyData> {
const embeddings = (await this.getInputConnectionData(
NodeConnectionTypes.AiEmbedding,
itemIndex,
)) as Embeddings;
const workflowId = this.getWorkflow().id;
const memoryKey = this.getNodeParameter('memoryKey', 0) as string;
const vectorStoreSingleton = MemoryVectorStoreManager.getInstance(embeddings, this.logger);
const vectorStoreInstance = await vectorStoreSingleton.getVectorStore(
`${workflowId}__${memoryKey}`,
);
return {
response: logWrapper(vectorStoreInstance, this),
};
}
}
@@ -0,0 +1,96 @@
import { Milvus } from '@langchain/community/vectorstores/milvus';
import type { MilvusLibArgs } from '@langchain/community/vectorstores/milvus';
import { MilvusClient } from '@zilliz/milvus2-sdk-node';
import type { INodeProperties } from 'n8n-workflow';
import { createVectorStoreNode } from '@n8n/ai-utilities';
import { milvusCollectionsSearch } from '../shared/methods/listSearch';
import { milvusCollectionRLC } from '../shared/descriptions';
const sharedFields: INodeProperties[] = [milvusCollectionRLC];
const insertFields: INodeProperties[] = [
{
displayName: 'Options',
name: 'options',
type: 'collection',
placeholder: 'Add Option',
default: {},
options: [
{
displayName: 'Clear Collection',
name: 'clearCollection',
type: 'boolean',
default: false,
description: 'Whether to clear the collection before inserting new data',
},
],
},
];
export class VectorStoreMilvus extends createVectorStoreNode<Milvus>({
meta: {
displayName: 'Milvus Vector Store',
name: 'vectorStoreMilvus',
description: 'Work with your data in Milvus Vector Store',
icon: { light: 'file:milvus-icon-black.svg', dark: 'file:milvus-icon-white.svg' },
docsUrl:
'https://docs.n8n.io/integrations/builtin/cluster-nodes/root-nodes/n8n-nodes-langchain.vectorstoremilvus/',
credentials: [
{
name: 'milvusApi',
required: true,
},
],
operationModes: ['load', 'insert', 'retrieve', 'retrieve-as-tool'],
},
methods: { listSearch: { milvusCollectionsSearch } },
sharedFields,
insertFields,
async getVectorStoreClient(context, _filter, embeddings, itemIndex): Promise<Milvus> {
const collection = context.getNodeParameter('milvusCollection', itemIndex, '', {
extractValue: true,
}) as string;
const credentials = await context.getCredentials<{
baseUrl: string;
username: string;
password: string;
}>('milvusApi');
const config: MilvusLibArgs = {
url: credentials.baseUrl,
username: credentials.username,
password: credentials.password,
collectionName: collection,
};
return await Milvus.fromExistingCollection(embeddings, config);
},
async populateVectorStore(context, embeddings, documents, itemIndex): Promise<void> {
const collection = context.getNodeParameter('milvusCollection', itemIndex, '', {
extractValue: true,
}) as string;
const options = context.getNodeParameter('options', itemIndex, {}) as {
clearCollection?: boolean;
};
const credentials = await context.getCredentials<{
baseUrl: string;
username: string;
password: string;
}>('milvusApi');
const config: MilvusLibArgs = {
url: credentials.baseUrl,
username: credentials.username,
password: credentials.password,
collectionName: collection,
};
if (options.clearCollection) {
const client = new MilvusClient({
address: credentials.baseUrl,
token: `${credentials.username}:${credentials.password}`,
});
await client.dropCollection({ collection_name: collection });
}
await Milvus.fromDocuments(documents, embeddings, config);
},
}) {}
@@ -0,0 +1 @@
<svg id="Layer_1" data-name="Layer 1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 360 360"><title>milvus-icon-black</title><path d="M169.11689,299.04939c-27.78535-.02915-51.298-8.31411-72.45031-23.69338a122.33707,122.33707,0,0,1-14.00922-12.00616q-16.556-16.35613-33.14895-32.67482Q35.31712,216.704,21.10726,202.7519c-4.27753-4.20375-8.581-8.38131-12.83638-12.60737-5.78529-5.74539-5.692-12.21736.17135-17.91294,4.35916-4.23447,8.6808-8.50759,13.01531-12.76743q21.09033-20.727,42.18351-41.45112c7.82349-7.66748,15.53733-15.458,23.59542-22.87264A117.61725,117.61725,0,0,1,142.926,66.46075,112.79714,112.79714,0,0,1,167.7708,63.678c27.822.22006,53.14231,8.0315,75.21837,25.35039a117.49179,117.49179,0,0,1,33.56725,40.74071,111.30862,111.30862,0,0,1,11.18191,37.59258c3.75648,35.37535-6.093,66.46091-30.10087,92.86236a114.32952,114.32952,0,0,1-57.75872,34.79075A132.31853,132.31853,0,0,1,169.11689,299.04939Zm5.35425-31.43461a85.592,85.592,0,0,0,8.90555-.52289c1.86733-.18941,3.736-.42361,5.58158-.7604,22.43092-4.09372,40.60221-15.4505,54.49679-33.26538,13.51833-17.33245,18.827-37.40452,17.16875-59.24969a82.75341,82.75341,0,0,0-9.27492-31.89563c-9.21938-17.8418-23.47591-30.41362-41.44061-38.92072-16.12138-7.6342-33.07061-9.547-50.63693-6.8248a85.52038,85.52038,0,0,0-47.48221,23.39486c-12.62025,12.19885-25.1024,24.54066-37.63746,36.82758-6.28192,6.15757-12.51006,12.37048-18.83189,18.48667-4.04633,3.91467-4.21333,8.6494-.20763,12.6018q11.8256,11.66819,23.69873,23.28819c10.60865,10.42576,21.17268,20.89736,31.83058,31.27252C128.07334,259.01635,148.957,267.6,174.47114,267.61478Z"/><path d="M357.01654,180.71583a12.11267,12.11267,0,0,1-3.67773,9.07927q-16.50428,16.58232-33.07758,33.09591c-1.20808,1.20715-2.42168,1.44863-3.67166.79033-1.35017-.71105-1.82827-1.79263-1.442-3.56058a187.7592,187.7592,0,0,0,3.66284-23.911,178.77219,178.77219,0,0,0,.42091-21.92036,170.04764,170.04764,0,0,0-3.96975-31.1,3.757,3.757,0,0,1,.24665-3.12677,2.86044,2.86044,0,0,1,4.014-.76856,9.243,9.243,0,0,1,1.2578,1.10625q15.99768,15.98621,31.98832,31.97946C355.28369,174.89374,357.02471,177.76063,357.01654,180.71583Z"/><path d="M232.52066,181.32156a58.91788,58.91788,0,0,1-59.06395,59.10475c-34.72229-.0744-59.571-28.74548-58.99573-60.0188a59.03419,59.03419,0,0,1,118.05968.91405Z"/></svg>

After

Width:  |  Height:  |  Size: 2.2 KiB

@@ -0,0 +1 @@
<svg id="Layer_1" data-name="Layer 1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 360 360"><defs><style>.cls-1{fill:#fff;}</style></defs><title>milvus-icon-white</title><path class="cls-1" d="M169.11689,299.04939c-27.78535-.02915-51.298-8.31411-72.45031-23.69338a122.33707,122.33707,0,0,1-14.00922-12.00616q-16.556-16.35613-33.14895-32.67482Q35.31712,216.704,21.10726,202.7519c-4.27753-4.20375-8.581-8.38131-12.83638-12.60737-5.78529-5.74539-5.692-12.21736.17135-17.91294,4.35916-4.23447,8.6808-8.50759,13.01531-12.76743q21.09033-20.727,42.18351-41.45112c7.82349-7.66748,15.53733-15.458,23.59542-22.87264A117.61725,117.61725,0,0,1,142.926,66.46075,112.79714,112.79714,0,0,1,167.7708,63.678c27.822.22006,53.14231,8.0315,75.21837,25.35039a117.49179,117.49179,0,0,1,33.56725,40.74071,111.30862,111.30862,0,0,1,11.18191,37.59258c3.75648,35.37535-6.093,66.46091-30.10087,92.86236a114.32952,114.32952,0,0,1-57.75872,34.79075A132.31853,132.31853,0,0,1,169.11689,299.04939Zm5.35425-31.43461a85.592,85.592,0,0,0,8.90555-.52289c1.86733-.18941,3.736-.42361,5.58158-.7604,22.43092-4.09372,40.60221-15.4505,54.49679-33.26538,13.51833-17.33245,18.827-37.40452,17.16875-59.24969a82.75341,82.75341,0,0,0-9.27492-31.89563c-9.21938-17.8418-23.47591-30.41362-41.44061-38.92072-16.12138-7.6342-33.07061-9.547-50.63693-6.8248a85.52038,85.52038,0,0,0-47.48221,23.39486c-12.62025,12.19885-25.1024,24.54066-37.63746,36.82758-6.28192,6.15757-12.51006,12.37048-18.83189,18.48667-4.04633,3.91467-4.21333,8.6494-.20763,12.6018q11.8256,11.66819,23.69873,23.28819c10.60865,10.42576,21.17268,20.89736,31.83058,31.27252C128.07334,259.01635,148.957,267.6,174.47114,267.61478Z"/><path class="cls-1" d="M357.01654,180.71583a12.11267,12.11267,0,0,1-3.67773,9.07927q-16.50428,16.58232-33.07758,33.09591c-1.20808,1.20715-2.42168,1.44863-3.67166.79033-1.35017-.71105-1.82827-1.79263-1.442-3.56058a187.7592,187.7592,0,0,0,3.66284-23.911,178.77219,178.77219,0,0,0,.42091-21.92036,170.04764,170.04764,0,0,0-3.96975-31.1,3.757,3.757,0,0,1,.24665-3.12677,2.86044,2.86044,0,0,1,4.014-.76856,9.243,9.243,0,0,1,1.2578,1.10625q15.99768,15.98621,31.98832,31.97946C355.28369,174.89374,357.02471,177.76063,357.01654,180.71583Z"/><path class="cls-1" d="M232.52066,181.32156a58.91788,58.91788,0,0,1-59.06395,59.10475c-34.72229-.0744-59.571-28.74548-58.99573-60.0188a59.03419,59.03419,0,0,1,118.05968.91405Z"/></svg>

After

Width:  |  Height:  |  Size: 2.3 KiB

@@ -0,0 +1,389 @@
import { mock } from 'jest-mock-extended';
import { MongoClient } from 'mongodb';
import type { ILoadOptionsFunctions, ISupplyDataFunctions } from 'n8n-workflow';
import {
EMBEDDING_NAME,
getCollectionName,
getEmbeddingFieldName,
getFilterValue,
getMetadataFieldName,
getMongoClient,
getVectorIndexName,
mongoConfig,
METADATA_FIELD_NAME,
MONGODB_COLLECTION_NAME,
VECTOR_INDEX_NAME,
} from './VectorStoreMongoDBAtlas.node';
jest.mock('mongodb', () => ({
MongoClient: jest.fn(),
}));
describe('VectorStoreMongoDBAtlas', () => {
const helpers = mock<ILoadOptionsFunctions['helpers']>();
const executeFunctions = mock<ILoadOptionsFunctions>({ helpers });
const dataHelpers = mock<ISupplyDataFunctions['helpers']>();
const dataFunctions = mock<ISupplyDataFunctions>({ helpers: dataHelpers });
beforeEach(() => {
jest.resetAllMocks();
});
describe('.getMongoClient', () => {
const mockContext = mock<ISupplyDataFunctions>({
getCredentials: jest.fn(),
});
const mockClient1 = {
connect: jest.fn().mockResolvedValue(undefined),
close: jest.fn().mockResolvedValue(undefined),
};
const mockClient2 = {
connect: jest.fn().mockResolvedValue(undefined),
close: jest.fn().mockResolvedValue(undefined),
};
const MockMongoClient = MongoClient as jest.MockedClass<typeof MongoClient>;
beforeEach(() => {
mongoConfig.client = null;
mongoConfig.connectionString = '';
});
it('should reuse the same client when connection string is unchanged', async () => {
MockMongoClient.mockImplementation(() => mockClient1 as unknown as MongoClient);
mockContext.getCredentials.mockResolvedValue({
configurationType: 'connectionString',
connectionString: 'mongodb://localhost:27017',
});
const client1 = await getMongoClient(mockContext, 1.1);
const client2 = await getMongoClient(mockContext, 1.1);
expect(MockMongoClient).toHaveBeenCalledTimes(1);
expect(MockMongoClient).toHaveBeenCalledWith('mongodb://localhost:27017', {
appName: 'devrel.integration.n8n_vector_integ',
driverInfo: {
name: 'n8n_vector',
version: '1.1',
},
});
expect(mockClient1.connect).toHaveBeenCalledTimes(1);
expect(mockClient1.close).not.toHaveBeenCalled();
expect(mockClient2.connect).not.toHaveBeenCalled();
expect(client1).toBe(mockClient1);
expect(client2).toBe(mockClient1);
});
it('should create new client when connection string changes', async () => {
MockMongoClient.mockImplementationOnce(
() => mockClient1 as unknown as MongoClient,
).mockImplementationOnce(() => mockClient2 as unknown as MongoClient);
mockContext.getCredentials
.mockResolvedValueOnce({
configurationType: 'connectionString',
connectionString: 'mongodb://localhost:27017',
})
.mockResolvedValueOnce({
configurationType: 'connectionString',
connectionString: 'mongodb://different-host:27017',
});
const client1 = await getMongoClient(mockContext, 1.1);
const client2 = await getMongoClient(mockContext, 1.1);
expect(MockMongoClient).toHaveBeenCalledTimes(2);
expect(MockMongoClient).toHaveBeenNthCalledWith(1, 'mongodb://localhost:27017', {
appName: 'devrel.integration.n8n_vector_integ',
driverInfo: {
name: 'n8n_vector',
version: '1.1',
},
});
expect(MockMongoClient).toHaveBeenNthCalledWith(2, 'mongodb://different-host:27017', {
appName: 'devrel.integration.n8n_vector_integ',
driverInfo: {
name: 'n8n_vector',
version: '1.1',
},
});
expect(mockClient1.connect).toHaveBeenCalledTimes(1);
expect(mockClient1.close).toHaveBeenCalledTimes(1);
expect(mockClient2.connect).toHaveBeenCalledTimes(1);
expect(mockClient2.close).not.toHaveBeenCalled();
expect(client1).toBe(mockClient1);
expect(client2).toBe(mockClient2);
});
it('should create client with values configuration and port specified', async () => {
MockMongoClient.mockImplementation(() => mockClient1 as unknown as MongoClient);
mockContext.getCredentials.mockResolvedValue({
configurationType: 'values',
host: 'localhost',
user: 'testuser',
password: 'testpass',
port: 27017,
database: 'testdb',
});
const client = await getMongoClient(mockContext, 1.1);
expect(MockMongoClient).toHaveBeenCalledTimes(1);
expect(MockMongoClient).toHaveBeenCalledWith('mongodb://testuser:testpass@localhost:27017', {
appName: 'devrel.integration.n8n_vector_integ',
driverInfo: {
name: 'n8n_vector',
version: '1.1',
},
});
expect(mockClient1.connect).toHaveBeenCalledTimes(1);
expect(client).toBe(mockClient1);
});
it('should create client with values configuration without port (Atlas format)', async () => {
MockMongoClient.mockImplementation(() => mockClient1 as unknown as MongoClient);
mockContext.getCredentials.mockResolvedValue({
configurationType: 'values',
host: 'cluster0.mongodb.net',
user: 'atlasuser',
password: 'atlaspass',
database: 'atlasdb',
});
const client = await getMongoClient(mockContext, 1.1);
expect(MockMongoClient).toHaveBeenCalledTimes(1);
expect(MockMongoClient).toHaveBeenCalledWith(
'mongodb+srv://atlasuser:atlaspass@cluster0.mongodb.net',
{
appName: 'devrel.integration.n8n_vector_integ',
driverInfo: {
name: 'n8n_vector',
version: '1.1',
},
},
);
expect(mockClient1.connect).toHaveBeenCalledTimes(1);
expect(client).toBe(mockClient1);
});
it('should reuse the same client when values configuration is unchanged', async () => {
MockMongoClient.mockImplementation(() => mockClient1 as unknown as MongoClient);
mockContext.getCredentials.mockResolvedValue({
configurationType: 'values',
host: 'localhost',
user: 'testuser',
password: 'testpass',
port: 27017,
database: 'testdb',
});
const client1 = await getMongoClient(mockContext, 1.1);
const client2 = await getMongoClient(mockContext, 1.1);
expect(MockMongoClient).toHaveBeenCalledTimes(1);
expect(MockMongoClient).toHaveBeenCalledWith('mongodb://testuser:testpass@localhost:27017', {
appName: 'devrel.integration.n8n_vector_integ',
driverInfo: {
name: 'n8n_vector',
version: '1.1',
},
});
expect(mockClient1.connect).toHaveBeenCalledTimes(1);
expect(mockClient1.close).not.toHaveBeenCalled();
expect(client1).toBe(mockClient1);
expect(client2).toBe(mockClient1);
});
it('should create new client when values configuration changes', async () => {
MockMongoClient.mockImplementationOnce(
() => mockClient1 as unknown as MongoClient,
).mockImplementationOnce(() => mockClient2 as unknown as MongoClient);
mockContext.getCredentials
.mockResolvedValueOnce({
configurationType: 'values',
host: 'localhost',
user: 'testuser',
password: 'testpass',
port: 27017,
database: 'testdb',
})
.mockResolvedValueOnce({
configurationType: 'values',
host: 'different-host',
user: 'testuser',
password: 'testpass',
port: 27017,
database: 'testdb',
});
const client1 = await getMongoClient(mockContext, 1.1);
const client2 = await getMongoClient(mockContext, 1.1);
expect(MockMongoClient).toHaveBeenCalledTimes(2);
expect(MockMongoClient).toHaveBeenNthCalledWith(
1,
'mongodb://testuser:testpass@localhost:27017',
{
appName: 'devrel.integration.n8n_vector_integ',
driverInfo: {
name: 'n8n_vector',
version: '1.1',
},
},
);
expect(MockMongoClient).toHaveBeenNthCalledWith(
2,
'mongodb://testuser:testpass@different-host:27017',
{
appName: 'devrel.integration.n8n_vector_integ',
driverInfo: {
name: 'n8n_vector',
version: '1.1',
},
},
);
expect(mockClient1.connect).toHaveBeenCalledTimes(1);
expect(mockClient1.close).toHaveBeenCalledTimes(1);
expect(mockClient2.connect).toHaveBeenCalledTimes(1);
expect(mockClient2.close).not.toHaveBeenCalled();
expect(client1).toBe(mockClient1);
expect(client2).toBe(mockClient2);
});
});
describe('.getCollectionName', () => {
beforeEach(() => {
executeFunctions.getNodeParameter.mockImplementation((paramName: string) => {
if (paramName === MONGODB_COLLECTION_NAME) return 'testCollection';
return '';
});
});
it('returns the collection name from the context', () => {
expect(getCollectionName(executeFunctions, 0)).toEqual('testCollection');
});
});
describe('.getVectorIndexName', () => {
beforeEach(() => {
executeFunctions.getNodeParameter.mockImplementation((paramName: string) => {
if (paramName === VECTOR_INDEX_NAME) return 'testIndex';
return '';
});
});
it('returns the index name from the context', () => {
expect(getVectorIndexName(executeFunctions, 0)).toEqual('testIndex');
});
});
describe('.getEmbeddingFieldName', () => {
beforeEach(() => {
executeFunctions.getNodeParameter.mockImplementation((paramName: string) => {
if (paramName === EMBEDDING_NAME) return 'testEmbedding';
return '';
});
});
it('returns the embedding name from the context', () => {
expect(getEmbeddingFieldName(executeFunctions, 0)).toEqual('testEmbedding');
});
});
describe('.getMetadataFieldName', () => {
beforeEach(() => {
executeFunctions.getNodeParameter.mockImplementation((paramName: string) => {
if (paramName === METADATA_FIELD_NAME) return 'testMetadata';
return '';
});
});
it('returns the metadata field name from the context', () => {
expect(getMetadataFieldName(executeFunctions, 0)).toEqual('testMetadata');
});
});
describe('.getFilterValue', () => {
describe('when no post filter is present', () => {
beforeEach(() => {
dataFunctions.getNodeParameter.mockImplementation(() => {
return {};
});
});
it('returns undefined', () => {
expect(getFilterValue('postFilterPipeline', dataFunctions, 0)).toEqual(undefined);
});
});
describe('when a post filter is present', () => {
describe('when the JSON is valid', () => {
beforeEach(() => {
dataFunctions.getNodeParameter.mockImplementation(() => {
return { postFilterPipeline: '[{ "$match": { "name": "value" }}]' };
});
});
it('returns the post filter pipeline', () => {
expect(getFilterValue('postFilterPipeline', dataFunctions, 0)).toEqual([
{ $match: { name: 'value' } },
]);
});
});
describe('when the JSON is invalid', () => {
beforeEach(() => {
dataFunctions.getNodeParameter.mockImplementation(() => {
return { postFilterPipeline: '[{ "$match": { "name":}}]' };
});
});
it('throws an error', () => {
expect(() => {
getFilterValue('postFilterPipeline', dataFunctions, 0);
}).toThrow();
});
});
});
describe('when no pre filter is present', () => {
beforeEach(() => {
dataFunctions.getNodeParameter.mockImplementation(() => {
return {};
});
});
it('returns undefined', () => {
expect(getFilterValue('preFilter', dataFunctions, 0)).toEqual(undefined);
});
});
describe('when a pre filter is present', () => {
describe('when the JSON is valid', () => {
beforeEach(() => {
dataFunctions.getNodeParameter.mockImplementation(() => {
return { preFilter: '{ "name": "value" }' };
});
});
it('returns the pre filter', () => {
expect(getFilterValue('preFilter', dataFunctions, 0)).toEqual({ name: 'value' });
});
});
describe('when the JSON is invalid', () => {
beforeEach(() => {
dataFunctions.getNodeParameter.mockImplementation(() => {
return { preFilter: '"name":}}]' };
});
});
it('throws an error', () => {
expect(() => {
getFilterValue('preFilter', dataFunctions, 0);
}).toThrow();
});
});
});
});
});
@@ -0,0 +1,401 @@
import type { EmbeddingsInterface } from '@langchain/core/embeddings';
import { MongoDBAtlasVectorSearch, type MongoDBAtlasVectorSearchLibArgs } from '@langchain/mongodb';
import { MongoClient } from 'mongodb';
import {
type IDataObject,
type ILoadOptionsFunctions,
NodeOperationError,
type INodeProperties,
type IExecuteFunctions,
type ISupplyDataFunctions,
} from 'n8n-workflow';
import { metadataFilterField, createVectorStoreNode } from '@n8n/ai-utilities';
import { validateAndResolveMongoCredentials } from 'n8n-nodes-base/dist/nodes/MongoDb/GenericFunctions';
/**
* Constants for the name of the credentials and Node parameters.
*/
export const MONGODB_CREDENTIALS = 'mongoDb';
export const MONGODB_COLLECTION_NAME = 'mongoCollection';
export const VECTOR_INDEX_NAME = 'vectorIndexName';
export const EMBEDDING_NAME = 'embedding';
export const METADATA_FIELD_NAME = 'metadata_field';
export const PRE_FILTER_NAME = 'preFilter';
export const POST_FILTER_NAME = 'postFilterPipeline';
const mongoCollectionRLC: INodeProperties = {
displayName: 'MongoDB Collection',
name: MONGODB_COLLECTION_NAME,
type: 'resourceLocator',
default: { mode: 'list', value: '' },
required: true,
modes: [
{
displayName: 'From List',
name: 'list',
type: 'list',
typeOptions: {
searchListMethod: 'mongoCollectionSearch', // Method to fetch collections
},
},
{
displayName: 'Name',
name: 'name',
type: 'string',
placeholder: 'e.g. my_collection',
},
],
};
const vectorIndexName: INodeProperties = {
displayName: 'Vector Index Name',
name: VECTOR_INDEX_NAME,
type: 'string',
default: '',
description: 'The name of the vector index',
required: true,
};
const embeddingField: INodeProperties = {
displayName: 'Embedding',
name: EMBEDDING_NAME,
type: 'string',
default: 'embedding',
description: 'The field with the embedding array',
required: true,
};
const metadataField: INodeProperties = {
displayName: 'Metadata Field',
name: METADATA_FIELD_NAME,
type: 'string',
default: 'text',
description: 'The text field of the raw data',
required: true,
};
const sharedFields: INodeProperties[] = [
mongoCollectionRLC,
embeddingField,
metadataField,
vectorIndexName,
];
const mongoNamespaceField: INodeProperties = {
displayName: 'Namespace',
name: 'namespace',
type: 'string',
description: 'Logical partition for documents. Uses metadata.namespace field for filtering.',
default: '',
};
const preFilterField: INodeProperties = {
displayName: 'Pre Filter',
name: PRE_FILTER_NAME,
type: 'json',
typeOptions: {
alwaysOpenEditWindow: true,
},
default: '',
placeholder: '{ "key": "value" }',
hint: 'This is a filter applied in the $vectorSearch stage <a href="https://www.mongodb.com/docs/atlas/atlas-vector-search/vector-search-stage/#atlas-vector-search-pre-filter">here</a>',
required: true,
description: 'MongoDB Atlas Vector Search pre-filter',
};
const postFilterField: INodeProperties = {
displayName: 'Post Filter Pipeline',
name: POST_FILTER_NAME,
type: 'json',
typeOptions: {
alwaysOpenEditWindow: true,
},
default: '',
placeholder: '[{ "$match": { "$gt": "1950-01-01" }, ... }]',
hint: 'Learn more about aggregation pipeline <a href="https://docs.mongodb.com/manual/core/aggregation-pipeline/">here</a>',
required: true,
description: 'MongoDB aggregation pipeline in JSON format',
};
const retrieveFields: INodeProperties[] = [
{
displayName: 'Options',
name: 'options',
type: 'collection',
placeholder: 'Add Option',
default: {},
options: [mongoNamespaceField, metadataFilterField, preFilterField, postFilterField],
},
];
const insertFields: INodeProperties[] = [
{
displayName: 'Options',
name: 'options',
type: 'collection',
placeholder: 'Add Option',
default: {},
options: [
{
displayName: 'Clear Namespace',
name: 'clearNamespace',
type: 'boolean',
default: false,
description: 'Whether to clear documents in the namespace before inserting new data',
},
mongoNamespaceField,
],
},
];
export const mongoConfig = {
client: null as MongoClient | null,
connectionString: '',
nodeVersion: 0,
};
/**
* Type used for cleaner, more intentional typing.
*/
type IFunctionsContext = IExecuteFunctions | ISupplyDataFunctions | ILoadOptionsFunctions;
/**
* Get the mongo client.
* @param context - The context.
* @returns the MongoClient for the node.
*/
export async function getMongoClient(
context: IExecuteFunctions | ISupplyDataFunctions | ILoadOptionsFunctions,
version: number,
) {
const credentials = await context.getCredentials(MONGODB_CREDENTIALS);
const node = context.getNode();
const { connectionString } = validateAndResolveMongoCredentials(node, credentials);
if (
!mongoConfig.client ||
mongoConfig.connectionString !== connectionString ||
mongoConfig.nodeVersion !== version
) {
if (mongoConfig.client) {
await mongoConfig.client.close();
}
mongoConfig.connectionString = connectionString;
mongoConfig.nodeVersion = version;
mongoConfig.client = new MongoClient(connectionString, {
appName: 'devrel.integration.n8n_vector_integ',
driverInfo: {
name: 'n8n_vector',
version: version.toString(),
},
});
await mongoConfig.client.connect();
}
return mongoConfig.client;
}
/**
* Get the database object from the MongoClient by the configured name.
* @param context - The context.
* @returns the Db object.
*/
export async function getDatabase(context: IFunctionsContext, client: MongoClient) {
const credentials = await context.getCredentials(MONGODB_CREDENTIALS);
return client.db(credentials.database as string);
}
/**
* Get all the collection in the database.
* @param this The load options context.
* @returns The list of collections.
*/
export async function getCollections(this: ILoadOptionsFunctions) {
try {
const client = await getMongoClient(this, this.getNode().typeVersion);
const db = await getDatabase(this, client);
const collections = await db.listCollections().toArray();
const results = collections.map((collection) => ({
name: collection.name,
value: collection.name,
}));
return { results };
} catch (error) {
throw new NodeOperationError(this.getNode(), `Error: ${error.message}`);
}
}
/**
* Get a parameter from the context.
* @param key - The key of the parameter.
* @param context - The context.
* @param itemIndex - The index.
* @returns The value.
*/
export function getParameter(key: string, context: IFunctionsContext, itemIndex: number): string {
const value = context.getNodeParameter(key, itemIndex, '', {
extractValue: true,
}) as string;
if (typeof value !== 'string') {
throw new NodeOperationError(context.getNode(), `Parameter ${key} must be a string`);
}
return value;
}
export const getCollectionName = getParameter.bind(null, MONGODB_COLLECTION_NAME);
export const getVectorIndexName = getParameter.bind(null, VECTOR_INDEX_NAME);
export const getEmbeddingFieldName = getParameter.bind(null, EMBEDDING_NAME);
export const getMetadataFieldName = getParameter.bind(null, METADATA_FIELD_NAME);
export function getFilterValue<T>(
name: string,
context: IExecuteFunctions | ISupplyDataFunctions,
itemIndex: number,
): T | undefined {
const options: IDataObject = context.getNodeParameter('options', itemIndex, {});
if (options[name]) {
if (typeof options[name] === 'string') {
try {
return JSON.parse(options[name]);
} catch (error) {
throw new NodeOperationError(context.getNode(), `Error: ${error.message}`, {
itemIndex,
description: `Could not parse JSON for ${name}`,
});
}
}
throw new NodeOperationError(context.getNode(), 'Error: No JSON string provided.', {
itemIndex,
description: `Could not parse JSON for ${name}`,
});
}
return undefined;
}
class ExtendedMongoDBAtlasVectorSearch extends MongoDBAtlasVectorSearch {
preFilter: IDataObject;
postFilterPipeline?: IDataObject[];
constructor(
embeddings: EmbeddingsInterface,
options: MongoDBAtlasVectorSearchLibArgs,
preFilter: IDataObject,
postFilterPipeline?: IDataObject[],
) {
super(embeddings, options);
this.preFilter = preFilter;
this.postFilterPipeline = postFilterPipeline;
}
async similaritySearchVectorWithScore(query: number[], k: number) {
const mergedFilter: MongoDBAtlasVectorSearch['FilterType'] = {
preFilter: this.preFilter,
postFilterPipeline: this.postFilterPipeline,
};
return await super.similaritySearchVectorWithScore(query, k, mergedFilter);
}
}
export class VectorStoreMongoDBAtlas extends createVectorStoreNode({
meta: {
displayName: 'MongoDB Atlas Vector Store',
name: 'vectorStoreMongoDBAtlas',
description: 'Work with your data in MongoDB Atlas Vector Store',
icon: { light: 'file:mongodb.svg', dark: 'file:mongodb.dark.svg' },
docsUrl:
'https://docs.n8n.io/integrations/builtin/cluster-nodes/root-nodes/n8n-nodes-langchain.vectorstoremongodbatlas/',
credentials: [
{
name: 'mongoDb',
required: true,
},
],
operationModes: ['load', 'insert', 'retrieve', 'update', 'retrieve-as-tool'],
},
methods: { listSearch: { mongoCollectionSearch: getCollections } },
retrieveFields,
loadFields: retrieveFields,
insertFields,
sharedFields,
async getVectorStoreClient(context, _filter, embeddings, itemIndex) {
try {
const client = await getMongoClient(context, context.getNode().typeVersion);
const db = await getDatabase(context, client);
const collectionName = getCollectionName(context, itemIndex);
const mongoVectorIndexName = getVectorIndexName(context, itemIndex);
const embeddingFieldName = getEmbeddingFieldName(context, itemIndex);
const metadataFieldName = getMetadataFieldName(context, itemIndex);
const collection = db.collection(collectionName);
// test index exists
const indexes = await collection.listSearchIndexes().toArray();
const indexExists = indexes.some((index) => index.name === mongoVectorIndexName);
if (!indexExists) {
throw new NodeOperationError(context.getNode(), `Index ${mongoVectorIndexName} not found`, {
itemIndex,
description: 'Please check that the index exists in your collection',
});
}
const preFilter = getFilterValue<IDataObject>(PRE_FILTER_NAME, context, itemIndex);
const postFilterPipeline = getFilterValue<IDataObject[]>(
POST_FILTER_NAME,
context,
itemIndex,
);
return new ExtendedMongoDBAtlasVectorSearch(
embeddings,
{
collection,
indexName: mongoVectorIndexName, // Default index name
textKey: metadataFieldName, // Field containing raw text
embeddingKey: embeddingFieldName, // Field containing embeddings
},
preFilter ?? {},
postFilterPipeline,
);
} catch (error) {
if (error instanceof NodeOperationError) {
throw error;
}
throw new NodeOperationError(context.getNode(), `Error: ${error.message}`, {
itemIndex,
description: 'Please check your MongoDB Atlas connection details',
});
}
},
async populateVectorStore(context, embeddings, documents, itemIndex) {
try {
const client = await getMongoClient(context, context.getNode().typeVersion);
const db = await getDatabase(context, client);
const collectionName = getCollectionName(context, itemIndex);
const mongoVectorIndexName = getVectorIndexName(context, itemIndex);
const embeddingFieldName = getEmbeddingFieldName(context, itemIndex);
const metadataFieldName = getMetadataFieldName(context, itemIndex);
// Check if collection exists
const collections = await db.listCollections({ name: collectionName }).toArray();
if (collections.length === 0) {
await db.createCollection(collectionName);
}
const collection = db.collection(collectionName);
await ExtendedMongoDBAtlasVectorSearch.fromDocuments(documents, embeddings, {
collection,
indexName: mongoVectorIndexName, // Default index name
textKey: metadataFieldName, // Field containing raw text
embeddingKey: embeddingFieldName, // Field containing embeddings
});
} catch (error) {
throw new NodeOperationError(context.getNode(), `Error: ${error.message}`, {
itemIndex,
description: 'Please check your MongoDB Atlas connection details',
});
}
},
}) {}
@@ -0,0 +1,3 @@
<svg width="120" height="258" viewBox="0 0 120 258" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M83.0089 28.7559C72.1328 15.9086 62.7673 2.86053 60.8539 0.150554C60.6525 -0.0501848 60.3503 -0.0501848 60.1489 0.150554C58.2355 2.86053 48.8699 15.9086 37.9938 28.7559C-55.3594 147.292 52.6968 227.287 52.6968 227.287L53.6031 227.889C54.4087 240.235 56.4228 258 56.4228 258H60.451H64.4792C64.4792 258 66.4934 240.335 67.299 227.889L68.2052 227.187C68.306 227.187 176.362 147.292 83.0089 28.7559ZM60.451 225.48C60.451 225.48 55.6172 221.365 54.3081 219.257V219.057L60.1489 89.9813C60.1489 89.5798 60.7532 89.5798 60.7532 89.9813L66.594 219.057V219.257C65.2848 221.365 60.451 225.48 60.451 225.48Z" fill="white"/>
</svg>

After

Width:  |  Height:  |  Size: 728 B

@@ -0,0 +1,3 @@
<svg width="120" height="258" viewBox="0 0 120 258" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M83.0089 28.7559C72.1328 15.9086 62.7673 2.86053 60.8539 0.150554C60.6525 -0.0501848 60.3503 -0.0501848 60.1489 0.150554C58.2355 2.86053 48.8699 15.9086 37.9938 28.7559C-55.3594 147.292 52.6968 227.287 52.6968 227.287L53.6031 227.889C54.4087 240.235 56.4228 258 56.4228 258H60.451H64.4792C64.4792 258 66.4934 240.335 67.299 227.889L68.2052 227.187C68.306 227.187 176.362 147.292 83.0089 28.7559ZM60.451 225.48C60.451 225.48 55.6172 221.365 54.3081 219.257V219.057L60.1489 89.9813C60.1489 89.5798 60.7532 89.5798 60.7532 89.9813L66.594 219.057V219.257C65.2848 221.365 60.451 225.48 60.451 225.48Z" fill="#00684A"/>
</svg>

After

Width:  |  Height:  |  Size: 730 B

@@ -0,0 +1,316 @@
import {
PGVectorStore,
type DistanceStrategy,
type PGVectorStoreArgs,
} from '@langchain/community/vectorstores/pgvector';
import type { EmbeddingsInterface } from '@langchain/core/embeddings';
import { configurePostgres } from 'n8n-nodes-base/dist/nodes/Postgres/transport/index';
import type { PostgresNodeCredentials } from 'n8n-nodes-base/dist/nodes/Postgres/v2/helpers/interfaces';
import type { INodeProperties } from 'n8n-workflow';
import type pg from 'pg';
import { metadataFilterField, createVectorStoreNode } from '@n8n/ai-utilities';
type CollectionOptions = {
useCollection?: boolean;
collectionName?: string;
collectionTableName?: string;
};
type ColumnOptions = {
idColumnName: string;
vectorColumnName: string;
contentColumnName: string;
metadataColumnName: string;
};
const sharedFields: INodeProperties[] = [
{
displayName: 'Table Name',
name: 'tableName',
type: 'string',
default: 'n8n_vectors',
description:
'The table name to store the vectors in. If table does not exist, it will be created.',
},
];
const collectionField: INodeProperties = {
displayName: 'Collection',
name: 'collection',
type: 'fixedCollection',
description: 'Collection of vectors',
default: {
values: {
useCollection: false,
collectionName: 'n8n',
collectionTable: 'n8n_vector_collections',
},
},
typeOptions: {},
placeholder: 'Add Collection Settings',
options: [
{
name: 'values',
displayName: 'Collection Settings',
values: [
{
displayName: 'Use Collection',
name: 'useCollection',
type: 'boolean',
default: false,
},
{
displayName: 'Collection Name',
name: 'collectionName',
type: 'string',
default: 'n8n',
required: true,
displayOptions: { show: { useCollection: [true] } },
},
{
displayName: 'Collection Table Name',
name: 'collectionTableName',
type: 'string',
default: 'n8n_vector_collections',
required: true,
displayOptions: { show: { useCollection: [true] } },
},
],
},
],
};
const columnNamesField: INodeProperties = {
displayName: 'Column Names',
name: 'columnNames',
type: 'fixedCollection',
description: 'The names of the columns in the PGVector table',
default: {
values: {
idColumnName: 'id',
vectorColumnName: 'embedding',
contentColumnName: 'text',
metadataColumnName: 'metadata',
},
},
typeOptions: {},
placeholder: 'Set Column Names',
options: [
{
name: 'values',
displayName: 'Column Name Settings',
values: [
{
displayName: 'ID Column Name',
name: 'idColumnName',
type: 'string',
default: 'id',
required: true,
},
{
displayName: 'Vector Column Name',
name: 'vectorColumnName',
type: 'string',
default: 'embedding',
required: true,
},
{
displayName: 'Content Column Name',
name: 'contentColumnName',
type: 'string',
default: 'text',
required: true,
},
{
displayName: 'Metadata Column Name',
name: 'metadataColumnName',
type: 'string',
default: 'metadata',
required: true,
},
],
},
],
};
const distanceStrategyField: INodeProperties = {
displayName: 'Distance Strategy',
name: 'distanceStrategy',
type: 'options',
default: 'cosine',
description: 'The method to calculate the distance between two vectors',
options: [
{
name: 'Cosine',
value: 'cosine',
},
{
name: 'Inner Product',
value: 'innerProduct',
},
{
name: 'Euclidean',
value: 'euclidean',
},
],
};
const insertFields: INodeProperties[] = [
{
displayName: 'Options',
name: 'options',
type: 'collection',
placeholder: 'Add Option',
default: {},
options: [collectionField, columnNamesField],
},
];
const retrieveFields: INodeProperties[] = [
{
displayName: 'Options',
name: 'options',
type: 'collection',
placeholder: 'Add Option',
default: {},
options: [distanceStrategyField, collectionField, columnNamesField, metadataFilterField],
},
];
/**
* Extended PGVectorStore class to handle custom filtering.
* This wrapper is necessary because when used as a retriever,
* similaritySearchVectorWithScore should use this.filter instead of
* expecting it from the parameter
*/
class ExtendedPGVectorStore extends PGVectorStore {
static async initialize(
embeddings: EmbeddingsInterface,
args: PGVectorStoreArgs & { dimensions?: number },
): Promise<ExtendedPGVectorStore> {
const { dimensions, ...rest } = args;
const postgresqlVectorStore = new this(embeddings, rest);
await postgresqlVectorStore._initializeClient();
await postgresqlVectorStore.ensureTableInDatabase(dimensions);
if (postgresqlVectorStore.collectionTableName) {
await postgresqlVectorStore.ensureCollectionTableInDatabase();
}
return postgresqlVectorStore;
}
async similaritySearchVectorWithScore(
query: number[],
k: number,
filter?: PGVectorStore['FilterType'],
) {
const mergedFilter = { ...this.filter, ...filter };
return await super.similaritySearchVectorWithScore(query, k, mergedFilter);
}
}
export class VectorStorePGVector extends createVectorStoreNode<ExtendedPGVectorStore>({
meta: {
description: 'Work with your data in Postgresql with the PGVector extension',
icon: 'file:postgres.svg',
displayName: 'Postgres PGVector Store',
docsUrl:
'https://docs.n8n.io/integrations/builtin/cluster-nodes/root-nodes/n8n-nodes-langchain.vectorstorepgvector/',
name: 'vectorStorePGVector',
credentials: [
{
name: 'postgres',
required: true,
testedBy: 'postgresConnectionTest',
},
],
operationModes: ['load', 'insert', 'retrieve', 'retrieve-as-tool'],
},
sharedFields,
insertFields,
loadFields: retrieveFields,
retrieveFields,
async getVectorStoreClient(context, filter, embeddings, itemIndex) {
const tableName = context.getNodeParameter('tableName', itemIndex, '', {
extractValue: true,
}) as string;
const credentials = await context.getCredentials('postgres');
const pgConf = await configurePostgres.call(context, credentials as PostgresNodeCredentials);
const pool = pgConf.db.$pool as unknown as pg.Pool;
const config: PGVectorStoreArgs = {
pool,
tableName,
filter,
};
const collectionOptions = context.getNodeParameter(
'options.collection.values',
0,
{},
) as CollectionOptions;
if (collectionOptions?.useCollection) {
config.collectionName = collectionOptions.collectionName;
config.collectionTableName = collectionOptions.collectionTableName;
}
config.columns = context.getNodeParameter('options.columnNames.values', 0, {
idColumnName: 'id',
vectorColumnName: 'embedding',
contentColumnName: 'text',
metadataColumnName: 'metadata',
}) as ColumnOptions;
config.distanceStrategy = context.getNodeParameter(
'options.distanceStrategy',
0,
'cosine',
) as DistanceStrategy;
return await ExtendedPGVectorStore.initialize(embeddings, config);
},
async populateVectorStore(context, embeddings, documents, itemIndex) {
// NOTE: if you are to create the HNSW index before use, you need to consider moving the distanceStrategy field to
// shared fields, because you need that strategy when creating the index.
const tableName = context.getNodeParameter('tableName', itemIndex, '', {
extractValue: true,
}) as string;
const credentials = await context.getCredentials('postgres');
const pgConf = await configurePostgres.call(context, credentials as PostgresNodeCredentials);
const pool = pgConf.db.$pool as unknown as pg.Pool;
const config: PGVectorStoreArgs = {
pool,
tableName,
};
const collectionOptions = context.getNodeParameter(
'options.collection.values',
0,
{},
) as CollectionOptions;
if (collectionOptions?.useCollection) {
config.collectionName = collectionOptions.collectionName;
config.collectionTableName = collectionOptions.collectionTableName;
}
config.columns = context.getNodeParameter('options.columnNames.values', 0, {
idColumnName: 'id',
vectorColumnName: 'embedding',
contentColumnName: 'text',
metadataColumnName: 'metadata',
}) as ColumnOptions;
const vectorStore = await PGVectorStore.fromDocuments(documents, embeddings, config);
vectorStore.client?.release();
},
releaseVectorStoreClient(vectorStore) {
vectorStore.client?.release();
},
}) {}
File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 5.9 KiB

@@ -0,0 +1,136 @@
import type { PineconeStoreParams } from '@langchain/pinecone';
import { PineconeStore } from '@langchain/pinecone';
import { Pinecone } from '@pinecone-database/pinecone';
import { NodeOperationError, type INodeProperties } from 'n8n-workflow';
import { metadataFilterField, createVectorStoreNode } from '@n8n/ai-utilities';
import { pineconeIndexSearch } from '../shared/methods/listSearch';
import { pineconeIndexRLC } from '../shared/descriptions';
const sharedFields: INodeProperties[] = [pineconeIndexRLC];
const pineconeNamespaceField: INodeProperties = {
displayName: 'Pinecone Namespace',
name: 'pineconeNamespace',
type: 'string',
description:
'Partition the records in an index into namespaces. Queries and other operations are then limited to one namespace, so different requests can search different subsets of your index.',
default: '',
};
const retrieveFields: INodeProperties[] = [
{
displayName: 'Options',
name: 'options',
type: 'collection',
placeholder: 'Add Option',
default: {},
options: [pineconeNamespaceField, metadataFilterField],
},
];
const insertFields: INodeProperties[] = [
{
displayName: 'Options',
name: 'options',
type: 'collection',
placeholder: 'Add Option',
default: {},
options: [
{
displayName: 'Clear Namespace',
name: 'clearNamespace',
type: 'boolean',
default: false,
description: 'Whether to clear the namespace before inserting new data',
},
pineconeNamespaceField,
],
},
];
export class VectorStorePinecone extends createVectorStoreNode<PineconeStore>({
meta: {
displayName: 'Pinecone Vector Store',
name: 'vectorStorePinecone',
description: 'Work with your data in Pinecone Vector Store',
icon: { light: 'file:pinecone.svg', dark: 'file:pinecone.dark.svg' },
docsUrl:
'https://docs.n8n.io/integrations/builtin/cluster-nodes/root-nodes/n8n-nodes-langchain.vectorstorepinecone/',
credentials: [
{
name: 'pineconeApi',
required: true,
},
],
operationModes: ['load', 'insert', 'retrieve', 'update', 'retrieve-as-tool'],
},
methods: { listSearch: { pineconeIndexSearch } },
retrieveFields,
loadFields: retrieveFields,
insertFields,
sharedFields,
async getVectorStoreClient(context, filter, embeddings, itemIndex) {
const index = context.getNodeParameter('pineconeIndex', itemIndex, '', {
extractValue: true,
}) as string;
const options = context.getNodeParameter('options', itemIndex, {}) as {
pineconeNamespace?: string;
};
const credentials = await context.getCredentials('pineconeApi');
const client = new Pinecone({
apiKey: credentials.apiKey as string,
});
const pineconeIndex = client.Index(index);
const config: PineconeStoreParams = {
namespace: options.pineconeNamespace ?? undefined,
pineconeIndex,
filter,
};
return await PineconeStore.fromExistingIndex(embeddings, config);
},
async populateVectorStore(context, embeddings, documents, itemIndex) {
const index = context.getNodeParameter('pineconeIndex', itemIndex, '', {
extractValue: true,
}) as string;
const options = context.getNodeParameter('options', itemIndex, {}) as {
pineconeNamespace?: string;
clearNamespace?: boolean;
};
const credentials = await context.getCredentials('pineconeApi');
const client = new Pinecone({
apiKey: credentials.apiKey as string,
});
const indexes = ((await client.listIndexes()).indexes ?? []).map((i) => i.name);
if (!indexes.includes(index)) {
throw new NodeOperationError(context.getNode(), `Index ${index} not found`, {
itemIndex,
description: 'Please check that the index exists in your vector store',
});
}
const pineconeIndex = client.Index(index);
if (options.pineconeNamespace && options.clearNamespace) {
const namespace = pineconeIndex.namespace(options.pineconeNamespace);
try {
await namespace.deleteAll();
} catch (error) {
// Namespace doesn't exist yet
context.logger.info(`Namespace ${options.pineconeNamespace} does not exist yet`);
}
}
await PineconeStore.fromDocuments(documents, embeddings, {
namespace: options.pineconeNamespace ?? undefined,
pineconeIndex,
});
},
}) {}
@@ -0,0 +1,21 @@
<svg width="32" height="35" viewBox="0 0 32 35" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M13.8555 34.2962C14.9325 34.2962 15.8055 33.4451 15.8055 32.3954C15.8055 31.3456 14.9325 30.4946 13.8555 30.4946C12.7786 30.4946 11.9055 31.3456 11.9055 32.3954C11.9055 33.4451 12.7786 34.2962 13.8555 34.2962Z" fill="white"/>
<path d="M18.4138 7.19675L19.2512 2.66005" stroke="white" stroke-width="2.11786" stroke-linecap="square"/>
<path d="M22.2656 5.5855L19.3466 2.11099L15.3748 4.37292" stroke="white" stroke-width="2.11786" stroke-linecap="square" stroke-linejoin="round"/>
<path d="M14.9202 26.5528L15.7337 22.0169" stroke="white" stroke-width="2.11786" stroke-linecap="square"/>
<path d="M18.7729 24.9304L15.83 21.4671L11.8701 23.741" stroke="white" stroke-width="2.11786" stroke-linecap="square" stroke-linejoin="round"/>
<path d="M16.6077 17.1996L17.4212 12.6633" stroke="white" stroke-width="2.11786" stroke-linecap="square"/>
<path d="M20.4587 15.58L17.5277 12.128L13.5679 14.3904" stroke="white" stroke-width="2.11786" stroke-linecap="square" stroke-linejoin="round"/>
<path d="M8.32871 26.1554L4.75171 28.5815" stroke="white" stroke-width="2.01017" stroke-linecap="square"/>
<path d="M8.54383 30.0865L4.3208 28.8738L4.63185 24.5944" stroke="white" stroke-width="2.01017" stroke-linecap="square" stroke-linejoin="round"/>
<path d="M21.3213 28.4299L23.8096 31.9282" stroke="white" stroke-width="2.01017" stroke-linecap="square"/>
<path d="M19.718 32.045L24.1085 32.3365L25.3527 28.2438" stroke="white" stroke-width="2.01017" stroke-linecap="square" stroke-linejoin="round"/>
<path d="M25.3999 21.3291L29.7784 22.0996" stroke="white" stroke-width="2.05804" stroke-linecap="square"/>
<path d="M26.9072 25.072L30.3048 22.1919L28.1634 18.3557" stroke="white" stroke-width="2.05804" stroke-linecap="square" stroke-linejoin="round"/>
<path d="M24.1196 12.8615L28.0197 10.763" stroke="white" stroke-width="2.05804" stroke-linecap="square"/>
<path d="M24.3357 8.83965L28.4869 10.5188L27.7093 14.8216" stroke="white" stroke-width="2.05804" stroke-linecap="square" stroke-linejoin="round"/>
<path d="M6.91639 18.1572L2.52588 17.4101" stroke="white" stroke-width="2.05804" stroke-linecap="square"/>
<path d="M4.17731 21.1645L2 17.328L5.36167 14.436" stroke="white" stroke-width="2.05804" stroke-linecap="square" stroke-linejoin="round"/>
<path d="M11.0799 10.6129L8.14893 7.34769" stroke="white" stroke-width="2.05804" stroke-linecap="square"/>
<path d="M12.2897 6.77496L7.80349 6.96156L7.01392 11.2649" stroke="white" stroke-width="2.05804" stroke-linecap="square" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 2.5 KiB

@@ -0,0 +1,21 @@
<svg width="32" height="35" viewBox="0 0 32 35" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M13.8555 34.2962C14.9325 34.2962 15.8055 33.4451 15.8055 32.3954C15.8055 31.3456 14.9325 30.4946 13.8555 30.4946C12.7786 30.4946 11.9055 31.3456 11.9055 32.3954C11.9055 33.4451 12.7786 34.2962 13.8555 34.2962Z" fill="black"/>
<path d="M18.4138 7.19675L19.2512 2.66005" stroke="black" stroke-width="2.11786" stroke-linecap="square"/>
<path d="M22.2656 5.5855L19.3466 2.11099L15.3748 4.37292" stroke="black" stroke-width="2.11786" stroke-linecap="square" stroke-linejoin="round"/>
<path d="M14.9202 26.5528L15.7337 22.0169" stroke="black" stroke-width="2.11786" stroke-linecap="square"/>
<path d="M18.7729 24.9304L15.83 21.4671L11.8701 23.741" stroke="black" stroke-width="2.11786" stroke-linecap="square" stroke-linejoin="round"/>
<path d="M16.6077 17.1996L17.4212 12.6633" stroke="black" stroke-width="2.11786" stroke-linecap="square"/>
<path d="M20.4587 15.58L17.5277 12.128L13.5679 14.3904" stroke="black" stroke-width="2.11786" stroke-linecap="square" stroke-linejoin="round"/>
<path d="M8.32871 26.1554L4.75171 28.5815" stroke="black" stroke-width="2.01017" stroke-linecap="square"/>
<path d="M8.54383 30.0865L4.3208 28.8738L4.63185 24.5944" stroke="black" stroke-width="2.01017" stroke-linecap="square" stroke-linejoin="round"/>
<path d="M21.3213 28.4299L23.8096 31.9282" stroke="black" stroke-width="2.01017" stroke-linecap="square"/>
<path d="M19.718 32.045L24.1085 32.3365L25.3527 28.2438" stroke="black" stroke-width="2.01017" stroke-linecap="square" stroke-linejoin="round"/>
<path d="M25.3999 21.3291L29.7784 22.0996" stroke="black" stroke-width="2.05804" stroke-linecap="square"/>
<path d="M26.9072 25.072L30.3048 22.1919L28.1634 18.3557" stroke="black" stroke-width="2.05804" stroke-linecap="square" stroke-linejoin="round"/>
<path d="M24.1196 12.8615L28.0197 10.763" stroke="black" stroke-width="2.05804" stroke-linecap="square"/>
<path d="M24.3357 8.83965L28.4869 10.5188L27.7093 14.8216" stroke="black" stroke-width="2.05804" stroke-linecap="square" stroke-linejoin="round"/>
<path d="M6.91639 18.1572L2.52588 17.4101" stroke="black" stroke-width="2.05804" stroke-linecap="square"/>
<path d="M4.17731 21.1645L2 17.328L5.36167 14.436" stroke="black" stroke-width="2.05804" stroke-linecap="square" stroke-linejoin="round"/>
<path d="M11.0799 10.6129L8.14893 7.34769" stroke="black" stroke-width="2.05804" stroke-linecap="square"/>
<path d="M12.2897 6.77496L7.80349 6.96156L7.01392 11.2649" stroke="black" stroke-width="2.05804" stroke-linecap="square" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 2.5 KiB

@@ -0,0 +1,139 @@
import type { Document } from '@langchain/core/documents';
import type { Embeddings } from '@langchain/core/embeddings';
import { PineconeStore } from '@langchain/pinecone';
import { Pinecone } from '@pinecone-database/pinecone';
import {
type IExecuteFunctions,
type INodeType,
type INodeTypeDescription,
type INodeExecutionData,
NodeConnectionTypes,
} from 'n8n-workflow';
import { processDocuments, type N8nJsonLoader } from '@n8n/ai-utilities';
import { pineconeIndexSearch } from '../shared/methods/listSearch';
import { pineconeIndexRLC } from '../shared/descriptions';
// This node is deprecated. Use VectorStorePinecone instead.
export class VectorStorePineconeInsert implements INodeType {
description: INodeTypeDescription = {
displayName: 'Pinecone: Insert',
hidden: true,
name: 'vectorStorePineconeInsert',
icon: 'file:pinecone.svg',
group: ['transform'],
version: 1,
description: 'Insert data into Pinecone Vector Store index',
defaults: {
name: 'Pinecone: Insert',
// eslint-disable-next-line n8n-nodes-base/node-class-description-non-core-color-present
color: '#1321A7',
},
codex: {
categories: ['AI'],
subcategories: {
AI: ['Vector Stores'],
},
resources: {
primaryDocumentation: [
{
url: 'https://docs.n8n.io/integrations/builtin/cluster-nodes/root-nodes/n8n-nodes-langchain.vectorstorepinecone/',
},
],
},
},
credentials: [
{
name: 'pineconeApi',
required: true,
},
],
inputs: [
NodeConnectionTypes.Main,
{
displayName: 'Document',
maxConnections: 1,
type: NodeConnectionTypes.AiDocument,
required: true,
},
{
displayName: 'Embedding',
maxConnections: 1,
type: NodeConnectionTypes.AiEmbedding,
required: true,
},
],
outputs: [NodeConnectionTypes.Main],
properties: [
pineconeIndexRLC,
{
displayName: 'Pinecone Namespace',
name: 'pineconeNamespace',
type: 'string',
default: '',
},
{
displayName: 'Specify the document to load in the document loader sub-node',
name: 'notice',
type: 'notice',
default: '',
},
{
displayName: 'Clear Namespace',
name: 'clearNamespace',
type: 'boolean',
default: false,
description: 'Whether to clear the namespace before inserting new data',
},
],
};
methods = {
listSearch: {
pineconeIndexSearch,
},
};
async execute(this: IExecuteFunctions): Promise<INodeExecutionData[][]> {
const items = this.getInputData(0);
this.logger.debug('Executing data for Pinecone Insert Vector Store');
const namespace = this.getNodeParameter('pineconeNamespace', 0) as string;
const index = this.getNodeParameter('pineconeIndex', 0, '', { extractValue: true }) as string;
const clearNamespace = this.getNodeParameter('clearNamespace', 0) as boolean;
const credentials = await this.getCredentials('pineconeApi');
const documentInput = (await this.getInputConnectionData(NodeConnectionTypes.AiDocument, 0)) as
| N8nJsonLoader
| Array<Document<Record<string, unknown>>>;
const embeddings = (await this.getInputConnectionData(
NodeConnectionTypes.AiEmbedding,
0,
)) as Embeddings;
const client = new Pinecone({
apiKey: credentials.apiKey as string,
});
const pineconeIndex = client.Index(index);
if (namespace && clearNamespace) {
await pineconeIndex.namespace(namespace).deleteAll();
}
const { processedDocuments, serializedDocuments } = await processDocuments(
documentInput,
items,
);
await PineconeStore.fromDocuments(processedDocuments, embeddings, {
namespace: namespace || undefined,
pineconeIndex,
});
return [serializedDocuments];
}
}
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" class="w-full -translate-y-0.5" viewBox="1 1 30 29"><g fill="none" fill-rule="evenodd" transform="translate(0 1)"><path stroke="currentColor" stroke-linecap="square" stroke-width="1.77" d="m14.58 5.24.7-3.89"/><path stroke="#7D7D87" stroke-linecap="square" stroke-linejoin="round" stroke-width="1.77" d="M17.8 3.86 15.36.88l-3.32 1.94"/><path stroke="currentColor" stroke-linecap="square" stroke-width="1.77" d="m11.66 21.84.68-3.89"/><path stroke="currentColor" stroke-linecap="square" stroke-linejoin="round" stroke-width="1.77" d="m14.88 20.45-2.46-2.97-3.31 1.95"/><path stroke="currentColor" stroke-linecap="square" stroke-width="1.77" d="m13.07 13.82.68-3.89"/><path stroke="currentColor" stroke-linecap="square" stroke-linejoin="round" stroke-width="1.77" d="m16.29 12.43-2.45-2.96-3.31 1.94"/><circle cx="10.77" cy="26.85" r="1.63" fill="currentColor" fill-rule="nonzero"/><g stroke="currentColor" stroke-linecap="square"><path stroke-width="1.68" d="m6.15 21.5-2.99 2.08"/><path stroke-linejoin="round" stroke-width="1.68" d="M6.33 24.87 2.8 23.83l.26-3.67"/><path stroke-width="1.68" d="m17.01 23.45 2.08 3"/><path stroke-linejoin="round" stroke-width="1.68" d="m15.67 26.55 3.67.25 1.04-3.51"/><path stroke-width="1.72" d="m20.42 17.36 3.66.66"/><path stroke-linejoin="round" stroke-width="1.72" d="m21.68 20.57 2.84-2.47-1.79-3.29"/><path stroke-width="1.72" d="m19.35 10.1 3.26-1.8"/><path stroke-linejoin="round" stroke-width="1.72" d="M19.53 6.65 23 8.09l-.65 3.69"/><path stroke-width="1.72" d="M4.97 14.64 1.3 14"/><path stroke-linejoin="round" stroke-width="1.72" d="M2.68 17.22.86 13.93l2.81-2.48"/><path stroke-width="1.72" d="M8.45 8.17 6 5.37"/><path stroke-linejoin="round" stroke-width="1.72" d="m9.46 4.88-3.75.16-.66 3.69"/></g></g></svg>

After

Width:  |  Height:  |  Size: 1.8 KiB

@@ -0,0 +1,117 @@
import type { Embeddings } from '@langchain/core/embeddings';
import type { PineconeStoreParams } from '@langchain/pinecone';
import { PineconeStore } from '@langchain/pinecone';
import { Pinecone } from '@pinecone-database/pinecone';
import {
NodeConnectionTypes,
type INodeType,
type INodeTypeDescription,
type ISupplyDataFunctions,
type SupplyData,
} from 'n8n-workflow';
import { logWrapper, getMetadataFiltersValues, metadataFilterField } from '@n8n/ai-utilities';
import { pineconeIndexSearch } from '../shared/methods/listSearch';
import { pineconeIndexRLC } from '../shared/descriptions';
// This node is deprecated. Use VectorStorePinecone instead.
export class VectorStorePineconeLoad implements INodeType {
description: INodeTypeDescription = {
displayName: 'Pinecone: Load',
// Vector Store nodes got merged into a single node
hidden: true,
name: 'vectorStorePineconeLoad',
icon: 'file:pinecone.svg',
group: ['transform'],
version: 1,
description: 'Load data from Pinecone Vector Store index',
defaults: {
name: 'Pinecone: Load',
},
codex: {
categories: ['AI'],
subcategories: {
AI: ['Vector Stores'],
},
resources: {
primaryDocumentation: [
{
url: 'https://docs.n8n.io/integrations/builtin/cluster-nodes/root-nodes/n8n-nodes-langchain.vectorstorepinecone/',
},
],
},
},
credentials: [
{
name: 'pineconeApi',
required: true,
},
],
inputs: [
{
displayName: 'Embedding',
maxConnections: 1,
type: NodeConnectionTypes.AiEmbedding,
required: true,
},
],
outputs: [NodeConnectionTypes.AiVectorStore],
outputNames: ['Vector Store'],
properties: [
pineconeIndexRLC,
{
displayName: 'Pinecone Namespace',
name: 'pineconeNamespace',
type: 'string',
default: '',
},
{
displayName: 'Options',
name: 'options',
type: 'collection',
placeholder: 'Add Option',
default: {},
options: [metadataFilterField],
},
],
};
methods = {
listSearch: {
pineconeIndexSearch,
},
};
async supplyData(this: ISupplyDataFunctions, itemIndex: number): Promise<SupplyData> {
this.logger.debug('Supplying data for Pinecone Load Vector Store');
const namespace = this.getNodeParameter('pineconeNamespace', itemIndex) as string;
const index = this.getNodeParameter('pineconeIndex', itemIndex, '', {
extractValue: true,
}) as string;
const credentials = await this.getCredentials('pineconeApi');
const embeddings = (await this.getInputConnectionData(
NodeConnectionTypes.AiEmbedding,
itemIndex,
)) as Embeddings;
const client = new Pinecone({
apiKey: credentials.apiKey as string,
});
const pineconeIndex = client.Index(index);
const config: PineconeStoreParams = {
namespace: namespace || undefined,
pineconeIndex,
filter: getMetadataFiltersValues(this, itemIndex),
};
const vectorStore = await PineconeStore.fromExistingIndex(embeddings, config);
return {
response: logWrapper(vectorStore, this),
};
}
}
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" class="w-full -translate-y-0.5" viewBox="1 1 30 29"><g fill="none" fill-rule="evenodd" transform="translate(0 1)"><path stroke="currentColor" stroke-linecap="square" stroke-width="1.77" d="m14.58 5.24.7-3.89"/><path stroke="#7D7D87" stroke-linecap="square" stroke-linejoin="round" stroke-width="1.77" d="M17.8 3.86 15.36.88l-3.32 1.94"/><path stroke="currentColor" stroke-linecap="square" stroke-width="1.77" d="m11.66 21.84.68-3.89"/><path stroke="currentColor" stroke-linecap="square" stroke-linejoin="round" stroke-width="1.77" d="m14.88 20.45-2.46-2.97-3.31 1.95"/><path stroke="currentColor" stroke-linecap="square" stroke-width="1.77" d="m13.07 13.82.68-3.89"/><path stroke="currentColor" stroke-linecap="square" stroke-linejoin="round" stroke-width="1.77" d="m16.29 12.43-2.45-2.96-3.31 1.94"/><circle cx="10.77" cy="26.85" r="1.63" fill="currentColor" fill-rule="nonzero"/><g stroke="currentColor" stroke-linecap="square"><path stroke-width="1.68" d="m6.15 21.5-2.99 2.08"/><path stroke-linejoin="round" stroke-width="1.68" d="M6.33 24.87 2.8 23.83l.26-3.67"/><path stroke-width="1.68" d="m17.01 23.45 2.08 3"/><path stroke-linejoin="round" stroke-width="1.68" d="m15.67 26.55 3.67.25 1.04-3.51"/><path stroke-width="1.72" d="m20.42 17.36 3.66.66"/><path stroke-linejoin="round" stroke-width="1.72" d="m21.68 20.57 2.84-2.47-1.79-3.29"/><path stroke-width="1.72" d="m19.35 10.1 3.26-1.8"/><path stroke-linejoin="round" stroke-width="1.72" d="M19.53 6.65 23 8.09l-.65 3.69"/><path stroke-width="1.72" d="M4.97 14.64 1.3 14"/><path stroke-linejoin="round" stroke-width="1.72" d="M2.68 17.22.86 13.93l2.81-2.48"/><path stroke-width="1.72" d="M8.45 8.17 6 5.37"/><path stroke-linejoin="round" stroke-width="1.72" d="m9.46 4.88-3.75.16-.66 3.69"/></g></g></svg>

After

Width:  |  Height:  |  Size: 1.8 KiB

@@ -0,0 +1,39 @@
import { QdrantClient } from '@qdrant/js-client-rest';
import { UserError } from 'n8n-workflow';
export type QdrantCredential = {
qdrantUrl: string;
apiKey: string;
};
function parseQdrantUrl(url: string): { protocol: string; host: string; port: number } {
try {
const parsedUrl = new URL(url);
return {
protocol: parsedUrl.protocol,
host: parsedUrl.hostname,
port: parsedUrl.port
? parseInt(parsedUrl.port, 10)
: parsedUrl.protocol === 'https:'
? 443
: 80,
};
} catch (error) {
throw new UserError(
`Invalid Qdrant URL: ${url}. Please provide a valid URL with protocol (http/https)`,
);
}
}
export function createQdrantClient(credentials: QdrantCredential): QdrantClient {
const { protocol, host, port } = parseQdrantUrl(credentials.qdrantUrl);
const qdrantClient = new QdrantClient({
host,
apiKey: credentials.apiKey,
https: protocol === 'https:',
port,
});
return qdrantClient;
}
@@ -0,0 +1,427 @@
import { mock } from 'jest-mock-extended';
import type { ISupplyDataFunctions } from 'n8n-workflow';
// Mock external modules that are not needed for these unit tests
jest.mock('@langchain/qdrant', () => {
const state: { ctorArgs?: unknown[] } = { ctorArgs: undefined };
class QdrantVectorStore {
static fromDocuments = jest.fn();
static fromExistingCollection = jest.fn();
similaritySearch = jest.fn();
constructor(...args: unknown[]) {
state.ctorArgs = args;
}
}
return { QdrantVectorStore, __state: state };
});
jest.mock('@n8n/ai-utilities', () => ({
metadataFilterField: {},
getMetadataFiltersValues: jest.fn(),
logAiEvent: jest.fn(),
N8nBinaryLoader: class {},
N8nJsonLoader: class {},
logWrapper: (fn: unknown) => fn,
createVectorStoreNode: (config: {
getVectorStoreClient: (...args: unknown[]) => unknown;
populateVectorStore: (...args: unknown[]) => unknown;
}) =>
class BaseNode {
async getVectorStoreClient(...args: unknown[]) {
return config.getVectorStoreClient.apply(config, args);
}
async populateVectorStore(...args: unknown[]) {
return config.populateVectorStore.apply(config, args);
}
},
}));
jest.mock('./Qdrant.utils', () => ({
createQdrantClient: jest.fn(),
}));
jest.mock('../shared/methods/listSearch', () => ({
qdrantCollectionsSearch: jest.fn(),
}));
jest.mock('../shared/descriptions', () => ({
qdrantCollectionRLC: {},
}));
import { QdrantVectorStore } from '@langchain/qdrant';
import * as QdrantNode from './VectorStoreQdrant.node';
import { createQdrantClient } from './Qdrant.utils';
const MockCreateQdrantClient = createQdrantClient as jest.MockedFunction<typeof createQdrantClient>;
const MockQdrantVectorStore = QdrantVectorStore as jest.MockedClass<typeof QdrantVectorStore>;
describe('VectorStoreQdrant.node', () => {
const helpers = mock<ISupplyDataFunctions['helpers']>();
const dataFunctions = mock<ISupplyDataFunctions>({ helpers });
dataFunctions.logger = {
info: jest.fn(),
debug: jest.fn(),
error: jest.fn(),
warn: jest.fn(),
verbose: jest.fn(),
} as unknown as ISupplyDataFunctions['logger'];
const baseCredentials = {
qdrantUrl: 'https://localhost:6333',
apiKey: 'test-api-key',
};
const mockClient = {
getCollections: jest.fn(),
createCollection: jest.fn(),
deleteCollection: jest.fn(),
};
beforeEach(() => {
jest.resetAllMocks();
MockCreateQdrantClient.mockReturnValue(mockClient as never);
});
describe('getVectorStoreClient', () => {
it('should create vector store client with default content and metadata keys', async () => {
const mockEmbeddings = {};
const mockVectorStore = {
similaritySearch: jest.fn().mockResolvedValue([]),
};
MockQdrantVectorStore.fromExistingCollection = jest.fn().mockResolvedValue(mockVectorStore);
const context = {
getCredentials: jest.fn().mockResolvedValue(baseCredentials),
getNodeParameter: jest.fn((name: string) => {
const map: Record<string, unknown> = {
qdrantCollection: 'test-collection',
'options.contentPayloadKey': '',
'options.metadataPayloadKey': '',
};
return map[name];
}),
getNode: () => ({ name: 'VectorStoreQdrant' }),
logger: dataFunctions.logger,
} as never;
const node = new QdrantNode.VectorStoreQdrant();
const vectorStore = await (node as any).getVectorStoreClient(
context,
undefined,
mockEmbeddings,
0,
);
expect(MockCreateQdrantClient).toHaveBeenCalledWith(baseCredentials);
expect(MockQdrantVectorStore.fromExistingCollection).toHaveBeenCalledWith(mockEmbeddings, {
client: mockClient,
collectionName: 'test-collection',
contentPayloadKey: undefined,
metadataPayloadKey: undefined,
});
expect(vectorStore).toBe(mockVectorStore);
});
it('should create vector store client with custom content and metadata keys', async () => {
const mockEmbeddings = {};
const mockVectorStore = {
similaritySearch: jest.fn().mockResolvedValue([]),
};
MockQdrantVectorStore.fromExistingCollection = jest.fn().mockResolvedValue(mockVectorStore);
const context = {
getCredentials: jest.fn().mockResolvedValue(baseCredentials),
getNodeParameter: jest.fn((name: string) => {
const map: Record<string, unknown> = {
qdrantCollection: 'test-collection',
'options.contentPayloadKey': 'custom_content',
'options.metadataPayloadKey': 'custom_metadata',
};
return map[name];
}),
getNode: () => ({ name: 'VectorStoreQdrant' }),
logger: dataFunctions.logger,
} as never;
const node = new QdrantNode.VectorStoreQdrant();
await (node as any).getVectorStoreClient(context, undefined, mockEmbeddings, 0);
expect(MockQdrantVectorStore.fromExistingCollection).toHaveBeenCalledWith(mockEmbeddings, {
client: mockClient,
collectionName: 'test-collection',
contentPayloadKey: 'custom_content',
metadataPayloadKey: 'custom_metadata',
});
});
it('should pass filter to vector store client', async () => {
const mockEmbeddings = {};
const mockVectorStore = {
similaritySearch: jest.fn().mockResolvedValue([]),
};
const filter = { should: [{ key: 'metadata.batch', match: { value: 12345 } }] };
MockQdrantVectorStore.fromExistingCollection = jest.fn().mockResolvedValue(mockVectorStore);
const context = {
getCredentials: jest.fn().mockResolvedValue(baseCredentials),
getNodeParameter: jest.fn((name: string) => {
const map: Record<string, unknown> = {
qdrantCollection: 'test-collection',
'options.contentPayloadKey': '',
'options.metadataPayloadKey': '',
};
return map[name];
}),
getNode: () => ({ name: 'VectorStoreQdrant' }),
logger: dataFunctions.logger,
} as never;
const node = new QdrantNode.VectorStoreQdrant();
await (node as any).getVectorStoreClient(context, filter, mockEmbeddings, 0);
expect(MockQdrantVectorStore.fromExistingCollection).toHaveBeenCalledWith(mockEmbeddings, {
client: mockClient,
collectionName: 'test-collection',
contentPayloadKey: undefined,
metadataPayloadKey: undefined,
});
});
});
describe('populateVectorStore', () => {
it('should populate vector store with default options', async () => {
const mockEmbeddings = {};
const mockDocuments = [
{ pageContent: 'test content 1', metadata: { id: 1 } },
{ pageContent: 'test content 2', metadata: { id: 2 } },
];
MockQdrantVectorStore.fromDocuments = jest.fn().mockResolvedValue(undefined);
const context = {
getCredentials: jest.fn().mockResolvedValue(baseCredentials),
getNodeParameter: jest.fn((name: string) => {
const map: Record<string, unknown> = {
qdrantCollection: 'test-collection',
'options.contentPayloadKey': '',
'options.metadataPayloadKey': '',
options: {},
};
return map[name];
}),
getNode: () => ({ name: 'VectorStoreQdrant' }),
logger: dataFunctions.logger,
} as never;
const node = new QdrantNode.VectorStoreQdrant();
await (node as any).populateVectorStore(context, mockEmbeddings, mockDocuments, 0);
expect(MockCreateQdrantClient).toHaveBeenCalledWith(baseCredentials);
expect(MockQdrantVectorStore.fromDocuments).toHaveBeenCalledWith(
mockDocuments,
mockEmbeddings,
{
client: mockClient,
collectionName: 'test-collection',
collectionConfig: undefined,
contentPayloadKey: undefined,
metadataPayloadKey: undefined,
},
);
});
it('should populate vector store with custom content and metadata keys', async () => {
const mockEmbeddings = {};
const mockDocuments = [{ pageContent: 'test content', metadata: {} }];
MockQdrantVectorStore.fromDocuments = jest.fn().mockResolvedValue(undefined);
const context = {
getCredentials: jest.fn().mockResolvedValue(baseCredentials),
getNodeParameter: jest.fn((name: string) => {
const map: Record<string, unknown> = {
qdrantCollection: 'test-collection',
'options.contentPayloadKey': 'custom_content',
'options.metadataPayloadKey': 'custom_metadata',
options: {},
};
return map[name];
}),
getNode: () => ({ name: 'VectorStoreQdrant' }),
logger: dataFunctions.logger,
} as never;
const node = new QdrantNode.VectorStoreQdrant();
await (node as any).populateVectorStore(context, mockEmbeddings, mockDocuments, 0);
expect(MockQdrantVectorStore.fromDocuments).toHaveBeenCalledWith(
mockDocuments,
mockEmbeddings,
{
client: mockClient,
collectionName: 'test-collection',
collectionConfig: undefined,
contentPayloadKey: 'custom_content',
metadataPayloadKey: 'custom_metadata',
},
);
});
it('should populate vector store with collection config', async () => {
const mockEmbeddings = {};
const mockDocuments = [{ pageContent: 'test content', metadata: {} }];
const collectionConfig = {
vectors: {
size: 1536,
distance: 'Cosine',
},
};
MockQdrantVectorStore.fromDocuments = jest.fn().mockResolvedValue(undefined);
const context = {
getCredentials: jest.fn().mockResolvedValue(baseCredentials),
getNodeParameter: jest.fn((name: string) => {
const map: Record<string, unknown> = {
qdrantCollection: 'test-collection',
'options.contentPayloadKey': '',
'options.metadataPayloadKey': '',
options: { collectionConfig },
};
return map[name];
}),
getNode: () => ({ name: 'VectorStoreQdrant' }),
logger: dataFunctions.logger,
} as never;
const node = new QdrantNode.VectorStoreQdrant();
await (node as any).populateVectorStore(context, mockEmbeddings, mockDocuments, 0);
expect(MockQdrantVectorStore.fromDocuments).toHaveBeenCalledWith(
mockDocuments,
mockEmbeddings,
{
client: mockClient,
collectionName: 'test-collection',
collectionConfig,
contentPayloadKey: undefined,
metadataPayloadKey: undefined,
},
);
});
it('should handle empty documents array', async () => {
const mockEmbeddings = {};
const mockDocuments: Array<{ pageContent: string; metadata: Record<string, unknown> }> = [];
MockQdrantVectorStore.fromDocuments = jest.fn().mockResolvedValue(undefined);
const context = {
getCredentials: jest.fn().mockResolvedValue(baseCredentials),
getNodeParameter: jest.fn((name: string) => {
const map: Record<string, unknown> = {
qdrantCollection: 'test-collection',
'options.contentPayloadKey': '',
'options.metadataPayloadKey': '',
options: {},
};
return map[name];
}),
getNode: () => ({ name: 'VectorStoreQdrant' }),
logger: dataFunctions.logger,
} as never;
const node = new QdrantNode.VectorStoreQdrant();
await (node as any).populateVectorStore(context, mockEmbeddings, mockDocuments, 0);
expect(MockQdrantVectorStore.fromDocuments).toHaveBeenCalledWith(
mockDocuments,
mockEmbeddings,
{
client: mockClient,
collectionName: 'test-collection',
collectionConfig: undefined,
contentPayloadKey: undefined,
metadataPayloadKey: undefined,
},
);
});
});
describe('ExtendedQdrantVectorStore filter behavior', () => {
it('should store and use default filter in ExtendedQdrantVectorStore', async () => {
const mockEmbeddings = {};
const mockBaseSimilaritySearch = jest
.fn()
.mockResolvedValue([{ pageContent: 'result 1', metadata: {} }]);
const defaultFilter = { must: [{ key: 'metadata.default', match: { value: 'test' } }] };
// Mock fromExistingCollection to actually call the real ExtendedQdrantVectorStore
// and return an instance that has the overridden similaritySearch method
MockQdrantVectorStore.fromExistingCollection = jest.fn().mockImplementation(async () => {
const instance = Object.create(MockQdrantVectorStore.prototype);
instance.similaritySearch = mockBaseSimilaritySearch;
return instance;
});
const context = {
getCredentials: jest.fn().mockResolvedValue(baseCredentials),
getNodeParameter: jest.fn((name: string) => {
const map: Record<string, unknown> = {
qdrantCollection: 'test-collection',
'options.contentPayloadKey': '',
'options.metadataPayloadKey': '',
};
return map[name];
}),
getNode: () => ({ name: 'VectorStoreQdrant' }),
logger: dataFunctions.logger,
} as never;
// The filter is passed as a parameter when getVectorStoreClient is called
// and stored in ExtendedQdrantVectorStore via fromExistingCollection
const node = new QdrantNode.VectorStoreQdrant();
await (node as any).getVectorStoreClient(context, defaultFilter, mockEmbeddings, 0);
// Verify fromExistingCollection was called (which stores the default filter)
expect(MockQdrantVectorStore.fromExistingCollection).toHaveBeenCalled();
});
it('should verify client creation with collection name', async () => {
const mockEmbeddings = {};
MockQdrantVectorStore.fromExistingCollection = jest.fn().mockResolvedValue({
similaritySearch: jest.fn(),
});
const context = {
getCredentials: jest.fn().mockResolvedValue(baseCredentials),
getNodeParameter: jest.fn((name: string) => {
const map: Record<string, unknown> = {
qdrantCollection: 'my-test-collection',
'options.contentPayloadKey': '',
'options.metadataPayloadKey': '',
};
return map[name];
}),
getNode: () => ({ name: 'VectorStoreQdrant' }),
logger: dataFunctions.logger,
} as never;
const node = new QdrantNode.VectorStoreQdrant();
await (node as any).getVectorStoreClient(context, undefined, mockEmbeddings, 0);
expect(MockQdrantVectorStore.fromExistingCollection).toHaveBeenCalledWith(
mockEmbeddings,
expect.objectContaining({
client: mockClient,
collectionName: 'my-test-collection',
}),
);
});
});
});
@@ -0,0 +1,179 @@
import type { Callbacks } from '@langchain/core/callbacks/manager';
import type { Embeddings } from '@langchain/core/embeddings';
import type { QdrantLibArgs } from '@langchain/qdrant';
import { QdrantVectorStore } from '@langchain/qdrant';
import { type Schemas as QdrantSchemas } from '@qdrant/js-client-rest';
import { assertParamIsString, type IDataObject, type INodeProperties } from 'n8n-workflow';
import { createQdrantClient, type QdrantCredential } from './Qdrant.utils';
import { createVectorStoreNode } from '@n8n/ai-utilities';
import { qdrantCollectionsSearch } from '../shared/methods/listSearch';
import { qdrantCollectionRLC } from '../shared/descriptions';
class ExtendedQdrantVectorStore extends QdrantVectorStore {
private static defaultFilter: IDataObject = {};
static async fromExistingCollection(
embeddings: Embeddings,
args: QdrantLibArgs,
defaultFilter: IDataObject = {},
): Promise<QdrantVectorStore> {
ExtendedQdrantVectorStore.defaultFilter = defaultFilter;
return await super.fromExistingCollection(embeddings, args);
}
async similaritySearch(query: string, k: number, filter?: IDataObject, callbacks?: Callbacks) {
const mergedFilter = { ...ExtendedQdrantVectorStore.defaultFilter, ...filter };
return await super.similaritySearch(query, k, mergedFilter, callbacks);
}
}
const sharedFields: INodeProperties[] = [qdrantCollectionRLC];
const sharedOptions: INodeProperties[] = [
{
displayName: 'Content Payload Key',
name: 'contentPayloadKey',
type: 'string',
default: 'content',
description: 'The key to use for the content payload in Qdrant. Default is "content".',
},
{
displayName: 'Metadata Payload Key',
name: 'metadataPayloadKey',
type: 'string',
default: 'metadata',
description: 'The key to use for the metadata payload in Qdrant. Default is "metadata".',
},
];
const insertFields: INodeProperties[] = [
{
displayName: 'Options',
name: 'options',
type: 'collection',
placeholder: 'Add Option',
default: {},
options: [
{
displayName: 'Collection Config',
name: 'collectionConfig',
type: 'json',
default: '',
description:
'JSON options for creating a collection. <a href="https://qdrant.tech/documentation/concepts/collections">Learn more</a>.',
},
...sharedOptions,
],
},
];
const retrieveFields: INodeProperties[] = [
{
displayName: 'Options',
name: 'options',
type: 'collection',
placeholder: 'Add Option',
default: {},
options: [
{
displayName: 'Search Filter',
name: 'searchFilterJson',
type: 'json',
typeOptions: {
rows: 5,
},
default:
'{\n "should": [\n {\n "key": "metadata.batch",\n "match": {\n "value": 12345\n }\n }\n ]\n}',
validateType: 'object',
description:
'Filter pageContent or metadata using this <a href="https://qdrant.tech/documentation/concepts/filtering/" target="_blank">filtering syntax</a>',
},
...sharedOptions,
],
},
];
export class VectorStoreQdrant extends createVectorStoreNode<ExtendedQdrantVectorStore>({
meta: {
displayName: 'Qdrant Vector Store',
name: 'vectorStoreQdrant',
description: 'Work with your data in a Qdrant collection',
icon: 'file:qdrant.svg',
docsUrl:
'https://docs.n8n.io/integrations/builtin/cluster-nodes/root-nodes/n8n-nodes-langchain.vectorstoreqdrant/',
credentials: [
{
name: 'qdrantApi',
required: true,
},
],
},
methods: { listSearch: { qdrantCollectionsSearch } },
loadFields: retrieveFields,
insertFields,
sharedFields,
retrieveFields,
async getVectorStoreClient(context, filter, embeddings, itemIndex) {
const collection = context.getNodeParameter('qdrantCollection', itemIndex, '', {
extractValue: true,
}) as string;
const contentPayloadKey = context.getNodeParameter('options.contentPayloadKey', itemIndex, '');
assertParamIsString('contentPayloadKey', contentPayloadKey, context.getNode());
const metadataPayloadKey = context.getNodeParameter(
'options.metadataPayloadKey',
itemIndex,
'',
);
assertParamIsString('metadataPayloadKey', metadataPayloadKey, context.getNode());
const credentials = await context.getCredentials('qdrantApi');
const client = createQdrantClient(credentials as QdrantCredential);
const config: QdrantLibArgs = {
client,
collectionName: collection,
contentPayloadKey: contentPayloadKey !== '' ? contentPayloadKey : undefined,
metadataPayloadKey: metadataPayloadKey !== '' ? metadataPayloadKey : undefined,
};
return await ExtendedQdrantVectorStore.fromExistingCollection(embeddings, config, filter);
},
async populateVectorStore(context, embeddings, documents, itemIndex) {
const collectionName = context.getNodeParameter('qdrantCollection', itemIndex, '', {
extractValue: true,
}) as string;
const contentPayloadKey = context.getNodeParameter('options.contentPayloadKey', itemIndex, '');
assertParamIsString('contentPayloadKey', contentPayloadKey, context.getNode());
const metadataPayloadKey = context.getNodeParameter(
'options.metadataPayloadKey',
itemIndex,
'',
);
assertParamIsString('metadataPayloadKey', metadataPayloadKey, context.getNode());
// If collection config is not provided, the collection will be created with default settings
// i.e. with the size of the passed embeddings and "Cosine" distance metric
const { collectionConfig } = context.getNodeParameter('options', itemIndex, {}) as {
collectionConfig?: QdrantSchemas['CreateCollection'];
};
const credentials = await context.getCredentials('qdrantApi');
const client = createQdrantClient(credentials as QdrantCredential);
const config: QdrantLibArgs = {
client,
collectionName,
collectionConfig,
contentPayloadKey: contentPayloadKey !== '' ? contentPayloadKey : undefined,
metadataPayloadKey: metadataPayloadKey !== '' ? metadataPayloadKey : undefined,
};
await QdrantVectorStore.fromDocuments(documents, embeddings, config);
},
}) {}
@@ -0,0 +1,25 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg data-name="Capa 2" viewBox="0 0 346.42 400" xmlns="http://www.w3.org/2000/svg">
<defs>
<style>.cls-1 {
fill: #9e0d38;
}
.cls-2 {
fill: #dc244c;
}
.cls-3 {
fill: #ff516b;
}</style>
</defs>
<polygon class="cls-2" points="173.21 0 0 100 0 300 173.21 400 238.16 362.5 238.16 287.5 173.21 325 64.96 262.5 64.96 137.5 173.21 75 281.46 137.5 281.46 387.5 346.42 350 346.42 100"/>
<polygon class="cls-2" points="108.26 162.5 108.26 237.5 173.21 275 238.16 237.5 238.16 162.5 173.21 125"/>
<polygon class="cls-1" points="238.16 287.5 238.16 362.5 173.21 400 173.21 325"/>
<polygon class="cls-1" points="346.42 100 346.42 350 281.46 387.5 281.46 137.5"/>
<polygon class="cls-3" points="346.42 100 281.46 137.5 173.21 75 64.96 137.5 0 100 173.21 0"/>
<polygon class="cls-2" points="173.21 325 173.21 400 0 300 0 100 64.96 137.5 64.96 262.5"/>
<polygon class="cls-3" points="238.16 162.5 173.21 200 108.26 162.5 173.21 125"/>
<polygon class="cls-2" points="173.21 200 173.21 275 108.26 237.5 108.26 162.5"/>
<polygon class="cls-1" points="238.16 162.5 238.16 237.5 173.21 275 173.21 200"/>
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

@@ -0,0 +1,522 @@
import { mock } from 'jest-mock-extended';
import { NodeOperationError, type ILoadOptionsFunctions } from 'n8n-workflow';
// Mock external modules that are not needed for these unit tests
jest.mock('@langchain/redis', () => {
const state: any = { ctorArgs: undefined };
class RedisVectorStore {
static fromDocuments = jest.fn();
constructor(...args: any[]) {
state.ctorArgs = args;
}
}
return { RedisVectorStore, __state: state };
});
jest.mock('@n8n/ai-utilities', () => ({
metadataFilterField: {},
getMetadataFiltersValues: jest.fn(),
logAiEvent: jest.fn(),
N8nBinaryLoader: class {},
N8nJsonLoader: class {},
logWrapper: (fn: any) => fn,
createVectorStoreNode: (config: any) =>
class BaseNode {
async getVectorStoreClient(...args: any[]) {
return config.getVectorStoreClient.apply(config, args);
}
async populateVectorStore(...args: any[]) {
return config.populateVectorStore.apply(config, args);
}
},
}));
jest.mock('redis', () => ({ createClient: jest.fn() }));
import { createClient } from 'redis';
import * as RedisNode from './VectorStoreRedis.node';
const MockCreateClient = createClient as jest.MockedFunction<typeof createClient>;
describe('VectorStoreRedis.node', () => {
const helpers = mock<ILoadOptionsFunctions['helpers']>();
const loadOptionsFunctions = mock<ILoadOptionsFunctions>({ helpers });
loadOptionsFunctions.logger = {
info: jest.fn(),
debug: jest.fn(),
error: jest.fn(),
warn: jest.fn(),
verbose: jest.fn(),
} as any;
const baseCredentials = {
host: 'localhost',
port: 6379,
ssl: false,
user: 'default',
password: 'pass',
database: 0,
} as any;
beforeEach(() => {
jest.resetAllMocks();
// Reset cached client
RedisNode.redisConfig.client = null as any;
RedisNode.redisConfig.connectionString = '';
});
describe('getRedisClient', () => {
it('creates and reuses client for same configuration', async () => {
const mockClient = {
on: jest.fn(),
connect: jest.fn().mockResolvedValue(undefined),
disconnect: jest.fn().mockResolvedValue(undefined),
quit: jest.fn().mockResolvedValue(undefined),
} as any;
MockCreateClient.mockReturnValue(mockClient);
const context = {
getCredentials: jest.fn().mockResolvedValue(baseCredentials),
} as any;
const client1 = await RedisNode.getRedisClient(context);
const client2 = await RedisNode.getRedisClient(context);
expect(MockCreateClient).toHaveBeenCalledTimes(1);
expect(mockClient.connect).toHaveBeenCalledTimes(1);
expect(mockClient.disconnect).not.toHaveBeenCalled();
expect(client1).toBe(mockClient);
expect(client2).toBe(mockClient);
});
it('disconnects previous client and creates a new one when configuration changes', async () => {
const mockClient1 = {
on: jest.fn(),
connect: jest.fn().mockResolvedValue(undefined),
disconnect: jest.fn().mockResolvedValue(undefined),
quit: jest.fn().mockResolvedValue(undefined),
} as any;
const mockClient2 = {
on: jest.fn(),
connect: jest.fn().mockResolvedValue(undefined),
disconnect: jest.fn().mockResolvedValue(undefined),
quit: jest.fn().mockResolvedValue(undefined),
} as any;
MockCreateClient.mockImplementationOnce(() => mockClient1).mockImplementationOnce(
() => mockClient2,
);
const context = {
getCredentials: jest
.fn()
.mockResolvedValueOnce(baseCredentials)
.mockResolvedValueOnce({ ...baseCredentials, port: 6380 }),
} as any;
const client1 = await RedisNode.getRedisClient(context);
const client2 = await RedisNode.getRedisClient(context);
expect(MockCreateClient).toHaveBeenCalledTimes(2);
expect(mockClient1.disconnect).toHaveBeenCalledTimes(1);
expect(mockClient2.connect).toHaveBeenCalledTimes(1);
expect(client1).toBe(mockClient1);
expect(client2).toBe(mockClient2);
});
});
describe('listIndexes', () => {
it('returns mapped indexes when FT._LIST succeeds', async () => {
const mockClient = {
on: jest.fn(),
connect: jest.fn().mockResolvedValue(undefined),
disconnect: jest.fn(),
quit: jest.fn(),
ft: { _list: jest.fn().mockResolvedValue(['Idx1', 'Idx2']) },
} as any;
MockCreateClient.mockReturnValue(mockClient);
(loadOptionsFunctions as any).getCredentials = jest.fn().mockResolvedValue(baseCredentials);
const results = await (RedisNode.listIndexes as any).call(loadOptionsFunctions as any);
expect(mockClient.ft._list).toHaveBeenCalled();
expect(results).toEqual({
results: [
{ name: 'Idx1', value: 'Idx1' },
{ name: 'Idx2', value: 'Idx2' },
],
});
});
it('returns empty results when FT._LIST fails', async () => {
const mockClient = {
on: jest.fn(),
connect: jest.fn().mockResolvedValue(undefined),
disconnect: jest.fn(),
quit: jest.fn(),
ft: { _list: jest.fn().mockRejectedValue(new Error('no module')) },
} as any;
MockCreateClient.mockReturnValue(mockClient);
const failureCredentials = { ...baseCredentials, port: 6380 };
(loadOptionsFunctions as any).getCredentials = jest
.fn()
.mockResolvedValue(failureCredentials);
const results = await (RedisNode.listIndexes as any).call(loadOptionsFunctions as any);
expect(results).toEqual({ results: [] });
});
it('returns empty results when FT._LIST returns unexpected data type', async () => {
const mockClient = {
on: jest.fn(),
connect: jest.fn().mockResolvedValue(undefined),
disconnect: jest.fn(),
quit: jest.fn(),
ft: { _list: jest.fn().mockResolvedValue({ unexpected: 'object' }) },
} as any;
MockCreateClient.mockReturnValue(mockClient);
(loadOptionsFunctions as any).getCredentials = jest.fn().mockResolvedValue(baseCredentials);
const results = await (RedisNode.listIndexes as any).call(loadOptionsFunctions as any);
expect(results).toEqual({ results: [] });
expect(loadOptionsFunctions.logger.warn).toHaveBeenCalledWith(
'FT._LIST returned unexpected data type',
);
});
});
describe('getVectorStoreClient', () => {
it('constructs ExtendedRedisVectorSearch with correct options and passes filter tokens', async () => {
const mockClient = {
on: jest.fn(),
connect: jest.fn().mockResolvedValue(undefined),
disconnect: jest.fn(),
quit: jest.fn(),
sendCommand: jest
.fn()
.mockImplementation(async ([cmd]) =>
cmd === 'FT.INFO' ? await Promise.resolve(undefined) : await Promise.resolve([]),
),
} as any;
// Adapt to new client.ft.info usage
mockClient.ft = { ...(mockClient.ft || {}), info: jest.fn().mockResolvedValue(undefined) };
(MockCreateClient as any).mockReturnValue(mockClient);
// Provide a base class method that ExtendedRedisVectorSearch will call via super
const RedisVectorStoreMod: any = jest.requireMock('@langchain/redis');
RedisVectorStoreMod.RedisVectorStore.prototype.similaritySearchVectorWithScore = jest
.fn()
.mockResolvedValue('ok');
const context: any = {
getCredentials: jest.fn().mockResolvedValue(baseCredentials),
getNodeParameter: (name: string) => {
const map: Record<string, any> = {
redisIndex: 'myIndex',
'options.keyPrefix': 'doc',
'options.metadataKey': 'm',
'options.contentKey': 'c',
'options.vectorKey': 'v',
'options.metadataFilter': 'a,b',
};
return map[name];
},
getNode: () => ({ name: 'VectorStoreRedis' }),
logger: loadOptionsFunctions.logger,
} as any;
const embeddings: any = {};
const instance = new RedisNode.VectorStoreRedis();
const client = await (instance as any).getVectorStoreClient(
context,
undefined,
embeddings,
0,
);
// Ensure FT.INFO is called to validate index
expect(mockClient.ft.info).toHaveBeenCalledWith('myIndex');
// The base class constructor should have been called with embeddings and options
const state = RedisVectorStoreMod.__state;
expect(state.ctorArgs[0]).toBe(embeddings);
expect(state.ctorArgs[1]).toMatchObject({
redisClient: mockClient,
indexName: 'myIndex',
keyPrefix: 'doc',
metadataKey: 'm',
contentKey: 'c',
vectorKey: 'v',
});
// Call the overridden method and ensure behavior is as expected
const res = await client.similaritySearchVectorWithScore([1, 2], 3);
expect(res).toBe('ok');
// Validate filter tokens got captured on the instance
expect(client.defaultFilter).toEqual(['a', 'b']);
});
it('trims and removes empty metadata filter tokens', async () => {
const mockClient = {
on: jest.fn(),
connect: jest.fn().mockResolvedValue(undefined),
disconnect: jest.fn(),
quit: jest.fn(),
ft: { info: jest.fn().mockResolvedValue(undefined) },
} as any;
(MockCreateClient as any).mockReturnValue(mockClient);
const RedisVectorStoreMod: any = jest.requireMock('@langchain/redis');
RedisVectorStoreMod.RedisVectorStore.prototype.similaritySearchVectorWithScore = jest
.fn()
.mockResolvedValue('ok');
const context: any = {
getCredentials: jest.fn().mockResolvedValue(baseCredentials),
getNodeParameter: (name: string) => {
const map: Record<string, any> = {
redisIndex: 'idx2',
'options.keyPrefix': '',
'options.metadataKey': '',
'options.contentKey': '',
'options.vectorKey': '',
'options.metadataFilter': 'tag1, tag2 , ,tag3',
};
return map[name];
},
getNode: () => ({ name: 'VectorStoreRedis' }),
logger: loadOptionsFunctions.logger,
} as any;
const node = new RedisNode.VectorStoreRedis();
const client = await (node as any).getVectorStoreClient(context, undefined, {}, 0);
// Ensure trimming/removal works
expect(client.defaultFilter).toEqual(['tag1', 'tag2', 'tag3']);
});
it('omits optional keys when empty/whitespace and handles empty filter as null', async () => {
const mockClient = {
on: jest.fn(),
connect: jest.fn().mockResolvedValue(undefined),
disconnect: jest.fn(),
quit: jest.fn(),
ft: { info: jest.fn().mockResolvedValue(undefined) },
} as any;
(MockCreateClient as any).mockReturnValue(mockClient);
const RedisVectorStoreMod: any = jest.requireMock('@langchain/redis');
RedisVectorStoreMod.RedisVectorStore.prototype.similaritySearchVectorWithScore = jest
.fn()
.mockResolvedValue('ok');
const context: any = {
getCredentials: jest.fn().mockResolvedValue(baseCredentials),
getNodeParameter: (name: string) => {
const map: Record<string, any> = {
redisIndex: 'myIndex',
'options.keyPrefix': ' ',
'options.metadataKey': ' ',
'options.contentKey': '',
'options.vectorKey': ' \t',
'options.metadataFilter': '',
};
return map[name];
},
getNode: () => ({ name: 'VectorStoreRedis' }),
logger: loadOptionsFunctions.logger,
} as any;
const embeddings: any = {};
const node = new RedisNode.VectorStoreRedis();
const instance = await (node as any).getVectorStoreClient(context, undefined, embeddings, 0);
// Ensure FT.INFO is called to validate index
expect(mockClient.ft.info).toHaveBeenCalledWith('myIndex');
const opts = RedisVectorStoreMod.__state.ctorArgs[1];
expect(opts).toMatchObject({ redisClient: mockClient, indexName: 'myIndex' });
expect(opts).not.toHaveProperty('keyPrefix');
expect(opts).not.toHaveProperty('metadataKey');
expect(opts).not.toHaveProperty('contentKey');
expect(opts).not.toHaveProperty('vectorKey');
const res = await instance.similaritySearchVectorWithScore([0], 1);
expect(res).toBeDefined();
expect(instance.defaultFilter).toBeUndefined();
});
it('returns undefined filter when filter string contains only whitespace and commas', async () => {
const mockClient = {
on: jest.fn(),
connect: jest.fn().mockResolvedValue(undefined),
disconnect: jest.fn(),
quit: jest.fn(),
ft: { info: jest.fn().mockResolvedValue(undefined) },
} as any;
(MockCreateClient as any).mockReturnValue(mockClient);
const RedisVectorStoreMod: any = jest.requireMock('@langchain/redis');
RedisVectorStoreMod.RedisVectorStore.prototype.similaritySearchVectorWithScore = jest
.fn()
.mockResolvedValue('ok');
const context: any = {
getCredentials: jest.fn().mockResolvedValue(baseCredentials),
getNodeParameter: (name: string) => {
const map: Record<string, any> = {
redisIndex: 'myIndex',
'options.keyPrefix': '',
'options.metadataKey': '',
'options.contentKey': '',
'options.vectorKey': '',
'options.metadataFilter': ' , , , ',
};
return map[name];
},
getNode: () => ({ name: 'VectorStoreRedis' }),
logger: loadOptionsFunctions.logger,
} as any;
const node = new RedisNode.VectorStoreRedis();
const instance = await (node as any).getVectorStoreClient(context, undefined, {}, 0);
// Filter with only whitespace and commas should result in undefined
expect(instance.defaultFilter).toBeUndefined();
});
it('throws NodeOperationError when index is missing', async () => {
const mockClient = {
on: jest.fn(),
connect: jest.fn().mockResolvedValue(undefined),
disconnect: jest.fn(),
quit: jest.fn(),
ft: { info: jest.fn().mockRejectedValue(new Error('no such index')) },
} as any;
(MockCreateClient as any).mockReturnValue(mockClient);
const context: any = {
getCredentials: jest.fn().mockResolvedValue(baseCredentials),
getNodeParameter: (name: string) => (name === 'redisIndex' ? 'idx' : ''),
getNode: () => ({ name: 'VectorStoreRedis' }),
};
const node = new RedisNode.VectorStoreRedis();
await expect((node as any).getVectorStoreClient(context, undefined, {}, 0)).rejects.toEqual(
new NodeOperationError(context.getNode(), 'Index idx not found', {
itemIndex: 0,
description: 'Please check that the index exists in your Redis instance',
}),
);
});
});
describe('populateVectorStore', () => {
it('drops index and deletes the documents when overwrite is true; passes TTL and batch size', async () => {
const mockClient = {
on: jest.fn(),
connect: jest.fn().mockResolvedValue(undefined),
disconnect: jest.fn(),
quit: jest.fn(),
ft: { dropIndex: jest.fn().mockResolvedValue(undefined) },
} as any;
(MockCreateClient as any).mockReturnValue(mockClient);
const RedisVectorStoreMod: any = jest.requireMock('@langchain/redis');
RedisVectorStoreMod.RedisVectorStore.fromDocuments = jest.fn().mockResolvedValue(undefined);
const context: any = {
getCredentials: jest.fn().mockResolvedValue(baseCredentials),
getNodeParameter: (name: string) => {
const map: Record<string, any> = {
redisIndex: 'myIndex',
'options.overwriteDocuments': true,
'options.keyPrefix': 'doc',
'options.metadataKey': 'm',
'options.contentKey': 'c',
'options.vectorKey': 'v',
'options.ttl': 60,
embeddingBatchSize: 123,
};
return map[name];
},
getNode: () => ({ name: 'VectorStoreRedis' }),
logger: loadOptionsFunctions.logger,
} as any;
const node = new RedisNode.VectorStoreRedis();
await (node as any).populateVectorStore(
context,
{},
[{ pageContent: 'hello', metadata: {} }],
0,
);
expect(mockClient.ft.dropIndex).toHaveBeenCalledWith('myIndex', { DD: true });
expect(RedisVectorStoreMod.RedisVectorStore.fromDocuments).toHaveBeenCalledWith(
[{ pageContent: 'hello', metadata: {} }],
{},
{
redisClient: mockClient,
indexName: 'myIndex',
keyPrefix: 'doc',
metadataKey: 'm',
contentKey: 'c',
vectorKey: 'v',
ttl: 60,
},
);
});
it('logs and throws NodeOperationError on failure', async () => {
const mockClient = {
on: jest.fn(),
connect: jest.fn().mockResolvedValue(undefined),
disconnect: jest.fn(),
quit: jest.fn(),
sendCommand: jest.fn().mockResolvedValue(undefined),
} as any;
(MockCreateClient as any).mockReturnValue(mockClient);
const RedisVectorStoreMod: any = jest.requireMock('@langchain/redis');
RedisVectorStoreMod.RedisVectorStore.fromDocuments = jest
.fn()
.mockRejectedValue(new Error('fail'));
const context: any = {
getCredentials: jest.fn().mockResolvedValue(baseCredentials),
getNodeParameter: (name: string) => (name === 'redisIndex' ? 'idx' : ''),
getNode: () => ({ name: 'VectorStoreRedis' }),
logger: loadOptionsFunctions.logger,
} as any;
const node = new RedisNode.VectorStoreRedis();
await expect((node as any).populateVectorStore(context, {}, [], 0)).rejects.toEqual(
new NodeOperationError(context.getNode(), 'Error: fail', {
itemIndex: 0,
description: 'Please check your index/schema and parameters',
}),
);
expect(loadOptionsFunctions.logger.info).toHaveBeenCalledWith(
'Error while populating the store: fail',
);
});
});
});
@@ -0,0 +1,414 @@
import type { EmbeddingsInterface } from '@langchain/core/embeddings';
import { RedisVectorStore } from '@langchain/redis';
import type { RedisVectorStoreConfig } from '@langchain/redis/dist/vectorstores';
import {
type IExecuteFunctions,
type ILoadOptionsFunctions,
type INodeProperties,
type ISupplyDataFunctions,
NodeOperationError,
} from 'n8n-workflow';
import type { RedisClientOptions } from 'redis';
import { createClient } from 'redis';
import { createVectorStoreNode } from '@n8n/ai-utilities';
/**
* Constants for the name of the credentials and Node parameters.
*/
const REDIS_CREDENTIALS = 'redis';
const REDIS_INDEX_NAME = 'redisIndex';
const REDIS_KEY_PREFIX = 'keyPrefix';
const REDIS_OVERWRITE_DOCUMENTS = 'overwriteDocuments';
const REDIS_METADATA_KEY = 'metadataKey';
const REDIS_METADATA_FILTER = 'metadataFilter';
const REDIS_CONTENT_KEY = 'contentKey';
const REDIS_EMBEDDING_KEY = 'vectorKey';
const REDIS_TTL = 'ttl';
const redisIndexRLC: INodeProperties = {
displayName: 'Redis Index',
name: REDIS_INDEX_NAME,
type: 'resourceLocator',
default: { mode: 'list', value: '' },
required: true,
modes: [
{
displayName: 'From List',
name: 'list',
type: 'list',
typeOptions: {
searchListMethod: 'redisIndexSearch',
},
},
{
displayName: 'ID',
name: 'id',
type: 'string',
},
],
};
const metadataFilterField: INodeProperties = {
displayName: 'Metadata Filter',
name: REDIS_METADATA_FILTER,
type: 'string',
description:
'The comma-separated list of words by which to apply additional full-text metadata filtering',
placeholder: 'Item1,Item2,Item3',
default: '',
};
const metadataKeyField: INodeProperties = {
displayName: 'Metadata Key',
name: REDIS_METADATA_KEY,
type: 'string',
description: 'The hash key to be used to store the metadata of the document',
placeholder: 'metadata',
default: '',
};
const contentKeyField: INodeProperties = {
displayName: 'Content Key',
name: REDIS_CONTENT_KEY,
type: 'string',
description: 'The hash key to be used to store the content of the document',
placeholder: 'content',
default: '',
};
const embeddingKeyField: INodeProperties = {
displayName: 'Embedding Key',
name: REDIS_EMBEDDING_KEY,
type: 'string',
description: 'The hash key to be used to store the embedding of the document',
placeholder: 'content_vector',
default: '',
};
const overwriteDocuments: INodeProperties = {
displayName: 'Overwrite Documents',
name: REDIS_OVERWRITE_DOCUMENTS,
type: 'boolean',
description: 'Whether existing documents and the index should be overwritten',
default: false,
};
const keyPrefixField: INodeProperties = {
displayName: 'Key Prefix',
name: REDIS_KEY_PREFIX,
type: 'string',
description: 'Prefix for Redis keys storing the documents',
placeholder: 'doc',
default: '',
};
const ttlField: INodeProperties = {
displayName: 'Time-To-Live',
name: REDIS_TTL,
description: 'Time-to-live for the documents in seconds',
placeholder: '0',
type: 'number',
default: '',
};
const sharedFields: INodeProperties[] = [redisIndexRLC];
const insertFields: INodeProperties[] = [
{
displayName: 'Options',
name: 'options',
type: 'collection',
placeholder: 'Add Option',
default: {},
options: [
keyPrefixField,
overwriteDocuments,
metadataKeyField,
contentKeyField,
embeddingKeyField,
ttlField,
],
},
];
const retrieveFields: INodeProperties[] = [
{
displayName: 'Options',
name: 'options',
type: 'collection',
placeholder: 'Add Option',
default: {},
options: [
metadataFilterField,
keyPrefixField,
metadataKeyField,
contentKeyField,
embeddingKeyField,
],
},
];
export const redisConfig = {
client: null as ReturnType<typeof createClient> | null,
connectionString: '',
};
/**
* Type used for cleaner, more intentional typing.
*/
type IFunctionsContext = IExecuteFunctions | ISupplyDataFunctions | ILoadOptionsFunctions;
/**
* Get the Redis client.
* @param context - The context.
* @returns the Redis client for the node.
*/
export async function getRedisClient(
context: IFunctionsContext,
): Promise<ReturnType<typeof createClient> | null> {
const credentials = await context.getCredentials(REDIS_CREDENTIALS);
// Create client configuration object
const config: RedisClientOptions = {
socket: {
host: (credentials.host as string) || 'localhost',
port: (credentials.port as number) || 6379,
tls: credentials.ssl === true,
},
username: credentials.user as string,
password: credentials.password as string,
database: credentials.database as number,
clientInfoTag: 'n8n',
};
if (!redisConfig.client || redisConfig.connectionString !== JSON.stringify(config)) {
if (redisConfig.client) {
await redisConfig.client.disconnect();
}
redisConfig.connectionString = JSON.stringify(config);
redisConfig.client = createClient(config);
if (redisConfig.client) {
redisConfig.client.on('error', (error: Error) => {
context.logger.error(`[Redis client] ${error.message}`, { error });
});
await redisConfig.client.connect();
}
}
return redisConfig.client;
}
/**
* Type guard to check if a value is a string array.
* @param value - The value to check.
* @returns True if the value is a string array, false otherwise.
*/
function isStringArray(value: unknown): value is string[] {
return Array.isArray(value) && value.every((item) => typeof item === 'string');
}
/**
* Get the complete list of indexes from Redis.
* @returns The list of indexes.
*/
export async function listIndexes(this: ILoadOptionsFunctions) {
const client = await getRedisClient(this);
if (client === null) {
return { results: [] };
}
try {
// Get all indexes using FT._LIST command
const indexes = await client.ft._list();
// Validate that indexes is actually a string array
if (!isStringArray(indexes)) {
this.logger.warn('FT._LIST returned unexpected data type');
return { results: [] };
}
const results = indexes.map((index) => ({
name: index,
value: index,
}));
return { results };
} catch (error) {
this.logger.info('Failed to get Redis indexes: ' + error.message);
return { results: [] };
}
}
/**
* Get a parameter from the context.
* @param key - The key of the parameter.
* @param context - The context.
* @param itemIndex - The index.
* @returns The value.
*/
export function getParameter(key: string, context: IFunctionsContext, itemIndex: number): string {
return context.getNodeParameter(key, itemIndex, '', {
extractValue: true,
}) as string;
}
/**
* Get a parameter from the context as a number.
* @param key - The key of the parameter.
* @param context - The context.
* @param itemIndex - The index.
* @returns The value.
*/
export function getParameterAsNumber(
key: string,
context: IFunctionsContext,
itemIndex: number,
): number {
return context.getNodeParameter(key, itemIndex, '', {
extractValue: true,
}) as number;
}
/**
* Extended RedisVectorStore class to handle custom filtering.
*
* This wrapper is necessary because when used as a retriever, the similaritySearchVectorWithScore should
* use a processed filter
*/
class ExtendedRedisVectorSearch extends RedisVectorStore {
defaultFilter?: string[];
constructor(embeddings: EmbeddingsInterface, options: RedisVectorStoreConfig, filter?: string[]) {
super(embeddings, options);
this.defaultFilter = filter;
}
async similaritySearchVectorWithScore(query: number[], k: number) {
return await super.similaritySearchVectorWithScore(query, k, this.defaultFilter);
}
}
const getIndexName = getParameter.bind(null, REDIS_INDEX_NAME);
const getKeyPrefix = getParameter.bind(null, `options.${REDIS_KEY_PREFIX}`);
const getOverwrite = getParameter.bind(null, `options.${REDIS_OVERWRITE_DOCUMENTS}`);
const getContentKey = getParameter.bind(null, `options.${REDIS_CONTENT_KEY}`);
const getMetadataFilter = getParameter.bind(null, `options.${REDIS_METADATA_FILTER}`);
const getMetadataKey = getParameter.bind(null, `options.${REDIS_METADATA_KEY}`);
const getEmbeddingKey = getParameter.bind(null, `options.${REDIS_EMBEDDING_KEY}`);
const getTtl = getParameterAsNumber.bind(null, `options.${REDIS_TTL}`);
export class VectorStoreRedis extends createVectorStoreNode({
meta: {
displayName: 'Redis Vector Store',
name: 'vectorStoreRedis',
description: 'Work with your data in a Redis vector index',
icon: { light: 'file:redis.svg', dark: 'file:redis.dark.svg' },
docsUrl:
'https://docs.n8n.io/integrations/builtin/cluster-nodes/root-nodes/n8n-nodes-langchain.vectorstoreredis/',
credentials: [
{
name: REDIS_CREDENTIALS,
required: true,
},
],
operationModes: ['load', 'insert', 'retrieve', 'update', 'retrieve-as-tool'],
},
methods: { listSearch: { redisIndexSearch: listIndexes } },
retrieveFields,
loadFields: retrieveFields,
insertFields,
sharedFields,
async getVectorStoreClient(context, _filter, embeddings, itemIndex) {
const client = await getRedisClient(context);
const indexField = getIndexName(context, itemIndex).trim();
const keyPrefixField = getKeyPrefix(context, itemIndex).trim();
const metadataField = getMetadataKey(context, itemIndex).trim();
const contentField = getContentKey(context, itemIndex).trim();
const embeddingField = getEmbeddingKey(context, itemIndex).trim();
const filter = getMetadataFilter(context, itemIndex).trim();
if (client === null) {
throw new NodeOperationError(context.getNode(), 'Redis client not initialized', {
itemIndex,
description: 'Please check your Redis connection details',
});
}
// Check if index exists by trying to get info about it
try {
await client.ft.info(indexField);
} catch (error) {
throw new NodeOperationError(context.getNode(), `Index ${indexField} not found`, {
itemIndex,
description: 'Please check that the index exists in your Redis instance',
});
}
// Process filter: split by comma, trim, and remove empty strings
// If no valid filter terms exist, pass undefined instead of empty array
const filterTerms = filter
? filter
.split(',')
.map((s) => s.trim())
.filter((s) => s)
: [];
return new ExtendedRedisVectorSearch(
embeddings,
{
redisClient: client,
indexName: indexField,
...(keyPrefixField ? { keyPrefix: keyPrefixField } : {}),
...(metadataField ? { metadataKey: metadataField } : {}),
...(contentField ? { contentKey: contentField } : {}),
...(embeddingField ? { vectorKey: embeddingField } : {}),
},
filterTerms.length > 0 ? filterTerms : undefined,
);
},
async populateVectorStore(context, embeddings, documents, itemIndex) {
const client = await getRedisClient(context);
if (client === null) {
throw new NodeOperationError(context.getNode(), 'Redis client not initialized', {
itemIndex,
description: 'Please check your Redis connection details',
});
}
try {
const indexField = getIndexName(context, itemIndex).trim();
const overwrite = getOverwrite(context, itemIndex);
const keyPrefixField = getKeyPrefix(context, itemIndex).trim();
const metadataField = getMetadataKey(context, itemIndex).trim();
const contentField = getContentKey(context, itemIndex).trim();
const embeddingField = getEmbeddingKey(context, itemIndex).trim();
const ttl = getTtl(context, itemIndex);
if (overwrite) {
await client.ft.dropIndex(indexField, { DD: true });
}
await ExtendedRedisVectorSearch.fromDocuments(documents, embeddings, {
redisClient: client,
indexName: indexField,
...(keyPrefixField ? { keyPrefix: keyPrefixField } : {}),
...(metadataField ? { metadataKey: metadataField } : {}),
...(contentField ? { contentKey: contentField } : {}),
...(embeddingField ? { vectorKey: embeddingField } : {}),
...(ttl ? { ttl } : {}),
});
} catch (error) {
context.logger.info(`Error while populating the store: ${error.message}`);
throw new NodeOperationError(context.getNode(), `Error: ${error.message}`, {
itemIndex,
description: 'Please check your index/schema and parameters',
});
}
},
}) {}
@@ -0,0 +1,37 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generator: Adobe Illustrator 28.0.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd" [
<!ENTITY ns_extend "http://ns.adobe.com/Extensibility/1.0/">
<!ENTITY ns_ai "http://ns.adobe.com/AdobeIllustrator/10.0/">
<!ENTITY ns_graphs "http://ns.adobe.com/Graphs/1.0/">
<!ENTITY ns_vars "http://ns.adobe.com/Variables/1.0/">
<!ENTITY ns_imrep "http://ns.adobe.com/ImageReplacement/1.0/">
<!ENTITY ns_sfw "http://ns.adobe.com/SaveForWeb/1.0/">
<!ENTITY ns_custom "http://ns.adobe.com/GenericCustomNamespace/1.0/">
<!ENTITY ns_adobe_xpath "http://ns.adobe.com/XPath/1.0/">
]>
<svg version="1.1" id="Layer_1" xmlns:x="&ns_extend;" xmlns:i="&ns_ai;" xmlns:graph="&ns_graphs;"
xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px" viewBox="0 0 156.0529938 144"
style="enable-background:new 0 0 156.0529938 144;" xml:space="preserve">
<style type="text/css">
.st0{fill:#FFFFFF;}
</style>
<metadata>
<sfw xmlns="&ns_sfw;">
<slices></slices>
<sliceSourceBounds bottomLeftOrigin="true" height="143.9999773" width="156.053" x="0" y="-143.9999773"></sliceSourceBounds>
</sfw>
</metadata>
<path class="st0" d="M147.6701355,79.4482651c-10.7946014,13.6011963-22.4527664,29.1454239-45.769104,29.1454239
c-20.826828,0-28.5858688-18.3700104-29.1312943-33.2931747c4.5630341,9.6490402,13.4846039,17.4628448,27.4041595,17.1012726
c26.7706146-0.8635712,45.1214371-25.0434799,45.1214371-47.0644608C145.2953339,18.998497,125.6491547,0,91.5382156,0
C67.1424179,0,36.9175339,9.2833567,17.0554695,23.9640141c-0.2158928,15.1124401,8.2038975,34.7586136,11.2263851,32.5996933
c17.2190762-12.3804779,30.8731899-20.3501434,44.1166077-24.3462524C52.7944527,54.0785789,5.7588773,104.8390808,0,113.7750931
c0.6476761,8.2038956,10.7946014,30.2248917,15.7601175,30.2248917c1.5112448,0,2.8065958-0.8635712,4.3178396-2.3748169
c14.1801414-15.9326324,25.7396431-30.2172928,36.0214996-43.985733
c1.443779,20.1794739,11.3667984,44.8493042,39.1089249,44.8493042c24.8275833,0,49.4392776-17.9190445,60.665657-58.2908478
C157.1693878,79.2323685,151.1244202,75.3463135,147.6701355,79.4482651z M119.3882904,46.848568
c0,12.7376289-12.5217438,18.9985008-23.9640198,18.9985008c-6.1162338,0-10.8146286-1.6060715-14.5303345-3.6929359
c6.8368607-10.3524857,13.6045761-20.9680901,20.8757477-32.3321495
C114.5905914,31.9920235,119.3882904,39.118515,119.3882904,46.848568z"/>
</svg>

After

Width:  |  Height:  |  Size: 2.5 KiB

@@ -0,0 +1,37 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generator: Adobe Illustrator 28.0.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd" [
<!ENTITY ns_extend "http://ns.adobe.com/Extensibility/1.0/">
<!ENTITY ns_ai "http://ns.adobe.com/AdobeIllustrator/10.0/">
<!ENTITY ns_graphs "http://ns.adobe.com/Graphs/1.0/">
<!ENTITY ns_vars "http://ns.adobe.com/Variables/1.0/">
<!ENTITY ns_imrep "http://ns.adobe.com/ImageReplacement/1.0/">
<!ENTITY ns_sfw "http://ns.adobe.com/SaveForWeb/1.0/">
<!ENTITY ns_custom "http://ns.adobe.com/GenericCustomNamespace/1.0/">
<!ENTITY ns_adobe_xpath "http://ns.adobe.com/XPath/1.0/">
]>
<svg version="1.1" id="Layer_1" xmlns:x="&ns_extend;" xmlns:i="&ns_ai;" xmlns:graph="&ns_graphs;"
xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px" viewBox="0 0 156.0529938 144"
style="enable-background:new 0 0 156.0529938 144;" xml:space="preserve">
<style type="text/css">
.st0{fill:#FF4438;}
</style>
<metadata>
<sfw xmlns="&ns_sfw;">
<slices></slices>
<sliceSourceBounds bottomLeftOrigin="true" height="144" width="156.0530246" x="0" y="-144"></sliceSourceBounds>
</sfw>
</metadata>
<path class="st0" d="M147.670166,79.4482727c-10.7946014,13.6012039-22.452774,29.1454315-45.7691193,29.1454315
c-20.8268356,0-28.5858765-18.3700104-29.1313019-33.2931824c4.5630417,9.6490479,13.4846115,17.4628525,27.4041672,17.1012802
c26.7706146-0.8635712,45.1214371-25.0434799,45.1214371-47.0644722C145.2953491,18.9985008,125.6491776,0,91.5382309,0
C67.1424255,0,36.9175415,9.2833586,17.0554714,23.9640179c-0.2158909,15.1124439,8.2038994,34.7586212,11.226387,32.5997009
c17.21908-12.3804817,30.8731976-20.3501434,44.1166153-24.3462563C52.7944603,54.0785866,5.7588782,104.8391037,0,113.775116
C0.6476762,121.9790115,10.7946024,144,15.7601204,144c1.5112438,0,2.8065968-0.8635712,4.3178406-2.3748169
c14.1801453-15.9326324,25.7396469-30.2172928,36.0215034-43.985733
c1.443779,20.1794739,11.366806,44.8493042,39.1089325,44.8493042c24.8275833,0,49.4392776-17.9190369,60.6656723-58.2908554
C157.1694183,79.2323837,151.1244354,75.3463287,147.670166,79.4482727z M119.3883057,46.8485756
c0,12.7376328-12.5217361,18.9985008-23.9640198,18.9985008c-6.1162338,0-10.8146286-1.6060638-14.5303345-3.6929359
c6.8368607-10.3524857,13.6045761-20.9680939,20.8757477-32.3321533
C114.5906143,31.9920292,119.3883057,39.1185226,119.3883057,46.8485756z"/>
</svg>

After

Width:  |  Height:  |  Size: 2.5 KiB

@@ -0,0 +1,113 @@
import { SupabaseVectorStore } from '@langchain/community/vectorstores/supabase';
import { createClient } from '@supabase/supabase-js';
import { NodeOperationError, type INodeProperties } from 'n8n-workflow';
import { metadataFilterField, createVectorStoreNode } from '@n8n/ai-utilities';
import { supabaseTableNameSearch } from '../shared/methods/listSearch';
import { supabaseTableNameRLC } from '../shared/descriptions';
const queryNameField: INodeProperties = {
displayName: 'Query Name',
name: 'queryName',
type: 'string',
default: 'match_documents',
description: 'Name of the query to use for matching documents',
};
const sharedFields: INodeProperties[] = [supabaseTableNameRLC];
const insertFields: INodeProperties[] = [
{
displayName: 'Options',
name: 'options',
type: 'collection',
placeholder: 'Add Option',
default: {},
options: [queryNameField],
},
];
const retrieveFields: INodeProperties[] = [
{
displayName: 'Options',
name: 'options',
type: 'collection',
placeholder: 'Add Option',
default: {},
options: [queryNameField, metadataFilterField],
},
];
const updateFields: INodeProperties[] = [...insertFields];
export class VectorStoreSupabase extends createVectorStoreNode<SupabaseVectorStore>({
meta: {
description: 'Work with your data in Supabase Vector Store',
icon: 'file:supabase.svg',
displayName: 'Supabase Vector Store',
docsUrl:
'https://docs.n8n.io/integrations/builtin/cluster-nodes/root-nodes/n8n-nodes-langchain.vectorstoresupabase/',
name: 'vectorStoreSupabase',
credentials: [
{
name: 'supabaseApi',
required: true,
},
],
operationModes: ['load', 'insert', 'retrieve', 'update', 'retrieve-as-tool'],
},
methods: {
listSearch: { supabaseTableNameSearch },
},
sharedFields,
insertFields,
loadFields: retrieveFields,
retrieveFields,
updateFields,
async getVectorStoreClient(context, filter, embeddings, itemIndex) {
const tableName = context.getNodeParameter('tableName', itemIndex, '', {
extractValue: true,
}) as string;
const options = context.getNodeParameter('options', itemIndex, {}) as {
queryName: string;
};
const credentials = await context.getCredentials('supabaseApi');
const client = createClient(credentials.host as string, credentials.serviceRole as string);
return await SupabaseVectorStore.fromExistingIndex(embeddings, {
client,
tableName,
queryName: options.queryName ?? 'match_documents',
filter,
});
},
async populateVectorStore(context, embeddings, documents, itemIndex) {
const tableName = context.getNodeParameter('tableName', itemIndex, '', {
extractValue: true,
}) as string;
const options = context.getNodeParameter('options', itemIndex, {}) as {
queryName: string;
};
const credentials = await context.getCredentials('supabaseApi');
const client = createClient(credentials.host as string, credentials.serviceRole as string);
try {
await SupabaseVectorStore.fromDocuments(documents, embeddings, {
client,
tableName,
queryName: options.queryName ?? 'match_documents',
});
} catch (error) {
if ((error as Error).message === 'Error inserting: undefined 404 Not Found') {
throw new NodeOperationError(context.getNode(), `Table ${tableName} not found`, {
itemIndex,
description: 'Please check that the table exists in your vector store',
});
} else {
throw new NodeOperationError(context.getNode(), error as Error, {
itemIndex,
});
}
}
},
}) {}
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="109" height="113" fill="none"><path fill="url(#a)" d="M63.708 110.284c-2.86 3.601-8.658 1.628-8.727-2.97l-1.007-67.251h45.22c8.19 0 12.758 9.46 7.665 15.874z"/><path fill="url(#b)" fill-opacity=".2" d="M63.708 110.284c-2.86 3.601-8.658 1.628-8.727-2.97l-1.007-67.251h45.22c8.19 0 12.758 9.46 7.665 15.874z"/><path fill="#3ECF8E" d="M45.317 2.071c2.86-3.601 8.657-1.628 8.726 2.97l.442 67.251H9.83c-8.19 0-12.759-9.46-7.665-15.875z"/><defs><linearGradient id="a" x1="53.974" x2="94.163" y1="54.974" y2="71.829" gradientUnits="userSpaceOnUse"><stop stop-color="#249361"/><stop offset="1" stop-color="#3ECF8E"/></linearGradient><linearGradient id="b" x1="36.156" x2="54.484" y1="30.578" y2="65.081" gradientUnits="userSpaceOnUse"><stop/><stop offset="1" stop-opacity="0"/></linearGradient></defs></svg>

After

Width:  |  Height:  |  Size: 846 B

@@ -0,0 +1,127 @@
import { SupabaseVectorStore } from '@langchain/community/vectorstores/supabase';
import type { Document } from '@langchain/core/documents';
import type { Embeddings } from '@langchain/core/embeddings';
import { createClient } from '@supabase/supabase-js';
import {
type IExecuteFunctions,
type INodeType,
type INodeTypeDescription,
type INodeExecutionData,
NodeConnectionTypes,
} from 'n8n-workflow';
import { processDocuments, type N8nJsonLoader } from '@n8n/ai-utilities';
import { supabaseTableNameSearch } from '../shared/methods/listSearch';
import { supabaseTableNameRLC } from '../shared/descriptions';
// This node is deprecated. Use VectorStoreSupabase instead.
export class VectorStoreSupabaseInsert implements INodeType {
description: INodeTypeDescription = {
displayName: 'Supabase: Insert',
// Vector Store nodes got merged into a single node
hidden: true,
name: 'vectorStoreSupabaseInsert',
icon: 'file:supabase.svg',
group: ['transform'],
version: 1,
description:
'Insert data into Supabase Vector Store index [https://supabase.com/docs/guides/ai/langchain]',
defaults: {
name: 'Supabase: Insert',
},
codex: {
categories: ['AI'],
subcategories: {
AI: ['Vector Stores'],
},
resources: {
primaryDocumentation: [
{
url: 'https://docs.n8n.io/integrations/builtin/cluster-nodes/root-nodes/n8n-nodes-langchain.vectorstoresupabase/',
},
],
},
},
credentials: [
{
name: 'supabaseApi',
required: true,
},
],
inputs: [
NodeConnectionTypes.Main,
{
displayName: 'Document',
maxConnections: 1,
type: NodeConnectionTypes.AiDocument,
required: true,
},
{
displayName: 'Embedding',
maxConnections: 1,
type: NodeConnectionTypes.AiEmbedding,
required: true,
},
],
outputs: [NodeConnectionTypes.Main],
properties: [
{
displayName:
'Please refer to the <a href="https://supabase.com/docs/guides/ai/langchain" target="_blank">Supabase documentation</a> for more information on how to setup your database as a Vector Store.',
name: 'setupNotice',
type: 'notice',
default: '',
},
supabaseTableNameRLC,
{
displayName: 'Query Name',
name: 'queryName',
type: 'string',
default: 'match_documents',
required: true,
description: 'Name of the query to use for matching documents',
},
{
displayName: 'Specify the document to load in the document loader sub-node',
name: 'notice',
type: 'notice',
default: '',
},
],
};
methods = { listSearch: { supabaseTableNameSearch } };
async execute(this: IExecuteFunctions): Promise<INodeExecutionData[][]> {
this.logger.debug('Executing data for Supabase Insert Vector Store');
const items = this.getInputData(0);
const tableName = this.getNodeParameter('tableName', 0, '', { extractValue: true }) as string;
const queryName = this.getNodeParameter('queryName', 0) as string;
const credentials = await this.getCredentials('supabaseApi');
const documentInput = (await this.getInputConnectionData(NodeConnectionTypes.AiDocument, 0)) as
| N8nJsonLoader
| Array<Document<Record<string, unknown>>>;
const embeddings = (await this.getInputConnectionData(
NodeConnectionTypes.AiEmbedding,
0,
)) as Embeddings;
const client = createClient(credentials.host as string, credentials.serviceRole as string);
const { processedDocuments, serializedDocuments } = await processDocuments(
documentInput,
items,
);
await SupabaseVectorStore.fromDocuments(processedDocuments, embeddings, {
client,
tableName,
queryName,
});
return [serializedDocuments];
}
}
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="109" height="113" fill="none"><path fill="url(#a)" d="M63.708 110.284c-2.86 3.601-8.658 1.628-8.727-2.97l-1.007-67.251h45.22c8.19 0 12.758 9.46 7.665 15.874z"/><path fill="url(#b)" fill-opacity=".2" d="M63.708 110.284c-2.86 3.601-8.658 1.628-8.727-2.97l-1.007-67.251h45.22c8.19 0 12.758 9.46 7.665 15.874z"/><path fill="#3ECF8E" d="M45.317 2.071c2.86-3.601 8.657-1.628 8.726 2.97l.442 67.251H9.83c-8.19 0-12.759-9.46-7.665-15.875z"/><defs><linearGradient id="a" x1="53.974" x2="94.163" y1="54.974" y2="71.829" gradientUnits="userSpaceOnUse"><stop stop-color="#249361"/><stop offset="1" stop-color="#3ECF8E"/></linearGradient><linearGradient id="b" x1="36.156" x2="54.484" y1="30.578" y2="65.081" gradientUnits="userSpaceOnUse"><stop/><stop offset="1" stop-opacity="0"/></linearGradient></defs></svg>

After

Width:  |  Height:  |  Size: 846 B

@@ -0,0 +1,112 @@
import type { SupabaseLibArgs } from '@langchain/community/vectorstores/supabase';
import { SupabaseVectorStore } from '@langchain/community/vectorstores/supabase';
import type { Embeddings } from '@langchain/core/embeddings';
import { createClient } from '@supabase/supabase-js';
import {
type INodeType,
type INodeTypeDescription,
type ISupplyDataFunctions,
type SupplyData,
NodeConnectionTypes,
} from 'n8n-workflow';
import { logWrapper, getMetadataFiltersValues, metadataFilterField } from '@n8n/ai-utilities';
import { supabaseTableNameSearch } from '../shared/methods/listSearch';
import { supabaseTableNameRLC } from '../shared/descriptions';
// This node is deprecated. Use VectorStoreSupabase instead.
export class VectorStoreSupabaseLoad implements INodeType {
description: INodeTypeDescription = {
displayName: 'Supabase: Load',
name: 'vectorStoreSupabaseLoad',
icon: 'file:supabase.svg',
// Vector Store nodes got merged into a single node
hidden: true,
group: ['transform'],
version: 1,
description: 'Load data from Supabase Vector Store index',
defaults: {
name: 'Supabase: Load',
},
codex: {
categories: ['AI'],
subcategories: {
AI: ['Vector Stores'],
},
resources: {
primaryDocumentation: [
{
url: 'https://docs.n8n.io/integrations/builtin/cluster-nodes/root-nodes/n8n-nodes-langchain.vectorstoresupabase/',
},
],
},
},
credentials: [
{
name: 'supabaseApi',
required: true,
},
],
inputs: [
{
displayName: 'Embedding',
maxConnections: 1,
type: NodeConnectionTypes.AiEmbedding,
required: true,
},
],
outputs: [NodeConnectionTypes.AiVectorStore],
outputNames: ['Vector Store'],
properties: [
supabaseTableNameRLC,
{
displayName: 'Query Name',
name: 'queryName',
type: 'string',
default: 'match_documents',
required: true,
description: 'Name of the query to use for matching documents',
},
{
displayName: 'Options',
name: 'options',
type: 'collection',
placeholder: 'Add Option',
default: {},
options: [metadataFilterField],
},
],
};
methods = { listSearch: { supabaseTableNameSearch } };
async supplyData(this: ISupplyDataFunctions, itemIndex: number): Promise<SupplyData> {
this.logger.debug('Supply Supabase Load Vector Store');
const tableName = this.getNodeParameter('tableName', itemIndex, '', {
extractValue: true,
}) as string;
const queryName = this.getNodeParameter('queryName', itemIndex) as string;
const credentials = await this.getCredentials('supabaseApi');
const embeddings = (await this.getInputConnectionData(
NodeConnectionTypes.AiEmbedding,
0,
)) as Embeddings;
const client = createClient(credentials.host as string, credentials.serviceRole as string);
const config: SupabaseLibArgs = {
client,
tableName,
queryName,
filter: getMetadataFiltersValues(this, itemIndex),
};
const vectorStore = await SupabaseVectorStore.fromExistingIndex(embeddings, config);
return {
response: logWrapper(vectorStore, this),
};
}
}
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="109" height="113" fill="none"><path fill="url(#a)" d="M63.708 110.284c-2.86 3.601-8.658 1.628-8.727-2.97l-1.007-67.251h45.22c8.19 0 12.758 9.46 7.665 15.874z"/><path fill="url(#b)" fill-opacity=".2" d="M63.708 110.284c-2.86 3.601-8.658 1.628-8.727-2.97l-1.007-67.251h45.22c8.19 0 12.758 9.46 7.665 15.874z"/><path fill="#3ECF8E" d="M45.317 2.071c2.86-3.601 8.657-1.628 8.726 2.97l.442 67.251H9.83c-8.19 0-12.759-9.46-7.665-15.875z"/><defs><linearGradient id="a" x1="53.974" x2="94.163" y1="54.974" y2="71.829" gradientUnits="userSpaceOnUse"><stop stop-color="#249361"/><stop offset="1" stop-color="#3ECF8E"/></linearGradient><linearGradient id="b" x1="36.156" x2="54.484" y1="30.578" y2="65.081" gradientUnits="userSpaceOnUse"><stop/><stop offset="1" stop-opacity="0"/></linearGradient></defs></svg>

After

Width:  |  Height:  |  Size: 846 B

@@ -0,0 +1,410 @@
import { Document } from '@langchain/core/documents';
import type { Embeddings } from '@langchain/core/embeddings';
import type { WeaviateLibArgs as OriginalWeaviateLibArgs } from '@langchain/weaviate';
import { WeaviateStore } from '@langchain/weaviate';
import {
ApplicationError,
type IDataObject,
type INodeProperties,
type INodePropertyCollection,
type INodePropertyOptions,
} from 'n8n-workflow';
import { type ProxiesParams, type TimeoutParams } from 'weaviate-client';
import type { WeaviateCompositeFilter, WeaviateCredential } from './Weaviate.utils';
import { createWeaviateClient, parseCompositeFilter } from './Weaviate.utils';
import { createVectorStoreNode } from '@n8n/ai-utilities';
import { weaviateCollectionsSearch } from '../shared/methods/listSearch';
import { weaviateCollectionRLC } from '../shared/descriptions';
type WeaviateLibArgs = OriginalWeaviateLibArgs & {
hybridQuery?: string;
autoCutLimit?: number;
alpha?: number;
queryProperties?: string;
maxVectorDistance?: number;
fusionType?: 'Ranked' | 'RelativeScore';
hybridExplainScore?: boolean;
};
class ExtendedWeaviateVectorStore extends WeaviateStore {
private defaultFilter?: WeaviateCompositeFilter;
private args!: WeaviateLibArgs;
static async fromExistingCollection(
embeddings: Embeddings,
args: WeaviateLibArgs,
defaultFilter?: WeaviateCompositeFilter,
): Promise<ExtendedWeaviateVectorStore> {
// Call parent factory method but bound to this (subclass) so the created instance is of the subclass
const ctor = this as unknown as typeof ExtendedWeaviateVectorStore & typeof WeaviateStore;
const baseCandidate = await ctor.fromExistingIndex(embeddings, args);
if (!(baseCandidate instanceof ExtendedWeaviateVectorStore)) {
throw new ApplicationError(
'Weaviate store factory did not return an ExtendedWeaviateVectorStore instance',
);
}
const base = baseCandidate;
// Attach per-instance config
base.args = args;
if (defaultFilter) {
base.defaultFilter = defaultFilter;
}
return base;
}
async similaritySearchVectorWithScore(query: number[], k: number, filter?: IDataObject) {
filter = filter ?? this.defaultFilter;
const args = this.args;
if (args.hybridQuery) {
const options = {
limit: k ?? undefined,
autoLimit: args.autoCutLimit ?? undefined,
alpha: args.alpha ?? undefined,
vector: query,
filter: filter ? parseCompositeFilter(filter as WeaviateCompositeFilter) : undefined,
queryProperties: args.queryProperties
? args.queryProperties.split(',').map((prop) => prop.trim())
: undefined,
maxVectorDistance: args.maxVectorDistance ?? undefined,
fusionType: args.fusionType,
returnMetadata: args.hybridExplainScore ? ['explainScore'] : undefined,
};
const content = await super.hybridSearch(args.hybridQuery, options);
return content.map((doc) => {
const { score, ...metadata } = doc.metadata;
if (typeof score !== 'number') {
throw new ApplicationError(`Unexpected score type: ${typeof score}`);
}
return [
new Document({
pageContent: doc.pageContent,
metadata,
}),
score,
] as [Document, number];
});
}
return await super.similaritySearchVectorWithScore(
query,
k,
filter ? parseCompositeFilter(filter as WeaviateCompositeFilter) : undefined,
);
}
}
const sharedFields: INodeProperties[] = [weaviateCollectionRLC];
const shared_options: Array<INodePropertyOptions | INodeProperties | INodePropertyCollection> = [
{
displayName: 'Tenant Name',
name: 'tenant',
type: 'string',
default: undefined,
validateType: 'string',
description: 'Tenant Name. Collection must have been created with tenant support enabled.',
},
{
displayName: 'Text Key',
name: 'textKey',
type: 'string',
default: 'text',
validateType: 'string',
description: 'The key in the document that contains the embedded text',
},
{
displayName: 'Skip Init Checks',
name: 'skip_init_checks',
type: 'boolean',
default: false,
validateType: 'boolean',
description: 'Whether to skip init checks while instantiating the client',
},
{
displayName: 'Init Timeout',
name: 'timeout_init',
type: 'number',
default: 2,
validateType: 'number',
description: 'Number of timeout seconds for initial checks',
},
{
displayName: 'Insert Timeout',
name: 'timeout_insert',
type: 'number',
default: 90,
validateType: 'number',
description: 'Number of timeout seconds for inserts',
},
{
displayName: 'Query Timeout',
name: 'timeout_query',
type: 'number',
default: 30,
validateType: 'number',
description: 'Number of timeout seconds for queries',
},
{
displayName: 'GRPC Proxy',
name: 'proxy_grpc',
type: 'string',
default: undefined,
validateType: 'string',
description: 'Proxy to use for GRPC',
},
];
const insertFields: INodeProperties[] = [
{
displayName: 'Options',
name: 'options',
type: 'collection',
placeholder: 'Add Option',
default: {},
options: [
...shared_options,
{
displayName: 'Clear Data',
name: 'clearStore',
type: 'boolean',
default: false,
description: 'Whether to clear the Collection/Tenant before inserting new data',
},
],
},
];
const retrieveFields: INodeProperties[] = [
{
displayName: 'Options',
name: 'options',
type: 'collection',
placeholder: 'Add Option',
default: {},
options: [
{
displayName: 'Search Filters',
name: 'searchFilterJson',
type: 'json',
typeOptions: {
rows: 5,
},
default:
'{\n "OR": [\n {\n "path": ["pdf_info_Author"],\n "operator": "Equal",\n "valueString": "Elis"\n },\n {\n "path": ["pdf_info_Author"],\n "operator": "Equal",\n "valueString": "Pinnacle"\n } \n ]\n}',
validateType: 'object',
description:
'Filter pageContent or metadata using this <a href="https://weaviate.io/" target="_blank">filtering syntax</a>',
},
{
displayName: 'Metadata Keys',
name: 'metadataKeys',
type: 'string',
default: 'source,page',
validateType: 'string',
description: 'Select the metadata to retrieve along the content',
},
{
displayName: 'Hybrid: Query Text',
name: 'hybridQuery',
type: 'string',
default: '',
validateType: 'string',
description: 'Provide a query text to combine vector search with a keyword/text search',
},
{
displayName: 'Hybrid: Explain Score',
name: 'hybridExplainScore',
type: 'boolean',
default: false,
validateType: 'boolean',
description: 'Whether to show the score fused between hybrid and vector search explanation',
},
{
displayName: 'Hybrid: Fusion Type',
name: 'fusionType',
type: 'options',
options: [
{
name: 'Relative Score',
value: 'RelativeScore',
},
{
name: 'Ranked',
value: 'Ranked',
},
],
default: 'RelativeScore',
description: 'Select the fusion type for combining vector and keyword search results',
},
{
displayName: 'Hybrid: Auto Cut Limit',
name: 'autoCutLimit',
type: 'number',
default: undefined,
validateType: 'number',
description: 'Limit result groups by detecting sudden jumps in score',
},
{
displayName: 'Hybrid: Alpha',
name: 'alpha',
type: 'number',
default: 0.5,
validateType: 'number',
description:
'Change the relative weights of the keyword and vector components. 1.0 = pure vector, 0.0 = pure keyword.',
},
{
displayName: 'Hybrid: Query Properties',
name: 'queryProperties',
type: 'string',
default: '',
validateType: 'string',
description:
'Comma-separated list of properties to include in the query with optionally weighted values, e.g., "question^2,answer"',
},
{
displayName: 'Hybrid: Max Vector Distance',
name: 'maxVectorDistance',
type: 'number',
default: undefined,
validateType: 'number',
description: 'Set the maximum allowable distance for the vector search component',
},
...shared_options,
],
},
];
export class VectorStoreWeaviate extends createVectorStoreNode<ExtendedWeaviateVectorStore>({
meta: {
displayName: 'Weaviate Vector Store',
name: 'vectorStoreWeaviate',
description: 'Work with your data in a Weaviate Cluster',
icon: 'file:weaviate.svg',
docsUrl:
'https://docs.n8n.io/integrations/builtin/cluster-nodes/root-nodes/n8n-nodes-langchain.vectorstoreweaviate/',
credentials: [
{
name: 'weaviateApi',
required: true,
},
],
},
methods: {
listSearch: { weaviateCollectionsSearch },
},
loadFields: retrieveFields,
insertFields,
sharedFields,
retrieveFields,
async getVectorStoreClient(context, filter, embeddings, itemIndex) {
const collection = context.getNodeParameter('weaviateCollection', itemIndex, '', {
extractValue: true,
}) as string;
const options = context.getNodeParameter('options', itemIndex, {}) as {
queryProperties: string;
maxVectorDistance: number;
fusionType: 'Ranked' | 'RelativeScore';
alpha?: number;
autoCutLimit?: number;
hybridQuery?: string;
tenant?: string;
textKey?: string;
timeout_init: number;
timeout_insert: number;
timeout_query: number;
skip_init_checks: boolean;
proxy_grpc: string;
metadataKeys?: string;
hybridExplainScore?: boolean;
};
// check if textKey is valid
const credentials = await context.getCredentials('weaviateApi');
const timeout = {
query: options.timeout_query,
init: options.timeout_init,
insert: options.timeout_insert,
};
const proxies = {
grpc: options.proxy_grpc,
};
const client = await createWeaviateClient(
credentials as WeaviateCredential,
timeout as TimeoutParams,
proxies as ProxiesParams,
options.skip_init_checks,
);
const metadataKeys = options.metadataKeys ? options.metadataKeys.split(',') : [];
const config: WeaviateLibArgs = {
client,
indexName: collection,
tenant: options.tenant ?? undefined,
textKey: options.textKey ? options.textKey : 'text',
metadataKeys: metadataKeys as string[] | undefined,
hybridQuery: options.hybridQuery ?? undefined,
autoCutLimit: options.autoCutLimit ?? undefined,
alpha: options.alpha ?? undefined,
queryProperties: options.queryProperties,
maxVectorDistance: options.maxVectorDistance,
fusionType: options.fusionType,
hybridExplainScore: options.hybridExplainScore ?? false,
};
const validFilter = (filter && Object.keys(filter).length > 0 ? filter : undefined) as
| WeaviateCompositeFilter
| undefined;
return await ExtendedWeaviateVectorStore.fromExistingCollection(
embeddings,
config,
validFilter,
);
},
async populateVectorStore(context, embeddings, documents, itemIndex) {
const collectionName = context.getNodeParameter('weaviateCollection', itemIndex, '', {
extractValue: true,
}) as string;
const options = context.getNodeParameter('options', itemIndex, {}) as {
tenant?: string;
textKey?: string;
clearStore?: boolean;
metadataKeys?: string;
};
const credentials = await context.getCredentials('weaviateApi');
const metadataKeys = options.metadataKeys ? options.metadataKeys.split(',') : [];
const client = await createWeaviateClient(credentials as WeaviateCredential);
const config: WeaviateLibArgs = {
client,
indexName: collectionName,
tenant: options.tenant ?? undefined,
textKey: options.textKey ? options.textKey : 'text',
metadataKeys: metadataKeys as string[] | undefined,
};
if (options.clearStore) {
if (!options.tenant) {
await client.collections.delete(collectionName);
} else {
const collection = client.collections.get(collectionName);
await collection.tenants.remove([{ name: options.tenant }]);
}
}
await WeaviateStore.fromDocuments(documents, embeddings, config);
},
}) {}
@@ -0,0 +1,141 @@
import { OperationalError } from 'n8n-workflow';
import type {
FilterValue,
GeoRangeFilter,
ProxiesParams,
TimeoutParams,
WeaviateClient,
} from 'weaviate-client';
import weaviate, { Filters } from 'weaviate-client';
export type WeaviateCredential = {
weaviate_cloud_endpoint: string;
weaviate_api_key: string;
custom_connection_http_host: string;
custom_connection_http_port: number;
custom_connection_http_secure: boolean;
custom_connection_grpc_host: string;
custom_connection_grpc_port: number;
custom_connection_grpc_secure: boolean;
};
export async function createWeaviateClient(
credentials: WeaviateCredential,
timeout?: TimeoutParams,
proxies?: ProxiesParams,
skipInitChecks: boolean = false,
): Promise<WeaviateClient> {
if (credentials.weaviate_cloud_endpoint) {
const weaviateClient: WeaviateClient = await weaviate.connectToWeaviateCloud(
credentials.weaviate_cloud_endpoint,
{
authCredentials: new weaviate.ApiKey(credentials.weaviate_api_key),
timeout,
skipInitChecks,
},
);
return weaviateClient;
} else {
const weaviateClient: WeaviateClient = await weaviate.connectToCustom({
httpHost: credentials.custom_connection_http_host,
httpPort: credentials.custom_connection_http_port,
grpcHost: credentials.custom_connection_grpc_host,
grpcPort: credentials.custom_connection_grpc_port,
grpcSecure: credentials.custom_connection_grpc_secure,
httpSecure: credentials.custom_connection_http_secure,
authCredentials: credentials.weaviate_api_key
? new weaviate.ApiKey(credentials.weaviate_api_key)
: undefined,
timeout,
proxies,
skipInitChecks,
});
return weaviateClient;
}
}
type WeaviateFilterUnit = {
path: string[];
operator: string;
valueString?: string;
valueTextArray?: string[];
valueBoolean?: boolean;
valueNumber?: number;
valueGeoCoordinates?: GeoRangeFilter;
};
export type WeaviateCompositeFilter = { AND: WeaviateFilterUnit[] } | { OR: WeaviateFilterUnit[] };
function buildFilter(filter: WeaviateFilterUnit): FilterValue {
const { path, operator } = filter;
const property = weaviate.filter.byProperty(path[0]);
switch (operator.toLowerCase()) {
case 'equal':
if (filter.valueString !== undefined) return property.equal(filter.valueString);
if (filter.valueNumber !== undefined) return property.equal(filter.valueNumber);
break;
case 'like':
if (filter.valueString === undefined) {
throw new OperationalError("Missing 'valueString' for 'like' operator.");
}
return property.like(filter.valueString);
case 'containsany':
if (filter.valueTextArray === undefined) {
throw new OperationalError("Missing 'valueTextArray' for 'containsAny' operator.");
}
return property.containsAny(filter.valueTextArray);
case 'containsall':
if (filter.valueTextArray === undefined) {
throw new OperationalError("Missing 'valueTextArray' for 'containsAll' operator.");
}
return property.containsAll(filter.valueTextArray);
case 'greaterthan':
if (filter.valueNumber === undefined) {
throw new OperationalError("Missing 'valueNumber' for 'greaterThan' operator.");
}
return property.greaterThan(filter.valueNumber);
case 'lessthan':
if (filter.valueNumber === undefined) {
throw new OperationalError("Missing 'valueNumber' for 'lessThan' operator.");
}
return property.lessThan(filter.valueNumber);
case 'isnull':
if (filter.valueBoolean === undefined) {
throw new OperationalError("Missing 'valueBoolean' for 'isNull' operator.");
}
return property.isNull(filter.valueBoolean);
case 'withingeorange':
if (!filter.valueGeoCoordinates) {
throw new OperationalError("Missing 'valueGeoCoordinates' for 'withinGeoRange' operator.");
}
return property.withinGeoRange(filter.valueGeoCoordinates);
default:
throw new OperationalError(`Unsupported operator: ${operator}`);
}
throw new OperationalError(`No valid filter value provided for operator: ${operator}`);
}
export function parseCompositeFilter(
filter: WeaviateCompositeFilter | WeaviateFilterUnit,
): FilterValue {
// Handle composite filters (AND/OR)
if (typeof filter === 'object' && ('AND' in filter || 'OR' in filter)) {
if ('AND' in filter) {
return Filters.and(...filter.AND.map(buildFilter));
} else if ('OR' in filter) {
return Filters.or(...filter.OR.map(buildFilter));
}
}
// Handle individual filter units
return buildFilter(filter);
}
@@ -0,0 +1,2 @@
<?xml version="1.0" encoding="utf-8"?>
<svg width="256px" height="296px" viewBox="0 0 256 296" version="1.1" xmlns="http://www.w3.org/2000/svg" preserveAspectRatio="xMidYMid" xmlns:bx="https://boxy-svg.com"><title>qdrant</title><defs><linearGradient id="linear-gradient" x1="39.84" y1="-2385.28" x2="34.01" y2="-2442.48" gradientTransform="translate(0 2433.92)" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#75be2c"/><stop offset="0.86" stop-color="#9dc03b"/></linearGradient><linearGradient id="linear-gradient-2" x1="37.06" y1="-2409.12" x2="37.06" y2="-2390.37" gradientTransform="translate(0 2433.92)" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#238d37"/><stop offset="0.94" stop-color="#35537f"/></linearGradient><linearGradient id="linear-gradient-3" x1="35.07" y1="-2418.83" x2="37.98" y2="-2399.75" gradientTransform="translate(0 2433.92)" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#67d84d"/><stop offset="1" stop-color="#348522"/></linearGradient><linearGradient id="linear-gradient-4" x1="64.03" y1="-2433.87" x2="64.03" y2="-2400.61" gradientTransform="translate(0 2433.92)" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#e4d00a"/><stop offset="0.56" stop-color="#c4d132"/></linearGradient><linearGradient id="linear-gradient-5" x1="10.04" y1="-2433.87" x2="10.04" href="#linear-gradient-4"/><linearGradient id="linear-gradient-6" x1="56.43" y1="-2400.25" x2="56.43" y2="-2389.16" gradientTransform="translate(0 2433.92)" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#8ab11b"/><stop offset="1" stop-color="#6eaf02"/></linearGradient><linearGradient id="linear-gradient-7" x1="64.51" y1="-2413.64" x2="62.18" y2="-2394.97" href="#linear-gradient"/><linearGradient id="linear-gradient-8" x1="17.64" y1="-2400.21" x2="17.64" y2="-2389.17" href="#linear-gradient-6"/><linearGradient id="linear-gradient-9" x1="11.82" y1="-2413.55" x2="9.53" y2="-2391.1" href="#linear-gradient"/><bx:export><bx:file format="svg" path="qdrant.svg"/></bx:export></defs><g transform="matrix(3.331938, 0, 0, 3.331938, 3.665985, 60.223675)" style=""><path class="cls-1" d="M71.67,7.95L58.85.5c-2.15-1.25-4.85.31-4.85,2.79v18.84l-9.1-5.24c-4.88-2.81-10.88-2.8-15.74.02l-9.06,5.24V3.28c0-2.49-2.69-4.04-4.85-2.79L2.4,7.94c-1.49.86-2.4,2.46-2.4,4.18v17.71c0,1.24.31,2.42.88,3.46h0c.58,1.07,1.42,1.98,2.48,2.66l6.61,4.22,5.06,3.22c3.04,1.93,6.95,1.78,9.83-.39l.38-.3s.07-.06.11-.08l7.63-5.82c2.23-1.71,5.88-1.71,8.12,0l7.61,5.8s.03.02.04.03l.47.37c2.87,2.17,6.79,2.32,9.83.39l5.06-3.23,6.63-4.22c1.04-.67,1.88-1.59,2.46-2.64s.89-2.23.89-3.47V12.13h0c0-1.72-.91-3.32-2.4-4.18h.01Z" style="fill: url(&quot;#linear-gradient&quot;); stroke-width: 0px;"/><path class="cls-3" d="M54.1,33.62v6.36c0,1.96-1.41,3.56-3.11,3.56-.67,0-1.51-.34-2.29-.93l-7.61-5.8c-2.23-1.71-5.88-1.71-8.12,0l-7.63,5.82c-.76.57-1.25.74-1.97.74-1.77.02-3.35-1.31-3.35-3.39v-6.34l10.9-7.04c3.74-2.41,8.52-2.41,12.25,0l10.92,6.88v.13h0Z" style="fill: url(&quot;#linear-gradient-2&quot;); stroke-width: 0px;"/><path class="cls-4" d="M54,22.13l.03,11.59-10.84-6.93c-3.73-2.39-8.51-2.39-12.25,0l-10.87,6.94.02-11.58,9.07-5.25c4.86-2.82,10.86-2.82,15.74-.02l9.1,5.24h0Z" style="fill: url(&quot;#linear-gradient-3&quot;); stroke-width: 0px;"/><path class="cls-2" d="M74.07,12.13v17.7c0,1.25-.31,2.42-.89,3.47l-19.19-11.17V3.28c0-2.49,2.7-4.04,4.85-2.79l12.82,7.46c1.49.86,2.4,2.46,2.4,4.18h0Z" style="fill: url(&quot;#linear-gradient-4&quot;); stroke-width: 0px;"/><path class="cls-8" d="M20.08,3.28v18.87L.88,33.31C.31,32.26,0,31.08,0,29.84V12.13C0,10.41.91,8.81,2.4,7.95L15.23.49c2.15-1.25,4.85.31,4.85,2.79h0Z" style="fill: url(&quot;#linear-gradient-5&quot;); stroke-width: 0px;"/><path class="cls-9" d="M50.72,43.32c1.7,0,3.3-1.34,3.3-3.28v-6.36s10.13,6.47,10.13,6.47l-5.12,3.26c-3.04,1.93-6.95,1.79-9.83-.39l-.48-.38c.72.49,1.33.68,2,.68h0Z" style="fill: url(&quot;#linear-gradient-6&quot;); stroke-width: 0px;"/><path class="cls-7" d="M73.18,33.31c-.58,1.05-1.42,1.97-2.46,2.64l-6.63,4.22-10.08-6.45-.03-11.59,19.19,11.18h0Z" style="fill: url(&quot;#linear-gradient-7&quot;); stroke-width: 0px;"/><path class="cls-5" d="M20.08,40.03c0,1.94,1.61,3.47,3.3,3.28.75-.09,1.34-.2,1.94-.66l-.46.36c-2.87,2.16-6.79,2.32-9.83.39l-5.06-3.23h0s10.11-6.46,10.11-6.46v6.31h0Z" style="fill: url(&quot;#linear-gradient-8&quot;); stroke-width: 0px;"/><path class="cls-6" d="M20.08,22.15v11.57l-10.11,6.45h0s-6.61-4.2-6.61-4.2c-1.05-.67-1.9-1.59-2.48-2.66l19.19-11.16h0Z" style="fill: url(&quot;#linear-gradient-9&quot;); stroke-width: 0px;"/></g></svg>

After

Width:  |  Height:  |  Size: 4.5 KiB

@@ -0,0 +1,73 @@
import { ZepVectorStore } from '@langchain/community/vectorstores/zep';
import { ZepCloudVectorStore } from '@langchain/community/vectorstores/zep_cloud';
import { mock } from 'jest-mock-extended';
import type { ISupplyDataFunctions } from 'n8n-workflow';
import { VectorStoreZep } from './VectorStoreZep.node';
describe('VectorStoreZep', () => {
const vectorStore = new VectorStoreZep();
const helpers = mock<ISupplyDataFunctions['helpers']>();
const executeFunctions = mock<ISupplyDataFunctions>({ helpers });
beforeEach(() => {
jest.resetAllMocks();
executeFunctions.addInputData.mockReturnValue({ index: 0 });
});
it('should get vector store cloud client', async () => {
executeFunctions.getNodeParameter.mockImplementation((paramName: string) => {
switch (paramName) {
case 'mode':
return 'retrieve';
case 'collectionName':
return 'test-collection';
case 'options':
return {};
default:
return undefined;
}
});
executeFunctions.getCredentials.mockResolvedValue(
mock({
apiKey: 'some-key',
cloud: true,
}),
);
const { response } = await vectorStore.supplyData.call(executeFunctions, 0);
expect(response).toBeDefined();
expect(response).toBeInstanceOf(ZepCloudVectorStore);
});
it('should get vector store self-hosted client', async () => {
executeFunctions.getNodeParameter.mockImplementation((paramName: string) => {
switch (paramName) {
case 'mode':
return 'retrieve';
case 'collectionName':
return 'test-collection';
case 'options':
return {};
default:
return undefined;
}
});
executeFunctions.getCredentials.mockResolvedValue(
mock({
apiKey: 'some-key',
apiUrl: 'https://example.com',
cloud: false,
}),
);
const { response } = await vectorStore.supplyData.call(executeFunctions, 0);
expect(response).toBeDefined();
expect(response).toBeInstanceOf(ZepVectorStore);
});
});
@@ -0,0 +1,151 @@
import { ZepVectorStore } from '@langchain/community/vectorstores/zep';
import { ZepCloudVectorStore } from '@langchain/community/vectorstores/zep_cloud';
import type { IDataObject, INodeProperties } from 'n8n-workflow';
import { NodeOperationError } from 'n8n-workflow';
import { metadataFilterField, createVectorStoreNode } from '@n8n/ai-utilities';
const embeddingDimensions: INodeProperties = {
displayName: 'Embedding Dimensions',
name: 'embeddingDimensions',
type: 'number',
default: 1536,
description: 'Whether to allow using characters from the Unicode surrogate blocks',
};
const insertFields: INodeProperties[] = [
{
displayName: 'Options',
name: 'options',
type: 'collection',
placeholder: 'Add Option',
default: {},
options: [
embeddingDimensions,
{
displayName: 'Is Auto Embedded',
name: 'isAutoEmbedded',
type: 'boolean',
default: true,
description: 'Whether to automatically embed documents when they are added',
},
],
},
];
const retrieveFields: INodeProperties[] = [
{
displayName: 'Options',
name: 'options',
type: 'collection',
placeholder: 'Add Option',
default: {},
options: [embeddingDimensions, metadataFilterField],
},
];
export class VectorStoreZep extends createVectorStoreNode<ZepVectorStore | ZepCloudVectorStore>({
meta: {
displayName: 'Zep Vector Store',
name: 'vectorStoreZep',
hidden: true,
description: 'Work with your data in Zep Vector Store',
credentials: [
{
name: 'zepApi',
required: true,
},
],
icon: 'file:zep.png',
docsUrl:
'https://docs.n8n.io/integrations/builtin/cluster-nodes/root-nodes/n8n-nodes-langchain.vectorstorezep/',
},
sharedFields: [
{
displayName: 'This Zep integration is deprecated and will be removed in a future version.',
name: 'deprecationNotice',
type: 'notice',
default: '',
},
{
displayName: 'Collection Name',
name: 'collectionName',
type: 'string',
default: '',
required: true,
},
],
insertFields,
loadFields: retrieveFields,
retrieveFields,
async getVectorStoreClient(context, filter, embeddings, itemIndex) {
const collectionName = context.getNodeParameter('collectionName', itemIndex) as string;
const options =
(context.getNodeParameter('options', itemIndex) as {
embeddingDimensions?: number;
}) || {};
const credentials = await context.getCredentials<{
apiKey?: string;
apiUrl: string;
cloud: boolean;
}>('zepApi');
const zepConfig = {
apiKey: credentials.apiKey,
collectionName,
embeddingDimensions: options.embeddingDimensions ?? 1536,
metadata: filter,
};
if (credentials.cloud) {
return new ZepCloudVectorStore(embeddings, zepConfig);
} else {
return new ZepVectorStore(embeddings, { ...zepConfig, apiUrl: credentials.apiUrl });
}
},
async populateVectorStore(context, embeddings, documents, itemIndex) {
const collectionName = context.getNodeParameter('collectionName', itemIndex) as string;
const options =
(context.getNodeParameter('options', itemIndex) as {
isAutoEmbedded?: boolean;
embeddingDimensions?: number;
}) || {};
const credentials = await context.getCredentials<{
apiKey?: string;
apiUrl: string;
cloud: boolean;
}>('zepApi');
const zepConfig = {
apiKey: credentials.apiKey,
collectionName,
embeddingDimensions: options.embeddingDimensions ?? 1536,
isAutoEmbedded: options.isAutoEmbedded ?? true,
};
try {
if (credentials.cloud) {
await ZepCloudVectorStore.fromDocuments(documents, embeddings, zepConfig);
} else {
await ZepVectorStore.fromDocuments(documents, embeddings, {
...zepConfig,
apiUrl: credentials.apiUrl,
});
}
} catch (error) {
const errorCode = (error as IDataObject).code as number;
const responseData = (error as IDataObject).responseData as string;
if (errorCode === 400 && responseData.includes('CreateDocumentCollectionRequest')) {
throw new NodeOperationError(context.getNode(), `Collection ${collectionName} not found`, {
itemIndex,
description:
'Please check that the collection exists in your vector store, or make sure that collection name contains only alphanumeric characters',
});
}
throw new NodeOperationError(context.getNode(), error as Error, { itemIndex });
}
},
}) {}
Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

@@ -0,0 +1,150 @@
import { ZepVectorStore } from '@langchain/community/vectorstores/zep';
import type { Document } from '@langchain/core/documents';
import type { Embeddings } from '@langchain/core/embeddings';
import {
type IExecuteFunctions,
type INodeType,
type INodeTypeDescription,
type INodeExecutionData,
NodeConnectionTypes,
} from 'n8n-workflow';
import { processDocuments, type N8nJsonLoader } from '@n8n/ai-utilities';
// This node is deprecated. Use VectorStoreZep instead.
export class VectorStoreZepInsert implements INodeType {
description: INodeTypeDescription = {
displayName: 'Zep Vector Store: Insert',
name: 'vectorStoreZepInsert',
hidden: true,
// eslint-disable-next-line n8n-nodes-base/node-class-description-icon-not-svg
icon: 'file:zep.png',
group: ['transform'],
version: 1,
description: 'Insert data into Zep Vector Store index',
defaults: {
name: 'Zep: Insert',
},
codex: {
categories: ['AI'],
subcategories: {
AI: ['Vector Stores'],
},
resources: {
primaryDocumentation: [
{
url: 'https://docs.n8n.io/integrations/builtin/cluster-nodes/root-nodes/n8n-nodes-langchain.vectorstorezep/',
},
],
},
},
credentials: [
{
name: 'zepApi',
required: true,
},
],
inputs: [
NodeConnectionTypes.Main,
{
displayName: 'Document',
maxConnections: 1,
type: NodeConnectionTypes.AiDocument,
required: true,
},
{
displayName: 'Embedding',
maxConnections: 1,
type: NodeConnectionTypes.AiEmbedding,
required: true,
},
],
outputs: [NodeConnectionTypes.Main],
properties: [
{
displayName: 'This Zep integration is deprecated and will be removed in a future version.',
name: 'deprecationNotice',
type: 'notice',
default: '',
},
{
displayName: 'Collection Name',
name: 'collectionName',
type: 'string',
default: '',
required: true,
},
{
displayName: 'Specify the document to load in the document loader sub-node',
name: 'notice',
type: 'notice',
default: '',
},
{
displayName: 'Options',
name: 'options',
type: 'collection',
placeholder: 'Add Option',
default: {},
options: [
{
displayName: 'Embedding Dimensions',
name: 'embeddingDimensions',
type: 'number',
default: 1536,
description: 'Whether to allow using characters from the Unicode surrogate blocks',
},
{
displayName: 'Is Auto Embedded',
name: 'isAutoEmbedded',
type: 'boolean',
default: true,
description: 'Whether to automatically embed documents when they are added',
},
],
},
],
};
async execute(this: IExecuteFunctions): Promise<INodeExecutionData[][]> {
this.logger.debug('Executing data for Zep Insert Vector Store');
const items = this.getInputData(0);
const collectionName = this.getNodeParameter('collectionName', 0) as string;
const options =
(this.getNodeParameter('options', 0, {}) as {
isAutoEmbedded?: boolean;
embeddingDimensions?: number;
}) || {};
const credentials = await this.getCredentials<{
apiKey?: string;
apiUrl: string;
}>('zepApi');
const documentInput = (await this.getInputConnectionData(NodeConnectionTypes.AiDocument, 0)) as
| N8nJsonLoader
| Array<Document<Record<string, unknown>>>;
const embeddings = (await this.getInputConnectionData(
NodeConnectionTypes.AiEmbedding,
0,
)) as Embeddings;
const { processedDocuments, serializedDocuments } = await processDocuments(
documentInput,
items,
);
const zepConfig = {
apiUrl: credentials.apiUrl,
apiKey: credentials.apiKey,
collectionName,
embeddingDimensions: options.embeddingDimensions ?? 1536,
isAutoEmbedded: options.isAutoEmbedded ?? true,
};
await ZepVectorStore.fromDocuments(processedDocuments, embeddings, zepConfig);
return [serializedDocuments];
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

@@ -0,0 +1,124 @@
import type { IZepConfig } from '@langchain/community/vectorstores/zep';
import { ZepVectorStore } from '@langchain/community/vectorstores/zep';
import type { Embeddings } from '@langchain/core/embeddings';
import {
NodeConnectionTypes,
type INodeType,
type INodeTypeDescription,
type ISupplyDataFunctions,
type SupplyData,
} from 'n8n-workflow';
import { logWrapper, getMetadataFiltersValues, metadataFilterField } from '@n8n/ai-utilities';
// This node is deprecated. Use VectorStoreZep instead.
export class VectorStoreZepLoad implements INodeType {
description: INodeTypeDescription = {
displayName: 'Zep Vector Store: Load',
name: 'vectorStoreZepLoad',
hidden: true,
// eslint-disable-next-line n8n-nodes-base/node-class-description-icon-not-svg
icon: 'file:zep.png',
group: ['transform'],
version: 1,
description: 'Load data from Zep Vector Store index',
defaults: {
name: 'Zep: Load',
},
codex: {
categories: ['AI'],
subcategories: {
AI: ['Vector Stores'],
},
resources: {
primaryDocumentation: [
{
url: 'https://docs.n8n.io/integrations/builtin/cluster-nodes/root-nodes/n8n-nodes-langchain.vectorstorezep/',
},
],
},
},
credentials: [
{
name: 'zepApi',
required: true,
},
],
inputs: [
{
displayName: 'Embedding',
maxConnections: 1,
type: NodeConnectionTypes.AiEmbedding,
required: true,
},
],
outputs: [NodeConnectionTypes.AiVectorStore],
outputNames: ['Vector Store'],
properties: [
{
displayName: 'This Zep integration is deprecated and will be removed in a future version.',
name: 'deprecationNotice',
type: 'notice',
default: '',
},
{
displayName: 'Collection Name',
name: 'collectionName',
type: 'string',
default: '',
required: true,
},
{
displayName: 'Options',
name: 'options',
type: 'collection',
placeholder: 'Add Option',
default: {},
options: [
{
displayName: 'Embedding Dimensions',
name: 'embeddingDimensions',
type: 'number',
default: 1536,
description: 'Whether to allow using characters from the Unicode surrogate blocks',
},
metadataFilterField,
],
},
],
};
async supplyData(this: ISupplyDataFunctions, itemIndex: number): Promise<SupplyData> {
this.logger.debug('Supplying data for Zep Load Vector Store');
const collectionName = this.getNodeParameter('collectionName', itemIndex) as string;
const options =
(this.getNodeParameter('options', itemIndex) as {
embeddingDimensions?: number;
}) || {};
const credentials = await this.getCredentials<{
apiKey?: string;
apiUrl: string;
}>('zepApi');
const embeddings = (await this.getInputConnectionData(
NodeConnectionTypes.AiEmbedding,
0,
)) as Embeddings;
const zepConfig: IZepConfig = {
apiUrl: credentials.apiUrl,
apiKey: credentials.apiKey,
collectionName,
embeddingDimensions: options.embeddingDimensions ?? 1536,
metadata: getMetadataFiltersValues(this, itemIndex),
};
const vectorStore = new ZepVectorStore(embeddings, zepConfig);
return {
response: logWrapper(vectorStore, this),
};
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

@@ -0,0 +1,138 @@
import type { INodeProperties } from 'n8n-workflow';
export const pineconeIndexRLC: INodeProperties = {
displayName: 'Pinecone Index',
name: 'pineconeIndex',
type: 'resourceLocator',
default: { mode: 'list', value: '' },
required: true,
modes: [
{
displayName: 'From List',
name: 'list',
type: 'list',
typeOptions: {
searchListMethod: 'pineconeIndexSearch',
},
},
{
displayName: 'ID',
name: 'id',
type: 'string',
},
],
};
export const supabaseTableNameRLC: INodeProperties = {
displayName: 'Table Name',
name: 'tableName',
type: 'resourceLocator',
default: { mode: 'list', value: '' },
required: true,
modes: [
{
displayName: 'From List',
name: 'list',
type: 'list',
typeOptions: {
searchListMethod: 'supabaseTableNameSearch',
},
},
{
displayName: 'ID',
name: 'id',
type: 'string',
},
],
};
export const qdrantCollectionRLC: INodeProperties = {
displayName: 'Qdrant Collection',
name: 'qdrantCollection',
type: 'resourceLocator',
default: { mode: 'list', value: '' },
required: true,
modes: [
{
displayName: 'From List',
name: 'list',
type: 'list',
typeOptions: {
searchListMethod: 'qdrantCollectionsSearch',
},
},
{
displayName: 'ID',
name: 'id',
type: 'string',
},
],
};
export const milvusCollectionRLC: INodeProperties = {
displayName: 'Milvus Collection',
name: 'milvusCollection',
type: 'resourceLocator',
default: { mode: 'list', value: '' },
required: true,
modes: [
{
displayName: 'From List',
name: 'list',
type: 'list',
typeOptions: {
searchListMethod: 'milvusCollectionsSearch',
},
},
{
displayName: 'ID',
name: 'id',
type: 'string',
},
],
};
export const weaviateCollectionRLC: INodeProperties = {
displayName: 'Weaviate Collection',
name: 'weaviateCollection',
type: 'resourceLocator',
default: { mode: 'list', value: '' },
required: true,
modes: [
{
displayName: 'From List',
name: 'list',
type: 'list',
typeOptions: {
searchListMethod: 'weaviateCollectionsSearch',
},
},
{
displayName: 'ID',
name: 'id',
type: 'string',
},
],
};
export const chromaCollectionRLC: INodeProperties = {
displayName: 'Chroma Collection',
name: 'chromaCollection',
type: 'resourceLocator',
default: { mode: 'list', value: '' },
required: true,
modes: [
{
displayName: 'From List',
name: 'list',
type: 'list',
typeOptions: {
searchListMethod: 'chromaCollectionsSearch',
},
},
{
displayName: 'ID',
name: 'id',
type: 'string',
},
],
};
@@ -0,0 +1,108 @@
import { Pinecone } from '@pinecone-database/pinecone';
import { MilvusClient } from '@zilliz/milvus2-sdk-node';
import { ApplicationError, type IDataObject, type ILoadOptionsFunctions } from 'n8n-workflow';
import type { QdrantCredential } from '../../VectorStoreQdrant/Qdrant.utils';
import { createQdrantClient } from '../../VectorStoreQdrant/Qdrant.utils';
import type { WeaviateCredential } from '../../VectorStoreWeaviate/Weaviate.utils';
import { createWeaviateClient } from '../../VectorStoreWeaviate/Weaviate.utils';
export async function pineconeIndexSearch(this: ILoadOptionsFunctions) {
const credentials = await this.getCredentials('pineconeApi');
const client = new Pinecone({
apiKey: credentials.apiKey as string,
});
const indexes = await client.listIndexes();
const results = (indexes.indexes ?? []).map((index) => ({
name: index.name,
value: index.name,
}));
return { results };
}
export async function supabaseTableNameSearch(this: ILoadOptionsFunctions) {
const credentials = await this.getCredentials('supabaseApi');
const results = [];
if (typeof credentials.host !== 'string') {
throw new ApplicationError('Expected Supabase credentials host to be a string');
}
const { paths } = (await this.helpers.requestWithAuthentication.call(this, 'supabaseApi', {
headers: {
Prefer: 'return=representation',
},
method: 'GET',
uri: `${credentials.host}/rest/v1/`,
json: true,
})) as { paths: IDataObject };
for (const path of Object.keys(paths)) {
//omit introspection path
if (path === '/') continue;
results.push({
name: path.replace('/', ''),
value: path.replace('/', ''),
});
}
return { results };
}
export async function qdrantCollectionsSearch(this: ILoadOptionsFunctions) {
const credentials = await this.getCredentials('qdrantApi');
const client = createQdrantClient(credentials as QdrantCredential);
const response = await client.getCollections();
const results = response.collections.map((collection) => ({
name: collection.name,
value: collection.name,
}));
return { results };
}
export async function milvusCollectionsSearch(this: ILoadOptionsFunctions) {
const credentials = await this.getCredentials<{
baseUrl: string;
username: string;
password: string;
}>('milvusApi');
const client = new MilvusClient({
address: credentials.baseUrl,
token: `${credentials.username}:${credentials.password}`,
});
const response = await client.listCollections();
const results = response.data.map((collection) => ({
name: collection.name,
value: collection.name,
}));
return { results };
}
export async function weaviateCollectionsSearch(this: ILoadOptionsFunctions) {
const credentials = await this.getCredentials('weaviateApi');
const client = await createWeaviateClient(credentials as WeaviateCredential);
const collections = await client.collections.listAll();
const results = collections.map((collection: { name: string }) => ({
name: collection.name,
value: collection.name,
}));
return { results };
}