diff --git a/packages/components/nodes/vectorstores/Milvus/Milvus.test.ts b/packages/components/nodes/vectorstores/Milvus/Milvus.test.ts new file mode 100644 index 00000000000..450688578a7 --- /dev/null +++ b/packages/components/nodes/vectorstores/Milvus/Milvus.test.ts @@ -0,0 +1,58 @@ +import { MetricType } from '@zilliz/milvus2-sdk-node' + +// getBaseClasses(Milvus) is called in the node constructor; stub the utils module +// so the test does not pull the full src/utils dependency graph. +jest.mock('../../../src/utils', () => ({ + getBaseClasses: jest.fn(() => ['Milvus', 'VectorStore']), + getCredentialData: jest.fn(), + getCredentialParam: jest.fn(), + FLOWISE_CHATID: 'chatId' +})) + +const { nodeClass: Milvus_VectorStores, resolveMilvusMetricType } = require('./Milvus') + +describe('Milvus resolveMilvusMetricType', () => { + it('maps L2 (any case) to MetricType.L2', () => { + expect(resolveMilvusMetricType('L2')).toBe(MetricType.L2) + expect(resolveMilvusMetricType('l2')).toBe(MetricType.L2) + }) + + it('maps COSINE (any case) to MetricType.COSINE', () => { + expect(resolveMilvusMetricType('COSINE')).toBe(MetricType.COSINE) + expect(resolveMilvusMetricType('cosine')).toBe(MetricType.COSINE) + expect(resolveMilvusMetricType(' Cosine ')).toBe(MetricType.COSINE) + }) + + it('maps IP (any case) to MetricType.IP', () => { + expect(resolveMilvusMetricType('IP')).toBe(MetricType.IP) + expect(resolveMilvusMetricType('ip')).toBe(MetricType.IP) + }) + + it('falls back to L2 for empty, undefined, or unknown input (backward compatible)', () => { + expect(resolveMilvusMetricType(undefined)).toBe(MetricType.L2) + expect(resolveMilvusMetricType('')).toBe(MetricType.L2) + expect(resolveMilvusMetricType('HAMMING')).toBe(MetricType.L2) + }) +}) + +describe('Milvus node Metric Type input', () => { + let node: any + + beforeEach(() => { + node = new Milvus_VectorStores() + }) + + it('exposes a metricType options input with L2/COSINE/IP and an L2 default', () => { + const input = node.inputs.find((i: any) => i.name === 'metricType') + expect(input).toBeDefined() + expect(input.type).toBe('options') + expect(input.default).toBe('L2') + const optionNames = input.options.map((o: any) => o.name) + expect(optionNames).toEqual(['L2', 'COSINE', 'IP']) + }) + + it('keeps the metricType input optional so existing flows are unaffected', () => { + const input = node.inputs.find((i: any) => i.name === 'metricType') + expect(input.optional).toBe(true) + }) +}) diff --git a/packages/components/nodes/vectorstores/Milvus/Milvus.ts b/packages/components/nodes/vectorstores/Milvus/Milvus.ts index 527d57bf5d2..7ecbfbb5a1c 100644 --- a/packages/components/nodes/vectorstores/Milvus/Milvus.ts +++ b/packages/components/nodes/vectorstores/Milvus/Milvus.ts @@ -11,6 +11,25 @@ interface InsertRow { [x: string]: string | number[] } +/** + * Resolve a user-provided metric type string to a Milvus metric type. + * Accepts the values exposed by the node's "Metric Type" input (L2, COSINE, IP), + * case-insensitively, and falls back to L2 for empty or unrecognized input so + * existing flows keep their previous behavior. Returns the string-literal union + * used by @langchain/community's Milvus `indexCreateParams.metric_type`. + */ +const resolveMilvusMetricType = (value?: string): 'L2' | 'COSINE' | 'IP' => { + switch ((value ?? '').trim().toUpperCase()) { + case 'COSINE': + return 'COSINE' + case 'IP': + return 'IP' + case 'L2': + default: + return 'L2' + } +} + class Milvus_VectorStores implements INode { label: string name: string @@ -28,7 +47,7 @@ class Milvus_VectorStores implements INode { constructor() { this.label = 'Milvus' this.name = 'milvus' - this.version = 2.1 + this.version = 2.2 this.type = 'Milvus' this.icon = 'milvus.svg' this.category = 'Vector Stores' @@ -112,6 +131,21 @@ class Milvus_VectorStores implements INode { additionalParams: true, optional: true }, + { + label: 'Metric Type', + name: 'metricType', + description: + 'Distance metric used when creating the index on upsert and when querying. Must match the metric the Milvus collection was created with. Defaults to L2.', + type: 'options', + options: [ + { label: 'L2 (Euclidean)', name: 'L2' }, + { label: 'Cosine Similarity', name: 'COSINE' }, + { label: 'Inner Product (IP)', name: 'IP' } + ], + default: 'L2', + additionalParams: true, + optional: true + }, { label: 'Secure', name: 'secure', @@ -194,6 +228,9 @@ class Milvus_VectorStores implements INode { // partition const partitionName = nodeData.inputs?.milvusPartition ?? '_default' + // metric type + const metricType = resolveMilvusMetricType(nodeData.inputs?.metricType as string) + // init MilvusLibArgs const milVusArgs: MilvusLibArgs = { url: address, @@ -229,7 +266,14 @@ class Milvus_VectorStores implements INode { } try { - const vectorStore = await MilvusUpsert.fromDocuments(finalDocs, embeddings, milVusArgs) + // Mirror MilvusUpsert.fromDocuments, but inject the configured metric type + // into indexCreateParams before the index is created on first upsert. + const vectorStore = new MilvusUpsert(embeddings, milVusArgs) + vectorStore.indexCreateParams = { + ...vectorStore.indexCreateParams, + metric_type: metricType + } + await vectorStore.addDocuments(finalDocs) // Avoid Illegal Invocation vectorStore.similaritySearchVectorWithScore = async (query: number[], k: number, filter?: string) => { @@ -276,6 +320,9 @@ class Milvus_VectorStores implements INode { // partition const partitionName = nodeData.inputs?.milvusPartition ?? '_default' + // metric type + const metricType = resolveMilvusMetricType(nodeData.inputs?.metricType as string) + // init MilvusLibArgs const milVusArgs: MilvusLibArgs = { url: address, @@ -308,6 +355,13 @@ class Milvus_VectorStores implements INode { const vectorStore = await Milvus.fromExistingCollection(embeddings, milVusArgs) + // Ensure the search path uses the user-selected metric (and its matching score + // normalization) instead of the LangChain default of L2. + vectorStore.indexCreateParams = { + ...vectorStore.indexCreateParams, + metric_type: metricType + } + // Avoid Illegal Invocation vectorStore.similaritySearchVectorWithScore = async (query: number[], k: number, filter?: string) => { return await similaritySearchVectorWithScore(query, k, vectorStore, milvusFilter, filter) @@ -471,7 +525,7 @@ class MilvusUpsert extends Milvus { field_name: this.vectorField, index_name: `myindex_${Date.now().toString()}`, index_type: IndexType.AUTOINDEX, - metric_type: MetricType.L2 + metric_type: (this.indexCreateParams?.metric_type as MetricType) ?? MetricType.L2 }) if (resp.error_code !== ErrorCode.SUCCESS) { throw new Error(`Error creating index`) @@ -491,4 +545,4 @@ class MilvusUpsert extends Milvus { } } -module.exports = { nodeClass: Milvus_VectorStores } +module.exports = { nodeClass: Milvus_VectorStores, resolveMilvusMetricType }