From 29c36e271194766ddd5ddd5d1088764ef4700314 Mon Sep 17 00:00:00 2001 From: Laurianti Date: Thu, 15 Oct 2026 16:25:59 +0200 Subject: [PATCH 1/2] Add BotSharp.Plugin.Chroma vector database plugin Chroma is an open-source search and retrieval database. This plugin follows the same shape as BotSharp.Plugin.Qdrant: an IVectorDb on top of ChromaDotNet.Client, with the URL, API key, tenant and database configured via ChromaSetting, for a local Chroma server and for Chroma Cloud. --- BotSharp.sln | 15 + Directory.Packages.props | 1 + .../BotSharp.Plugin.Chroma.csproj | 20 + .../BotSharp.Plugin.Chroma/ChromaDb.cs | 580 ++++++++++++++++++ .../BotSharp.Plugin.Chroma/ChromaPlugin.cs | 24 + .../BotSharp.Plugin.Chroma/ChromaSetting.cs | 24 + src/Plugins/BotSharp.Plugin.Chroma/Using.cs | 10 + src/WebStarter/WebStarter.csproj | 1 + src/WebStarter/appsettings.json | 8 + 9 files changed, 683 insertions(+) create mode 100644 src/Plugins/BotSharp.Plugin.Chroma/BotSharp.Plugin.Chroma.csproj create mode 100644 src/Plugins/BotSharp.Plugin.Chroma/ChromaDb.cs create mode 100644 src/Plugins/BotSharp.Plugin.Chroma/ChromaPlugin.cs create mode 100644 src/Plugins/BotSharp.Plugin.Chroma/ChromaSetting.cs create mode 100644 src/Plugins/BotSharp.Plugin.Chroma/Using.cs diff --git a/BotSharp.sln b/BotSharp.sln index ab198bed3..af8507972 100644 --- a/BotSharp.sln +++ b/BotSharp.sln @@ -35,6 +35,8 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "VectorStores", "VectorStore EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "BotSharp.Plugin.Qdrant", "src\Plugins\BotSharp.Plugin.Qdrant\BotSharp.Plugin.Qdrant.csproj", "{FE2E6CC1-EB80-4518-B3A3-CB373EDA6A83}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "BotSharp.Plugin.Chroma", "src\Plugins\BotSharp.Plugin.Chroma\BotSharp.Plugin.Chroma.csproj", "{0C097984-6C10-4F7E-A57D-95C5C9974697}" +EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Visions", "Visions", "{B797EC3E-B5B9-4047-A6C8-6D330B7C6763}" EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "BotSharp.Plugin.PaddleSharp", "src\Plugins\BotSharp.Plugin.PaddleSharp\BotSharp.Plugin.PaddleSharp.csproj", "{0308FBFD-57EB-4709-9AE4-A80D516AD84D}" @@ -291,6 +293,18 @@ Global {FE2E6CC1-EB80-4518-B3A3-CB373EDA6A83}.Release|x64.Build.0 = Release|Any CPU {FE2E6CC1-EB80-4518-B3A3-CB373EDA6A83}.Release|x86.ActiveCfg = Release|Any CPU {FE2E6CC1-EB80-4518-B3A3-CB373EDA6A83}.Release|x86.Build.0 = Release|Any CPU + {0C097984-6C10-4F7E-A57D-95C5C9974697}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {0C097984-6C10-4F7E-A57D-95C5C9974697}.Debug|Any CPU.Build.0 = Debug|Any CPU + {0C097984-6C10-4F7E-A57D-95C5C9974697}.Debug|x64.ActiveCfg = Debug|Any CPU + {0C097984-6C10-4F7E-A57D-95C5C9974697}.Debug|x64.Build.0 = Debug|Any CPU + {0C097984-6C10-4F7E-A57D-95C5C9974697}.Debug|x86.ActiveCfg = Debug|Any CPU + {0C097984-6C10-4F7E-A57D-95C5C9974697}.Debug|x86.Build.0 = Debug|Any CPU + {0C097984-6C10-4F7E-A57D-95C5C9974697}.Release|Any CPU.ActiveCfg = Release|Any CPU + {0C097984-6C10-4F7E-A57D-95C5C9974697}.Release|Any CPU.Build.0 = Release|Any CPU + {0C097984-6C10-4F7E-A57D-95C5C9974697}.Release|x64.ActiveCfg = Release|Any CPU + {0C097984-6C10-4F7E-A57D-95C5C9974697}.Release|x64.Build.0 = Release|Any CPU + {0C097984-6C10-4F7E-A57D-95C5C9974697}.Release|x86.ActiveCfg = Release|Any CPU + {0C097984-6C10-4F7E-A57D-95C5C9974697}.Release|x86.Build.0 = Release|Any CPU {0308FBFD-57EB-4709-9AE4-A80D516AD84D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {0308FBFD-57EB-4709-9AE4-A80D516AD84D}.Debug|Any CPU.Build.0 = Debug|Any CPU {0308FBFD-57EB-4709-9AE4-A80D516AD84D}.Debug|x64.ActiveCfg = Debug|Any CPU @@ -1028,6 +1042,7 @@ Global {6D8D18A9-86D7-455E-81EC-9682C30AB7E7} = {D5293208-2BEF-42FC-A64C-5954F61720BA} {53E7CD86-0D19-40D9-A0FA-AB4613837E89} = {2635EC9B-2E5F-4313-AC21-0B847F31F36C} {FE2E6CC1-EB80-4518-B3A3-CB373EDA6A83} = {53E7CD86-0D19-40D9-A0FA-AB4613837E89} + {0C097984-6C10-4F7E-A57D-95C5C9974697} = {53E7CD86-0D19-40D9-A0FA-AB4613837E89} {B797EC3E-B5B9-4047-A6C8-6D330B7C6763} = {2635EC9B-2E5F-4313-AC21-0B847F31F36C} {0308FBFD-57EB-4709-9AE4-A80D516AD84D} = {B797EC3E-B5B9-4047-A6C8-6D330B7C6763} {8300F66D-9EB8-438A-BF0F-70DFBE07D9DE} = {64264688-0F5C-4AB0-8F2B-B59B717CCE00} diff --git a/Directory.Packages.props b/Directory.Packages.props index 0ec51a45d..0f0253710 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -62,6 +62,7 @@ + diff --git a/src/Plugins/BotSharp.Plugin.Chroma/BotSharp.Plugin.Chroma.csproj b/src/Plugins/BotSharp.Plugin.Chroma/BotSharp.Plugin.Chroma.csproj new file mode 100644 index 000000000..830720e23 --- /dev/null +++ b/src/Plugins/BotSharp.Plugin.Chroma/BotSharp.Plugin.Chroma.csproj @@ -0,0 +1,20 @@ + + + + $(TargetFramework) + enable + $(LangVersion) + $(BotSharpVersion) + $(GeneratePackageOnBuild) + $(GenerateDocumentationFile) + $(SolutionDir)packages + + + + + + + + + + diff --git a/src/Plugins/BotSharp.Plugin.Chroma/ChromaDb.cs b/src/Plugins/BotSharp.Plugin.Chroma/ChromaDb.cs new file mode 100644 index 000000000..784606fe3 --- /dev/null +++ b/src/Plugins/BotSharp.Plugin.Chroma/ChromaDb.cs @@ -0,0 +1,580 @@ +using BotSharp.Abstraction.Utilities; +using BotSharp.Abstraction.VectorStorage.Options; +using ChromaDB.Client; +using ChromaDB.Client.Models; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using System.Net.Http; + +namespace BotSharp.Plugin.Chroma; + +public class ChromaDb : IVectorDb +{ + private ChromaClient? _client; + private readonly ChromaSetting _setting; + private readonly IServiceProvider _services; + private readonly ILogger _logger; + + public ChromaDb( + ChromaSetting setting, + ILogger logger, + IServiceProvider services) + { + _setting = setting; + _logger = logger; + _services = services; + } + + public string Provider => "Chroma"; + + private async Task GetClient() + { + if (_client == null) + { + var options = new ChromaConfigurationOptions + ( + uri: _setting.Url, + tenant: _setting.Tenant.IfNullOrEmptyAs(null), + database: _setting.Database.IfNullOrEmptyAs(null), + chromaToken: _setting.ApiKey.IfNullOrEmptyAs(null) + ); + var http = _services.GetRequiredService(); + var client = new ChromaClient(options, http.CreateClient()); + + // On Chroma Cloud the API key gives the tenant and the database, as the CloudClient of the Python client + _client = options.IsChromaCloud ? await client.WithTenantAndDatabaseFromIdentityAsync() : client; + } + return _client; + } + + #region Collection + public async Task DoesCollectionExist(string collectionName) + { + var client = await GetClient(); + return await client.CollectionExistsAsync(collectionName); + } + + public async Task CreateCollection(string collectionName, VectorCollectionCreateOptions options) + { + var exist = await DoesCollectionExist(collectionName); + + if (exist) return false; + + try + { + // Create a new collection: Chroma takes the dimension from the first vector it stores + var client = await GetClient(); + await client.CreateCollectionAsync(new ChromaCollectionDefinition(collectionName) + { + Configuration = new() { Space = ChromaSpace.Cosine } + }); + return true; + } + catch (Exception ex) + { + _logger.LogWarning(ex, $"Error when create collection (Name: {collectionName}, Dimension: {options.Dimension})."); + return false; + } + } + + public async Task DeleteCollection(string collectionName) + { + var exist = await DoesCollectionExist(collectionName); + + if (!exist) return false; + + var client = await GetClient(); + await client.DeleteCollectionAsync(collectionName); + return true; + } + + public async Task> GetCollections() + { + // List all the collections + var client = await GetClient(); + var collections = await client.ListCollectionsAsync(); + return collections.Select(x => x.Name).ToList(); + } + + public async Task GetCollectionDetails(string collectionName) + { + var exist = await DoesCollectionExist(collectionName); + + if (!exist) + { + return null; + } + + var client = await GetClient(); + var collection = client.GetCollectionClient(collectionName); + var count = (ulong)await collection.CountAsync(); + var indexedCount = (ulong)await collection.CountAsync(ChromaReadLevel.IndexOnly); + + return new VectorCollectionDetails + { + VectorsCount = count, + IndexedVectorsCount = indexedCount, + PointsCount = count + }; + } + #endregion + + #region Collection data + public async Task> GetPagedCollectionData(string collectionName, VectorFilter filter) + { + var exist = await DoesCollectionExist(collectionName); + if (!exist) + { + return new StringIdPagedItems(); + } + + // Chroma pages by offset: the next id is the offset of the next page + var where = BuildQueryFilter(filter.FilterGroups); + var offset = int.TryParse(filter.StartId, out var start) ? start : 0; + var include = ChromaGetInclude.Metadatas | ChromaGetInclude.Documents; + if (filter.WithVector) + { + include |= ChromaGetInclude.Embeddings; + } + + var client = await GetClient(); + var collection = client.GetCollectionClient(collectionName); + var totalCount = where == null + ? (ulong)await collection.CountAsync() + : (ulong)(await collection.GetAsync(where: where, include: ChromaGetInclude.None)).Count; + var entries = await collection.GetAsync(where: where, limit: filter.Size, offset: offset, include: include); + + var points = entries.Select(x => new VectorCollectionData + { + Id = x.Id, + Payload = MapPayload(x.Metadata, x.Document, filter.Fields), + Vector = filter.WithVector ? x.Embedding?.ToArray() : null + }).ToList(); + + var nextOffset = offset + points.Count; + return new StringIdPagedItems + { + Count = totalCount, + NextId = points.Count == filter.Size && (ulong)nextOffset < totalCount ? nextOffset.ToString() : null, + Items = points + }; + } + + public async Task> GetCollectionData(string collectionName, IEnumerable ids, VectorQueryOptions? options = null) + { + if (ids.IsNullOrEmpty()) + { + return Enumerable.Empty(); + } + + var exist = await DoesCollectionExist(collectionName); + if (!exist) + { + return Enumerable.Empty(); + } + + var include = ChromaGetInclude.None; + if (options?.WithPayload == true) + { + include |= ChromaGetInclude.Metadatas | ChromaGetInclude.Documents; + } + if (options?.WithVector == true) + { + include |= ChromaGetInclude.Embeddings; + } + + var client = await GetClient(); + var collection = client.GetCollectionClient(collectionName); + var pointIds = ids.Select(x => x.ToString()).Distinct().ToList(); + var entries = await collection.GetAsync(pointIds, include: include); + return entries.Select(x => new VectorCollectionData + { + Id = x.Id, + Payload = MapPayload(x.Metadata, x.Document), + Vector = x.Embedding?.ToArray() + }); + } + + public async Task Upsert(string collectionName, Guid id, float[] vector, string text, Dictionary? payload = null) + { + // The text is the document of the record, the other payload values its metadata + var metadata = new Dictionary(); + + if (payload != null) + { + foreach (var item in payload) + { + if (item.Value == null || item.Key.IsEqualTo(KnowledgePayloadName.Text)) + { + continue; + } + + var value = item.Value.DataValue?.ConvertToString(); + if (string.IsNullOrEmpty(value)) + { + continue; + } + + switch (item.Value.DataType) + { + case VectorPayloadDataType.Boolean when bool.TryParse(value, out var boolVal): + metadata[item.Key] = boolVal; + break; + case VectorPayloadDataType.Integer when long.TryParse(value, out var longVal): + metadata[item.Key] = longVal; + break; + case VectorPayloadDataType.Double when double.TryParse(value, out var doubleVal): + metadata[item.Key] = doubleVal; + break; + case VectorPayloadDataType.Datetime when DateTime.TryParse(value, out var dt): + metadata[item.Key] = dt.ToString("o"); + break; + case VectorPayloadDataType.String: + metadata[item.Key] = value; + break; + default: + break; + } + } + } + + var client = await GetClient(); + var collection = client.GetCollectionClient(collectionName); + + // The payload replaces the stored one, as in Qdrant: Chroma keeps the keys an upsert does not send, so a null deletes each of them + var stored = await collection.GetAsync(id.ToString(), include: ChromaGetInclude.Metadatas); + foreach (var key in stored?.Metadata?.Keys ?? Enumerable.Empty()) + { + if (!metadata.ContainsKey(key)) + { + metadata[key] = null!; + } + } + + await collection.UpsertAsync(new ChromaRecords([id.ToString()]) + { + Embeddings = [vector], + Metadatas = metadata.Count > 0 ? [metadata] : null, + Documents = [text] + }); + + return true; + } + + public async Task> Search(string collectionName, float[] vector, VectorSearchOptions? options = null) + { + var results = new List(); + + var exist = await DoesCollectionExist(collectionName); + if (!exist) + { + return results; + } + + options ??= VectorSearchOptions.Default(); + var where = BuildQueryFilter(options.FilterGroups); + var include = ChromaQueryInclude.Metadatas | ChromaQueryInclude.Documents | ChromaQueryInclude.Distances; + if (options.WithVector) + { + include |= ChromaQueryInclude.Embeddings; + } + + var client = await GetClient(); + var collection = client.GetCollectionClient(collectionName); + var entries = await collection.QueryAsync(new ChromaQuery([vector]) + { + NResults = options.Limit.GetValueOrDefault(), + Where = where, + Include = include + }); + + // The collections have the cosine space: the score is the cosine similarity, 1 - the distance + results = entries.Single() + .Select(x => new VectorCollectionData + { + Id = x.Id, + Payload = MapPayload(x.Metadata, x.Document, options.Fields), + Score = 1 - x.Distance, + Vector = options.WithVector ? x.Embedding?.ToArray() : null + }) + .Where(x => options.Confidence == null || x.Score >= options.Confidence) + .ToList(); + + return results; + } + + public async Task DeleteCollectionData(string collectionName, List ids) + { + if (ids.IsNullOrEmpty()) return false; + + var exist = await DoesCollectionExist(collectionName); + if (!exist) + { + return false; + } + + var client = await GetClient(); + var collection = client.GetCollectionClient(collectionName); + await collection.DeleteAsync(ids.Select(x => x.ToString()).ToList()); + return true; + } + + public async Task DeleteCollectionAllData(string collectionName) + { + var exist = await DoesCollectionExist(collectionName); + if (!exist) + { + return false; + } + + // Chroma deletes by ids or by filters: read the ids, then delete them + var client = await GetClient(); + var collection = client.GetCollectionClient(collectionName); + var ids = (await collection.GetAsync(include: ChromaGetInclude.None)).Select(x => x.Id).ToList(); + if (ids.Any()) + { + await collection.DeleteAsync(ids); + } + return true; + } + #endregion + + #region Payload index + public async Task CreateCollectionPayloadIndex(string collectionName, CollectionIndexOptions options) + { + // Chroma indexes every metadata key of a collection by default + return await DoesCollectionExist(collectionName); + } + + public async Task DeleteCollectionPayloadIndex(string collectionName, CollectionIndexOptions options) + { + // Chroma does not turn off the index of a key once the collection exists + return await Task.FromResult(false); + } + #endregion + + #region Snapshots + // Chroma has no snapshots of a collection + public async Task> GetCollectionSnapshots(string collectionName) + { + return await Task.FromResult(Enumerable.Empty()); + } + + public async Task CreateCollectionShapshot(string collectionName) + { + return await Task.FromResult(null); + } + + public async Task DownloadCollectionSnapshot(string collectionName, string snapshotFileName) + { + return await Task.FromResult(BinaryData.Empty); + } + + public async Task RecoverCollectionFromShapshot(string collectionName, string snapshotFileName, BinaryData snapshotData) + { + return await Task.FromResult(false); + } + + public async Task DeleteCollectionShapshot(string collectionName, string snapshotName) + { + return await Task.FromResult(false); + } + #endregion + + + #region Private methods + #region Query filter + private ChromaWhereOperator? BuildQueryFilter(IEnumerable? filterGroups) + { + if (filterGroups.IsNullOrEmpty()) + { + return null; + } + + var groupConditions = filterGroups!.Select(x => BuildFilterGroupCondition(x)) + .Where(c => c != null) + .ToList(); + + if (groupConditions.IsNullOrEmpty()) + { + return null; + } + + // Multiple groups are combined with AND, as in the other vector databases + return groupConditions.Aggregate((a, b) => a! & b!); + } + + private ChromaWhereOperator? BuildFilterGroupCondition(VectorFilterGroup group) + { + if (group.Filters.IsNullOrEmpty()) + { + return null; + } + + var subGroupConditions = group.Filters! + .Select(x => BuildSubGroupCondition(x)) + .Where(c => c != null) + .ToList(); + + return Combine(subGroupConditions, group.LogicalOperator); + } + + private ChromaWhereOperator? BuildSubGroupCondition(VectorFilterSubGroup subGroup) + { + if (subGroup.Operands.IsNullOrEmpty()) + { + return null; + } + + var operandConditions = subGroup.Operands + .Select(x => BuildOperandCondition(x)) + .Where(c => c != null) + .ToList(); + + return Combine(operandConditions, subGroup.LogicalOperator); + } + + private ChromaWhereOperator? Combine(List conditions, string logicalOperator) + { + if (conditions.IsNullOrEmpty()) + { + return null; + } + + return logicalOperator.IsEqualTo("and") + ? conditions.Aggregate((a, b) => a! & b!) + : conditions.Aggregate((a, b) => a! | b!); + } + + private ChromaWhereOperator? BuildOperandCondition(VectorFilterOperand operand) + { + ChromaWhereOperator? condition = null; + + if (operand.Match != null) + { + condition = BuildMatchCondition(operand.Match); + } + else if (operand.Range != null) + { + condition = BuildRangeCondition(operand.Range); + } + + return condition; + } + + private ChromaWhereOperator? BuildMatchCondition(VectorFilterMatch match) + { + if (string.IsNullOrEmpty(match.Key) || match.Value == null) + { + return null; + } + + object value = match.Value; + if (match.DataType == VectorPayloadDataType.Boolean + && bool.TryParse(match.Value, out var boolVal)) + { + value = boolVal; + } + else if (match.DataType == VectorPayloadDataType.Integer + && long.TryParse(match.Value, out var longVal)) + { + value = longVal; + } + else if (match.DataType == VectorPayloadDataType.Double + && double.TryParse(match.Value, out var doubleVal)) + { + value = doubleVal; + } + + return match.Operator.IsEqualTo("eq") + ? ChromaWhereOperator.Equal(match.Key, value) + : ChromaWhereOperator.NotEqual(match.Key, value); + } + + private ChromaWhereOperator? BuildRangeCondition(VectorFilterRange range) + { + if (string.IsNullOrEmpty(range.Key) || range.Conditions.IsNullOrEmpty()) + { + return null; + } + + // Chroma compares numbers only: a datetime is stored as text + if (range.DataType != VectorPayloadDataType.Integer + && range.DataType != VectorPayloadDataType.Double) + { + _logger.LogWarning($"Chroma does not filter {range.DataType} values by range (Key: {range.Key})."); + return null; + } + + var conditions = new List(); + foreach (var condition in range.Conditions) + { + // An integer value stays an integer: Chroma Cloud does not compare a float with integer values + object value; + if (range.DataType == VectorPayloadDataType.Integer + && long.TryParse(condition.Value, out var longVal)) + { + value = longVal; + } + else if (double.TryParse(condition.Value, out var doubleVal)) + { + value = doubleVal; + } + else + { + continue; + } + + switch (condition.Operator.ToLower()) + { + case "lt": + conditions.Add(ChromaWhereOperator.LessThan(range.Key, value)); + break; + case "lte": + conditions.Add(ChromaWhereOperator.LessThanOrEqual(range.Key, value)); + break; + case "gt": + conditions.Add(ChromaWhereOperator.GreaterThan(range.Key, value)); + break; + case "gte": + conditions.Add(ChromaWhereOperator.GreaterThanOrEqual(range.Key, value)); + break; + } + } + + return Combine(conditions, "and"); + } + #endregion + + private Dictionary MapPayload(IReadOnlyDictionary? metadata, string? document, IEnumerable? fields = null) + { + var payload = new Dictionary(); + if (document != null) + { + payload[KnowledgePayloadName.Text] = VectorPayloadValue.BuildStringValue(document); + } + + foreach (var item in metadata ?? new Dictionary()) + { + payload[item.Key] = item.Value switch + { + string s => VectorPayloadValue.BuildStringValue(s), + bool b => VectorPayloadValue.BuildBooleanValue(b), + long l => VectorPayloadValue.BuildIntegerValue(l), + int i => VectorPayloadValue.BuildIntegerValue(i), + double d => VectorPayloadValue.BuildDoubleValue(d), + float f => VectorPayloadValue.BuildDoubleValue(f), + _ => VectorPayloadValue.BuildUnkownValue(string.Empty) + }; + } + + if (fields.IsNullOrEmpty()) + { + return payload; + } + + // Only the payload fields asked for, as the other vector databases return + return payload.Where(x => fields!.Contains(x.Key)).ToDictionary(x => x.Key, x => x.Value); + } + #endregion +} diff --git a/src/Plugins/BotSharp.Plugin.Chroma/ChromaPlugin.cs b/src/Plugins/BotSharp.Plugin.Chroma/ChromaPlugin.cs new file mode 100644 index 000000000..f255b2a09 --- /dev/null +++ b/src/Plugins/BotSharp.Plugin.Chroma/ChromaPlugin.cs @@ -0,0 +1,24 @@ +using BotSharp.Abstraction.Plugins; +using BotSharp.Abstraction.Settings; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; + +namespace BotSharp.Plugin.Chroma; + +public class ChromaPlugin : IBotSharpPlugin +{ + public string Id => "c6e47c66-e02c-4e0a-b9c1-5ab64d74a9a1"; + public string Name => "Chroma"; + public string Description => "Open-source search and retrieval database for AI applications"; + public string IconUrl => "https://raw.githubusercontent.com/chroma-core/chroma/main/docs/mintlify/images/light-logo.svg"; + public void RegisterDI(IServiceCollection services, IConfiguration config) + { + services.AddScoped(provider => + { + var settingService = provider.GetRequiredService(); + return settingService.Bind("Chroma"); + }); + + services.AddScoped(); + } +} diff --git a/src/Plugins/BotSharp.Plugin.Chroma/ChromaSetting.cs b/src/Plugins/BotSharp.Plugin.Chroma/ChromaSetting.cs new file mode 100644 index 000000000..4d7c5eefe --- /dev/null +++ b/src/Plugins/BotSharp.Plugin.Chroma/ChromaSetting.cs @@ -0,0 +1,24 @@ +namespace BotSharp.Plugin.Chroma; + +public class ChromaSetting +{ + /// + /// Server address, like http://localhost:8000, or https://api.trychroma.com for Chroma Cloud + /// + public string Url { get; set; } = string.Empty; + + /// + /// API key of Chroma Cloud, or the token of a server with token authentication + /// + public string ApiKey { get; set; } = string.Empty; + + /// + /// Tenant: on Chroma Cloud the API key gives it when empty, otherwise default_tenant + /// + public string Tenant { get; set; } = string.Empty; + + /// + /// Database: on Chroma Cloud an API key for one database gives it when empty, otherwise default_database + /// + public string Database { get; set; } = string.Empty; +} diff --git a/src/Plugins/BotSharp.Plugin.Chroma/Using.cs b/src/Plugins/BotSharp.Plugin.Chroma/Using.cs new file mode 100644 index 000000000..a68f82869 --- /dev/null +++ b/src/Plugins/BotSharp.Plugin.Chroma/Using.cs @@ -0,0 +1,10 @@ +global using System; +global using System.Collections.Generic; +global using System.Linq; +global using System.Threading.Tasks; +global using BotSharp.Abstraction.VectorStorage; +global using BotSharp.Abstraction.VectorStorage.Enums; +global using BotSharp.Abstraction.VectorStorage.Models; +global using BotSharp.Abstraction.Knowledges.Enums; +global using BotSharp.Abstraction.Knowledges.Models; +global using BotSharp.Abstraction.Knowledges.Options; diff --git a/src/WebStarter/WebStarter.csproj b/src/WebStarter/WebStarter.csproj index 85aa1b1e2..8faed1223 100644 --- a/src/WebStarter/WebStarter.csproj +++ b/src/WebStarter/WebStarter.csproj @@ -73,6 +73,7 @@ + diff --git a/src/WebStarter/appsettings.json b/src/WebStarter/appsettings.json index 823cc3110..3e4bf12c9 100644 --- a/src/WebStarter/appsettings.json +++ b/src/WebStarter/appsettings.json @@ -1437,6 +1437,13 @@ "ApiKey": "" }, + "Chroma": { + "Url": "", + "ApiKey": "", + "Tenant": "", + "Database": "" + }, + "Graph": { "BaseUrl": "", "SearchPath": "" @@ -1586,6 +1593,7 @@ "BotSharp.Plugin.Planner", "BotSharp.Plugin.Graph", "BotSharp.Plugin.Qdrant", + "BotSharp.Plugin.Chroma", "BotSharp.Plugin.Membase", "BotSharp.Plugin.ChatHub", "BotSharp.Plugin.WeChat", From e4a89307df7005fc601427444bee8d48b9e49b9e Mon Sep 17 00:00:00 2001 From: Laurianti Date: Thu, 15 Oct 2026 17:06:40 +0200 Subject: [PATCH 2/2] Register the HTTP client factory, filter text on the document and reject what Chroma cannot filter The plugin registers IHttpClientFactory, which ChromaDb takes its HttpClient from. A filter on text matches the document exactly, with an anchored regular expression. A range on values other than numbers, an empty text, and text in an OR with another field throw a NotSupportedException instead of leaving the filter out. --- .../BotSharp.Plugin.Chroma/ChromaDb.cs | 77 +++++++++++++------ .../BotSharp.Plugin.Chroma/ChromaPlugin.cs | 1 + 2 files changed, 54 insertions(+), 24 deletions(-) diff --git a/src/Plugins/BotSharp.Plugin.Chroma/ChromaDb.cs b/src/Plugins/BotSharp.Plugin.Chroma/ChromaDb.cs index 784606fe3..b124d7b1a 100644 --- a/src/Plugins/BotSharp.Plugin.Chroma/ChromaDb.cs +++ b/src/Plugins/BotSharp.Plugin.Chroma/ChromaDb.cs @@ -395,16 +395,11 @@ public async Task DeleteCollectionShapshot(string collectionName, string s .Where(c => c != null) .ToList(); - if (groupConditions.IsNullOrEmpty()) - { - return null; - } - // Multiple groups are combined with AND, as in the other vector databases - return groupConditions.Aggregate((a, b) => a! & b!); + return Combine(groupConditions, "and")?.Where; } - private ChromaWhereOperator? BuildFilterGroupCondition(VectorFilterGroup group) + private FilterCondition? BuildFilterGroupCondition(VectorFilterGroup group) { if (group.Filters.IsNullOrEmpty()) { @@ -419,7 +414,7 @@ public async Task DeleteCollectionShapshot(string collectionName, string s return Combine(subGroupConditions, group.LogicalOperator); } - private ChromaWhereOperator? BuildSubGroupCondition(VectorFilterSubGroup subGroup) + private FilterCondition? BuildSubGroupCondition(VectorFilterSubGroup subGroup) { if (subGroup.Operands.IsNullOrEmpty()) { @@ -434,21 +429,31 @@ public async Task DeleteCollectionShapshot(string collectionName, string s return Combine(operandConditions, subGroup.LogicalOperator); } - private ChromaWhereOperator? Combine(List conditions, string logicalOperator) + private FilterCondition? Combine(List conditions, string logicalOperator) { if (conditions.IsNullOrEmpty()) { return null; } - return logicalOperator.IsEqualTo("and") - ? conditions.Aggregate((a, b) => a! & b!) - : conditions.Aggregate((a, b) => a! | b!); + var hasText = conditions.Any(x => x!.HasText); + var hasMetadata = conditions.Any(x => x!.HasMetadata); + if (logicalOperator.IsEqualTo("and")) + { + return new(conditions.Select(x => x!.Where).Aggregate((a, b) => a & b), hasText, hasMetadata); + } + + // Chroma takes the filters on the document and the ones on the metadata together only with AND + if (hasText && hasMetadata) + { + throw new NotSupportedException($"Chroma does not combine a filter on {KnowledgePayloadName.Text} with a filter on another payload field by OR."); + } + return new(conditions.Select(x => x!.Where).Aggregate((a, b) => a | b), hasText, hasMetadata); } - private ChromaWhereOperator? BuildOperandCondition(VectorFilterOperand operand) + private FilterCondition? BuildOperandCondition(VectorFilterOperand operand) { - ChromaWhereOperator? condition = null; + FilterCondition? condition = null; if (operand.Match != null) { @@ -462,13 +467,28 @@ public async Task DeleteCollectionShapshot(string collectionName, string s return condition; } - private ChromaWhereOperator? BuildMatchCondition(VectorFilterMatch match) + private FilterCondition? BuildMatchCondition(VectorFilterMatch match) { if (string.IsNullOrEmpty(match.Key) || match.Value == null) { return null; } + // The text is the document of the record: an exact match is an anchored regular expression on it + if (match.Key.IsEqualTo(KnowledgePayloadName.Text)) + { + if (match.Value.Length == 0) + { + throw new NotSupportedException($"Chroma does not filter {KnowledgePayloadName.Text} by an empty value."); + } + + var pattern = $"^{EscapeRegex(match.Value)}$"; + var document = match.Operator.IsEqualTo("eq") + ? ChromaWhereDocumentOperator.Regex(pattern) + : ChromaWhereDocumentOperator.NotRegex(pattern); + return new(ChromaWhereOperator.Document(document), HasText: true, HasMetadata: false); + } + object value = match.Value; if (match.DataType == VectorPayloadDataType.Boolean && bool.TryParse(match.Value, out var boolVal)) @@ -486,12 +506,13 @@ public async Task DeleteCollectionShapshot(string collectionName, string s value = doubleVal; } - return match.Operator.IsEqualTo("eq") + var where = match.Operator.IsEqualTo("eq") ? ChromaWhereOperator.Equal(match.Key, value) : ChromaWhereOperator.NotEqual(match.Key, value); + return new(where, HasText: false, HasMetadata: true); } - private ChromaWhereOperator? BuildRangeCondition(VectorFilterRange range) + private FilterCondition? BuildRangeCondition(VectorFilterRange range) { if (string.IsNullOrEmpty(range.Key) || range.Conditions.IsNullOrEmpty()) { @@ -502,11 +523,10 @@ public async Task DeleteCollectionShapshot(string collectionName, string s if (range.DataType != VectorPayloadDataType.Integer && range.DataType != VectorPayloadDataType.Double) { - _logger.LogWarning($"Chroma does not filter {range.DataType} values by range (Key: {range.Key})."); - return null; + throw new NotSupportedException($"Chroma does not filter {range.DataType} values by range (Key: {range.Key})."); } - var conditions = new List(); + var conditions = new List(); foreach (var condition in range.Conditions) { // An integer value stays an integer: Chroma Cloud does not compare a float with integer values @@ -528,22 +548,31 @@ public async Task DeleteCollectionShapshot(string collectionName, string s switch (condition.Operator.ToLower()) { case "lt": - conditions.Add(ChromaWhereOperator.LessThan(range.Key, value)); + conditions.Add(new(ChromaWhereOperator.LessThan(range.Key, value), HasText: false, HasMetadata: true)); break; case "lte": - conditions.Add(ChromaWhereOperator.LessThanOrEqual(range.Key, value)); + conditions.Add(new(ChromaWhereOperator.LessThanOrEqual(range.Key, value), HasText: false, HasMetadata: true)); break; case "gt": - conditions.Add(ChromaWhereOperator.GreaterThan(range.Key, value)); + conditions.Add(new(ChromaWhereOperator.GreaterThan(range.Key, value), HasText: false, HasMetadata: true)); break; case "gte": - conditions.Add(ChromaWhereOperator.GreaterThanOrEqual(range.Key, value)); + conditions.Add(new(ChromaWhereOperator.GreaterThanOrEqual(range.Key, value), HasText: false, HasMetadata: true)); break; } } return Combine(conditions, "and"); } + // The characters that the regular expressions of Chroma, of the Rust crate regex, take as syntax + private static string EscapeRegex(string text) + { + const string syntax = @"\.+*?()|[]{}^$#&-~"; + return string.Concat(text.Select(c => syntax.Contains(c) ? $"\\{c}" : c.ToString())); + } + + // A condition of the filter, and whether it has a condition on the text, which goes to the document, and on the metadata + private record FilterCondition(ChromaWhereOperator Where, bool HasText, bool HasMetadata); #endregion private Dictionary MapPayload(IReadOnlyDictionary? metadata, string? document, IEnumerable? fields = null) diff --git a/src/Plugins/BotSharp.Plugin.Chroma/ChromaPlugin.cs b/src/Plugins/BotSharp.Plugin.Chroma/ChromaPlugin.cs index f255b2a09..cab334cd5 100644 --- a/src/Plugins/BotSharp.Plugin.Chroma/ChromaPlugin.cs +++ b/src/Plugins/BotSharp.Plugin.Chroma/ChromaPlugin.cs @@ -19,6 +19,7 @@ public void RegisterDI(IServiceCollection services, IConfiguration config) return settingService.Bind("Chroma"); }); + services.AddHttpClient(); services.AddScoped(); } }