Skip to content

Commit 58ad1a3

Browse files
authored
.Net: Improve Milvus filtering (#14498)
### Motivation and Context <!-- Thank you for your contribution to the semantic-kernel repo! Please help reviewers and future users, providing the following information: 1. Why is this change required? 2. What problem does it solve? 3. What scenario does it contribute to? 4. If it fixes an open issue, please link to the issue here. --> ### Description Improve Milvus filtering to allow more id types to be supported ### Contribution Checklist <!-- Before submitting this PR, please make sure: --> - [ ] The code builds clean without any errors or warnings - [ ] The PR follows the [SK Contribution Guidelines](https://github.com/microsoft/semantic-kernel/blob/main/CONTRIBUTING.md) and the [pre-submission formatting script](https://github.com/microsoft/semantic-kernel/blob/main/CONTRIBUTING.md#development-scripts) raises no violations - [ ] All unit tests pass, and I have added new tests where possible - [ ] I didn't break anyone 😄
1 parent 082700e commit 58ad1a3

9 files changed

Lines changed: 534 additions & 67 deletions

File tree

‎dotnet/MEVD.slnx‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,5 +26,6 @@
2626
<Folder Name="/test/" />
2727
<Folder Name="/test/VectorData/">
2828
<Project Path="test/VectorData/Chroma.UnitTests/Chroma.UnitTests.csproj" />
29+
<Project Path="test/VectorData/Milvus.UnitTests/Milvus.UnitTests.csproj" />
2930
</Folder>
3031
</Solution>

‎dotnet/src/VectorData/Milvus/Milvus.csproj‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@
2828

2929
<ItemGroup>
3030
<ProjectReference Include="..\..\SemanticKernel.Core\SemanticKernel.Core.csproj" />
31+
<InternalsVisibleTo Include="SemanticKernel.Connectors.Milvus.UnitTests" />
3132
</ItemGroup>
3233

3334
</Project>

‎dotnet/src/VectorData/Milvus/MilvusMemoryStore.cs‎

Lines changed: 89 additions & 64 deletions
Original file line numberDiff line numberDiff line change
@@ -247,6 +247,7 @@ public Task DeleteCollectionAsync(string collectionName, CancellationToken cance
247247
/// <inheritdoc />
248248
public async Task<string> UpsertAsync(string collectionName, MemoryRecord record, CancellationToken cancellationToken = default)
249249
{
250+
await this.EnsureSupportedServerAsync(cancellationToken).ConfigureAwait(false);
250251
MilvusCollection collection = this.Client.GetCollection(collectionName);
251252

252253
var metadata = record.Metadata;
@@ -276,8 +277,7 @@ public async IAsyncEnumerable<string> UpsertBatchAsync(
276277
IEnumerable<MemoryRecord> records,
277278
[EnumeratorCancellation] CancellationToken cancellationToken = default)
278279
{
279-
StringBuilder idString = new();
280-
280+
await this.EnsureSupportedServerAsync(cancellationToken).ConfigureAwait(false);
281281
List<bool> isReferenceData = [];
282282
List<string> externalSourceNameData = [];
283283
List<string> idData = [];
@@ -292,13 +292,6 @@ public async IAsyncEnumerable<string> UpsertBatchAsync(
292292
{
293293
var metadata = record.Metadata;
294294

295-
if (idString.Length > 0)
296-
{
297-
idString.Append(',');
298-
}
299-
300-
idString.Append('"').Append(metadata.Id).Append('"');
301-
302295
isReferenceData.Add(metadata.IsReference);
303296
externalSourceNameData.Add(metadata.ExternalSourceName);
304297
idData.Add(record.Metadata.Id);
@@ -356,22 +349,11 @@ public async IAsyncEnumerable<MemoryRecord> GetBatchAsync(
356349
bool withEmbeddings = false,
357350
[EnumeratorCancellation] CancellationToken cancellationToken = default)
358351
{
359-
StringBuilder idString = new();
360-
361-
foreach (string key in keys)
362-
{
363-
if (idString.Length > 0)
364-
{
365-
idString.Append(',');
366-
}
367-
368-
idString.Append('"').Append(key).Append('"');
369-
}
370-
352+
await this.EnsureSupportedServerAsync(cancellationToken).ConfigureAwait(false);
371353
IReadOnlyList<FieldData> fields = await this.Client
372354
.GetCollection(collectionName)
373355
.QueryAsync(
374-
$"{IdFieldName} in [{idString}]",
356+
BuildIdFilter(keys),
375357
withEmbeddings ? this._queryParametersWithEmbedding : this._queryParametersWithoutEmbedding,
376358
cancellationToken: cancellationToken)
377359
.ConfigureAwait(false);
@@ -386,36 +368,16 @@ public async IAsyncEnumerable<MemoryRecord> GetBatchAsync(
386368

387369
/// <inheritdoc />
388370
public Task RemoveAsync(string collectionName, string key, CancellationToken cancellationToken = default)
389-
=> this.Client.GetCollection(collectionName)
390-
.DeleteAsync($@"{IdFieldName} in [""{key}""]", cancellationToken: cancellationToken);
371+
=> this.RemoveBatchAsync(collectionName, [key], cancellationToken);
391372

392373
/// <inheritdoc />
393-
public Task RemoveBatchAsync(string collectionName, IEnumerable<string> keys, CancellationToken cancellationToken = default)
374+
public async Task RemoveBatchAsync(string collectionName, IEnumerable<string> keys, CancellationToken cancellationToken = default)
394375
{
395-
StringBuilder idString = new();
396-
397-
idString.Append(IdFieldName).Append(" in [");
398-
399-
bool first = true;
400-
foreach (string id in keys)
401-
{
402-
if (first)
403-
{
404-
first = false;
405-
}
406-
else
407-
{
408-
idString.Append(',');
409-
}
410-
411-
idString.Append('"').Append(id).Append('"');
412-
}
413-
414-
idString.Append(']');
415-
416-
return this.Client
376+
await this.EnsureSupportedServerAsync(cancellationToken).ConfigureAwait(false);
377+
await this.Client
417378
.GetCollection(collectionName)
418-
.DeleteAsync(idString.ToString(), cancellationToken: cancellationToken);
379+
.DeleteAsync(BuildIdFilter(keys), cancellationToken: cancellationToken)
380+
.ConfigureAwait(false);
419381
}
420382

421383
/// <inheritdoc />
@@ -443,6 +405,7 @@ public Task RemoveBatchAsync(string collectionName, IEnumerable<string> keys, Ca
443405
bool withEmbeddings = false,
444406
[EnumeratorCancellation] CancellationToken cancellationToken = default)
445407
{
408+
await this.EnsureSupportedServerAsync(cancellationToken).ConfigureAwait(false);
446409
MilvusCollection collection = this.Client.GetCollection(collectionName);
447410

448411
SearchResults results = await collection
@@ -459,23 +422,8 @@ public Task RemoveBatchAsync(string collectionName, IEnumerable<string> keys, Ca
459422
Dictionary<string, ReadOnlyMemory<float>>? embeddingMap = null;
460423
if (withEmbeddings)
461424
{
462-
StringBuilder filter = new();
463-
filter.Append(IdFieldName).Append(" in [");
464-
465-
for (int rowNum = 0; rowNum < ids.Count; rowNum++)
466-
{
467-
if (rowNum > 0)
468-
{
469-
filter.Append(',');
470-
}
471-
472-
filter.Append('"').Append(ids[rowNum]).Append('"');
473-
}
474-
475-
filter.Append(']');
476-
477425
IReadOnlyList<FieldData> fieldData = await collection.QueryAsync(
478-
filter.ToString(),
426+
BuildIdFilter(ids),
479427
new() { OutputFields = { EmbeddingFieldName } },
480428
cancellationToken: cancellationToken)
481429
.ConfigureAwait(false);
@@ -502,6 +450,83 @@ public Task RemoveBatchAsync(string collectionName, IEnumerable<string> keys, Ca
502450
}
503451
}
504452

453+
private async Task EnsureSupportedServerAsync(CancellationToken cancellationToken)
454+
{
455+
string version = await this.Client.GetVersionAsync(cancellationToken).ConfigureAwait(false);
456+
ValidateServerVersion(version);
457+
}
458+
459+
internal static void ValidateServerVersion(string version)
460+
{
461+
string numericVersion = version.StartsWith("v", StringComparison.Ordinal) ? version.Substring(1) : version;
462+
// The official 2.3.0 release image reports "v2.3.0-dev".
463+
if (numericVersion.EndsWith("-dev", StringComparison.Ordinal))
464+
{
465+
numericVersion = numericVersion.Substring(0, numericVersion.Length - 4);
466+
}
467+
468+
// Older servers accept escaped strings but retain the escapes as part of the ID.
469+
if (!Version.TryParse(numericVersion, out Version? parsedVersion) ||
470+
parsedVersion < new Version(2, 3, 0))
471+
{
472+
throw new NotSupportedException($"Milvus 2.3.0 or newer is required to handle record IDs safely. The server reported version '{version}'.");
473+
}
474+
}
475+
476+
internal static string BuildIdFilter(IEnumerable<string> keys)
477+
{
478+
StringBuilder filter = new();
479+
filter.Append(IdFieldName).Append(" in [");
480+
481+
bool first = true;
482+
foreach (string key in keys)
483+
{
484+
if (!first)
485+
{
486+
filter.Append(',');
487+
}
488+
489+
first = false;
490+
filter.Append('"');
491+
foreach (char character in key)
492+
{
493+
switch (character)
494+
{
495+
case '\\':
496+
filter.Append(@"\\");
497+
break;
498+
case '"':
499+
filter.Append("\\\"");
500+
break;
501+
case '\r':
502+
filter.Append(@"\r");
503+
break;
504+
case '\n':
505+
filter.Append(@"\n");
506+
break;
507+
case '\t':
508+
filter.Append(@"\t");
509+
break;
510+
default:
511+
if (char.IsControl(character))
512+
{
513+
filter.Append(@"\u").Append(((int)character).ToString("x4", CultureInfo.InvariantCulture));
514+
}
515+
else
516+
{
517+
filter.Append(character);
518+
}
519+
520+
break;
521+
}
522+
}
523+
524+
filter.Append('"');
525+
}
526+
527+
return filter.Append(']').ToString();
528+
}
529+
505530
private MemoryRecord ReadMemoryRecord(IReadOnlyList<FieldData> data, int rowNum, ReadOnlyMemory<float>? externalEmbedding = null)
506531
{
507532
bool isReference = false;

‎dotnet/src/VectorData/Milvus/README.md‎

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,14 +2,16 @@
22

33
This is an implementation of the Semantic Kernel Memory Store abstraction for the [Milvus vector database](https://milvus.io).
44

5-
**Note:** Currently, only Milvus v2.2 is supported. v2.3 is coming soon, older versions are untested.
5+
**Server requirement:** Record operations require Milvus v2.3.0 or newer. The connector checks the server version before saving, reading, removing, or searching records and throws `NotSupportedException` for older or unrecognized versions. Collection management and direct calls through `Client` are not covered by this check.
6+
7+
Record IDs are treated as literal values, including quotes, backslashes, brackets, and line breaks. IDs are not renamed or normalized. Milvus v2.2.14 does not decode escaped IDs correctly and could otherwise target a different record. Upgrade the server before using this connector with existing collections.
68

79
## Quickstart using a standalone Milvus installation
810

911
1. Download the Milvus docker-compose.yml:
1012

1113
```bash
12-
wget https://github.com/milvus-io/milvus/releases/download/v2.2.14/milvus-standalone-docker-compose.yml -O docker-compose.yml
14+
wget https://github.com/milvus-io/milvus/releases/download/v2.3.0/milvus-standalone-docker-compose.yml -O docker-compose.yml
1315
```
1416

1517
2. Start Milvus:
@@ -30,4 +32,4 @@ SemanticTextMemory textMemory = new(memoryStore, embeddingGenerator);
3032
var memoryPlugin = kernel.ImportPluginFromObject(new TextMemoryPlugin(textMemory));
3133
```
3234

33-
More information on setting up Milvus can be found [here](https://milvus.io/docs/v2.2.x/install_standalone-docker.md). The `MilvusMemoryStore` constructor provides additional configuration options, such as the vector size, the similarity metric type, etc.
35+
More information on setting up Milvus can be found [here](https://milvus.io/docs/v2.3.x/install_standalone-docker.md). The `MilvusMemoryStore` constructor provides additional configuration options, such as the vector size, the similarity metric type, etc.
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
<Project Sdk="Microsoft.NET.Sdk">
2+
<PropertyGroup>
3+
<AssemblyName>SemanticKernel.Connectors.Milvus.UnitTests</AssemblyName>
4+
<RootNamespace>SemanticKernel.Connectors.Milvus.UnitTests</RootNamespace>
5+
<TargetFramework>net10.0</TargetFramework>
6+
<IsTestProject>true</IsTestProject>
7+
<IsPackable>false</IsPackable>
8+
<NoWarn>$(NoWarn);SKEXP0001;SKEXP0020</NoWarn>
9+
</PropertyGroup>
10+
11+
<ItemGroup>
12+
<PackageReference Include="Microsoft.NET.Test.Sdk" />
13+
<PackageReference Include="xunit" />
14+
<PackageReference Include="xunit.runner.visualstudio">
15+
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
16+
<PrivateAssets>all</PrivateAssets>
17+
</PackageReference>
18+
<PackageReference Include="Testcontainers.Milvus" />
19+
</ItemGroup>
20+
21+
<ItemGroup>
22+
<ProjectReference Include="..\..\..\src\VectorData\Milvus\Milvus.csproj" />
23+
</ItemGroup>
24+
</Project>
Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
// Copyright (c) Microsoft. All rights reserved.
2+
3+
using System;
4+
using System.Collections.Generic;
5+
using Microsoft.SemanticKernel.Connectors.Milvus;
6+
using Xunit;
7+
8+
namespace SemanticKernel.Connectors.Milvus.UnitTests;
9+
10+
public sealed class MilvusFilterTests
11+
{
12+
[Theory]
13+
[InlineData("v2.3.0")]
14+
[InlineData("v2.3.0-dev")]
15+
[InlineData("2.3.0")]
16+
[InlineData("v2.6.1")]
17+
[InlineData("3.0.0")]
18+
public void AcceptsSupportedServerVersions(string version)
19+
{
20+
MilvusMemoryStore.ValidateServerVersion(version);
21+
}
22+
23+
[Theory]
24+
[InlineData("v2.2.14")]
25+
[InlineData("v2.2.14-dev")]
26+
[InlineData("v2.2.16")]
27+
[InlineData("v2.3.0-beta")]
28+
[InlineData("unknown")]
29+
[InlineData("")]
30+
public void RejectsOlderOrUnrecognizedServerVersions(string version)
31+
{
32+
Assert.Throws<NotSupportedException>(() => MilvusMemoryStore.ValidateServerVersion(version));
33+
}
34+
35+
[Theory]
36+
[InlineData("", "")]
37+
[InlineData("ordinary-id", "ordinary-id")]
38+
[InlineData("[],'", "[],'")]
39+
[InlineData("\"", "\\\"")]
40+
[InlineData("\\", "\\\\")]
41+
[InlineData("\\\"", "\\\\\\\"")]
42+
[InlineData("trailing\\", "trailing\\\\")]
43+
[InlineData("\r\n\t", "\\r\\n\\t")]
44+
[InlineData("\\r\\n\\t", "\\\\r\\\\n\\\\t")]
45+
[InlineData("\0\b\f\v\u001f\u007f\u0085", "\\u0000\\u0008\\u000c\\u000b\\u001f\\u007f\\u0085")]
46+
[InlineData("\u00e9\u4e2d\ud83d\ude00", "\u00e9\u4e2d\ud83d\ude00")]
47+
[InlineData("a\"] || true || id in [\"", "a\\\"] || true || id in [\\\"")]
48+
[InlineData("z\"] && text like \"%secret%\" || id in [\"", "z\\\"] && text like \\\"%secret%\\\" || id in [\\\"")]
49+
[InlineData("x\" || id != \"", "x\\\" || id != \\\"")]
50+
[InlineData("a\",\"victim", "a\\\",\\\"victim")]
51+
public void EncodesEachIdAsOneString(string key, string expected)
52+
{
53+
Assert.Equal("id in [\"" + expected + "\"]", MilvusMemoryStore.BuildIdFilter([key]));
54+
}
55+
56+
[Fact]
57+
public void PreservesEmptyLists()
58+
{
59+
Assert.Equal("id in []", MilvusMemoryStore.BuildIdFilter([]));
60+
}
61+
62+
[Fact]
63+
public void PreservesOrderDuplicatesAndEmptyIds()
64+
{
65+
Assert.Equal("id in [\"a\",\"\",\"a\",\"b\"]", MilvusMemoryStore.BuildIdFilter(["a", "", "a", "b"]));
66+
}
67+
68+
[Fact]
69+
public void EnumeratesKeysOnlyOnce()
70+
{
71+
bool enumerated = false;
72+
73+
IEnumerable<string> Keys()
74+
{
75+
Assert.False(enumerated);
76+
enumerated = true;
77+
yield return "first";
78+
yield return "second";
79+
}
80+
81+
Assert.Equal("id in [\"first\",\"second\"]", MilvusMemoryStore.BuildIdFilter(Keys()));
82+
Assert.True(enumerated);
83+
}
84+
}

0 commit comments

Comments
 (0)