feat(Azure AI Search Node): Add clear index option to Azure AI Search vector store (#22183)

Co-authored-by: farzad528 <farzad528@users.noreply.github.com>
Co-authored-by: RomanDavydchuk <roman.davydchuk@n8n.io>
This commit is contained in:
Farzad Sunavala 2025-11-26 12:45:19 +00:00 committed by GitHub
parent 0668a72611
commit 5cb594d7ef
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 249 additions and 26 deletions

View File

@ -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<typeof SearchIndexClient>;
describe('VectorStoreAzureAISearch', () => {
const vectorStore = new VectorStoreAzureAISearch();
const helpers = mock<ISupplyDataFunctions['helpers']>();
@ -123,4 +134,143 @@ describe('VectorStoreAzureAISearch', () => {
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',
});
});
});
});

View File

@ -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<T>(
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 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)