mirror of
https://github.com/n8n-io/n8n.git
synced 2026-05-30 16:26:59 +02:00
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:
parent
0668a72611
commit
5cb594d7ef
|
|
@ -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',
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user