From 5cb594d7efcc52c834855d13850735939e7d7086 Mon Sep 17 00:00:00 2001 From: Farzad Sunavala <40604067+farzad528@users.noreply.github.com> Date: Wed, 26 Nov 2025 12:45:19 +0000 Subject: [PATCH] feat(Azure AI Search Node): Add clear index option to Azure AI Search vector store (#22183) Co-authored-by: farzad528 Co-authored-by: RomanDavydchuk --- .../VectorStoreAzureAISearch.node.test.ts | 156 +++++++++++++++++- .../VectorStoreAzureAISearch.node.ts | 119 ++++++++++--- 2 files changed, 249 insertions(+), 26 deletions(-) diff --git a/packages/@n8n/nodes-langchain/nodes/vector_store/VectorStoreAzureAISearch/VectorStoreAzureAISearch.node.test.ts b/packages/@n8n/nodes-langchain/nodes/vector_store/VectorStoreAzureAISearch/VectorStoreAzureAISearch.node.test.ts index 440a68083cb..9c9112c9ca2 100644 --- a/packages/@n8n/nodes-langchain/nodes/vector_store/VectorStoreAzureAISearch/VectorStoreAzureAISearch.node.test.ts +++ b/packages/@n8n/nodes-langchain/nodes/vector_store/VectorStoreAzureAISearch/VectorStoreAzureAISearch.node.test.ts @@ -1,14 +1,25 @@ +import { AzureKeyCredential, SearchIndexClient } from '@azure/search-documents'; import { AzureAISearchVectorStore } from '@langchain/community/vectorstores/azure_aisearch'; -import { AzureKeyCredential } from '@azure/search-documents'; import { mock } from 'jest-mock-extended'; -import type { ISupplyDataFunctions, ILoadOptionsFunctions, INode } from 'n8n-workflow'; +import type { + ISupplyDataFunctions, + ILoadOptionsFunctions, + INode, + IExecuteFunctions, +} from 'n8n-workflow'; -import { VectorStoreAzureAISearch, getIndexName } from './VectorStoreAzureAISearch.node'; +import { + VectorStoreAzureAISearch, + getIndexName, + clearAzureSearchIndex, +} 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; + describe('VectorStoreAzureAISearch', () => { const vectorStore = new VectorStoreAzureAISearch(); const helpers = mock(); @@ -123,4 +134,143 @@ describe('VectorStoreAzureAISearch', () => { extractValue: true, }); }); + + describe('clearIndex functionality', () => { + const mockDeleteIndex = jest.fn().mockResolvedValue(undefined); + const mockContext = mock(); + 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', + }); + }); + }); }); diff --git a/packages/@n8n/nodes-langchain/nodes/vector_store/VectorStoreAzureAISearch/VectorStoreAzureAISearch.node.ts b/packages/@n8n/nodes-langchain/nodes/vector_store/VectorStoreAzureAISearch/VectorStoreAzureAISearch.node.ts index 7237019186d..a90774dc9ac 100644 --- a/packages/@n8n/nodes-langchain/nodes/vector_store/VectorStoreAzureAISearch/VectorStoreAzureAISearch.node.ts +++ b/packages/@n8n/nodes-langchain/nodes/vector_store/VectorStoreAzureAISearch/VectorStoreAzureAISearch.node.ts @@ -1,17 +1,18 @@ -import type { EmbeddingsInterface } from '@langchain/core/embeddings'; +import { AzureKeyCredential, SearchIndexClient } from '@azure/search-documents'; import { AzureAISearchVectorStore, AzureAISearchQueryType, } from '@langchain/community/vectorstores/azure_aisearch'; -import { AzureKeyCredential } from '@azure/search-documents'; +import type { EmbeddingsInterface } from '@langchain/core/embeddings'; import { + NodeOperationError, type IDataObject, type ILoadOptionsFunctions, - NodeOperationError, type INodeProperties, type IExecuteFunctions, type ISupplyDataFunctions, } from 'n8n-workflow'; + import { createVectorStoreNode } from '../shared/createVectorStoreNode/createVectorStoreNode'; // User agent for usage tracking @@ -94,7 +95,25 @@ const retrieveFields: INodeProperties[] = [ }, ]; -const insertFields: INodeProperties[] = []; +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.', + }, + ], + }, +]; type IFunctionsContext = IExecuteFunctions | ISupplyDataFunctions | ILoadOptionsFunctions; @@ -135,38 +154,88 @@ function getOptionValue( return options[name] !== undefined ? (options[name] as T) : defaultValue; } +interface ValidatedCredentials { + endpoint: string; + apiKey: string; +} + +async function getValidatedCredentials( + context: IFunctionsContext, + itemIndex: number, +): Promise { + 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 { + 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 { - const credentials = await context.getCredentials(AZURE_AI_SEARCH_CREDENTIALS); + const credentials = await getValidatedCredentials(context, itemIndex); try { const indexName = getIndexName(context, itemIndex); - - if (!credentials.endpoint || typeof credentials.endpoint !== 'string') { - throw new NodeOperationError( - context.getNode(), - 'Azure AI Search endpoint is missing or invalid', - { itemIndex }, - ); - } - const endpoint = credentials.endpoint; - - // Validate API Key - if (!credentials.apiKey || typeof credentials.apiKey !== 'string') { - throw new NodeOperationError(context.getNode(), 'API Key is required for authentication', { - 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, + endpoint: credentials.endpoint, indexName, credentials: azureCredentials, search: {}, @@ -340,6 +409,10 @@ export class VectorStoreAzureAISearch extends createVectorStoreNode({ }, async populateVectorStore(context, embeddings, documents, itemIndex) { try { + // Clear the index if requested (delete and recreate) + await clearAzureSearchIndex(context, itemIndex); + + // 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)