Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Directory.Packages.props
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
<PackageVersion Include="Microsoft.Extensions.VectorData.ConformanceTests" Version="10.8.2" />

<PackageVersion Include="Azure.Search.Documents" Version="11.7.0" />
<PackageVersion Include="ChromaDotNet.Client" Version="2.18.1" />
<PackageVersion Include="ChromaDotNet.Client" Version="2.20.0" />
<PackageVersion Include="Microsoft.Azure.Cosmos" Version="3.61.0" />
<PackageVersion Include="Microsoft.Data.SqlClient" Version="6.1.6" />
<PackageVersion Include="System.Security.Cryptography.Pkcs" Version="10.0.13" />
Expand Down
2 changes: 1 addition & 1 deletion MEVD/src/Chroma/Chroma.csproj
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk">

<PropertyGroup>
<Version>1.0.2-preview</Version>
<Version>1.1.0-preview</Version>
<AssemblyName>CommunityToolkit.VectorData.Chroma</AssemblyName>
<RootNamespace>$(AssemblyName)</RootNamespace>
<TargetFrameworks>net10.0;net8.0;netstandard2.0;net462</TargetFrameworks>
Expand Down
35 changes: 21 additions & 14 deletions MEVD/src/Chroma/ChromaCollection.cs
Original file line number Diff line number Diff line change
Expand Up @@ -55,9 +55,6 @@ public class ChromaCollection<TKey, TRecord> : VectorStoreCollection<TKey, TReco
/// <summary>The properties to create a BM25 index for when the collection is created.</summary>
private readonly List<DataPropertyModel> _bm25Properties;

/// <summary>Whether the collection offers hybrid search, computed the first time it is asked.</summary>
private bool? _offersHybridSearch;

/// <summary>Whether the collection was disposed: it releases its share of the client only once.</summary>
private int _disposed;

Expand Down Expand Up @@ -411,7 +408,7 @@ public async IAsyncEnumerable<VectorSearchResult<TRecord>> HybridSearchAsync<TIn
async () =>
{
var index = await _records.FindBm25IndexAsync(textProperty.StorageName, cancellationToken).ConfigureAwait(false)
?? throw new InvalidOperationException(NoBm25IndexMessage(textProperty));
?? throw new InvalidOperationException(await NoBm25IndexMessageAsync(textProperty, cancellationToken).ConfigureAwait(false));
return await _records.SearchAsync(
new ChromaSearch
{
Expand Down Expand Up @@ -493,19 +490,29 @@ public override async IAsyncEnumerable<TRecord> GetAsync(Expression<Func<TRecord
}
}

// Hybrid search needs the BM25 indexes, which only Chroma Cloud has. An existing collection can have them under keys
// that the client did not generate, so a missing index is reported by the hybrid search itself, not here.
private bool OffersHybridSearch
=> _offersHybridSearch ??= _chromaClient.Client.Options.IsChromaCloud && _bm25Properties.Count > 0;

// Why the collection has no BM25 index on the property: on Chroma Cloud, the key of the index can be beyond its quota, or
// the collection was created without the index.
private string NoBm25IndexMessage(DataPropertyModel textProperty)
// Hybrid search needs the BM25 indexes, which distributed Chroma, the one Chroma Cloud runs on, and the servers that declare
// sparse vector indexes have. The client tells it from the server, the same whatever was called before, and keeps the answer:
// it is not kept here, because while the server does not answer, the client answers from its options and asks the server again
// later. An existing collection can have the indexes under keys that the client did not generate, so a missing index is
// reported by the hybrid search itself, not here. A method, for the callers that are not async: until the client has the answer
// of the server, it sends a request and waits for it, 5 seconds at most.
private bool OffersHybridSearch()
=> _bm25Properties.Count > 0 && _chromaClient.Client.SupportsSparseVectorIndexes();
Comment thread
adamsitnik marked this conversation as resolved.
Resolved

// Why the collection has no BM25 index on the property: the server has no sparse vector indexes, on Chroma Cloud the key of
// the index can be beyond its quota, or the collection was created without the index.
private async Task<string> NoBm25IndexMessageAsync(DataPropertyModel textProperty, CancellationToken cancellationToken)
{
var message = $"The Chroma collection '{Name}' has no BM25 index on the text of the property '{textProperty.ModelName}', which hybrid search needs. ";
if (!await _chromaClient.Client.SupportsSparseVectorIndexesAsync(cancellationToken).ConfigureAwait(false))
{
return message + "Only distributed Chroma, the one Chroma Cloud runs on, and the servers that declare sparse vector indexes have BM25 indexes. "
+ "For distributed Chroma installed elsewhere, set Distributed=true in the connection string, or WithDistributedChroma() in the options of the client.";
}

if (!_chromaClient.Client.Options.IsChromaCloud)
{
return message + "Only Chroma Cloud has BM25 indexes.";
return message + "The collection was created without it, for example by another client.";
}

var key = ChromaCollectionCreateMapping.GetBm25IndexKey(textProperty, _mapper.DocumentProperty);
Expand All @@ -523,7 +530,7 @@ private string NoBm25IndexMessage(DataPropertyModel textProperty)
serviceKey is not null ? null :
serviceType == typeof(VectorStoreCollectionMetadata) ? _collectionMetadata :
serviceType == typeof(ChromaClient) ? _chromaClient.Client :
serviceType == typeof(IKeywordHybridSearchable<TRecord>) ? (OffersHybridSearch ? this : null) :
serviceType == typeof(IKeywordHybridSearchable<TRecord>) ? (OffersHybridSearch() ? this : null) :
serviceType.IsInstanceOfType(this) ? this :
null;
}
Expand Down
3 changes: 2 additions & 1 deletion MEVD/src/Chroma/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ For more information, see the [Microsoft.Extensions.VectorData documentation](ht

## Limitations

- Hybrid search needs BM25 indexes, which distributed Chroma, the one Chroma Cloud runs on, and the servers that declare sparse vector indexes have. For distributed Chroma installed elsewhere, like with the Helm chart of Chroma or an operator, set `Distributed=true` in the connection string, or `WithDistributedChroma()` in the options of the client. On a single Chroma server, a collection does not offer `IKeywordHybridSearchable`, and a hybrid search throws an `InvalidOperationException` that says why.
- On Chroma Cloud, a vector search returns at most 300 results, `Skip` included, the default quota of Chroma Cloud: beyond that, Chroma Cloud answers with a quota error.
- Chroma does not store empty lists, so an empty array or list comes back as `null`.
- A data property whose storage name is empty, or starts with `#`, `$` or `chroma:`, throws a `NotSupportedException` when the collection object is created. Chroma rejects the first ones in a write, and drops the `chroma:` keys from every read, so such a property would always come back as `null`.
Expand All @@ -35,7 +36,7 @@ For more information, see the [Microsoft.Extensions.VectorData documentation](ht
- On Chroma Cloud, a record has at most 32 metadata keys, and a null value counts as one. The provider writes every data property, null or not. A write with null values that would take a record beyond 32 keys throws an `ArgumentException`. With `WithReadsBeyondKeyQuota()`, the client reads such a record first and sends only the null values of the keys it has. Two writes of the same record at the same time can then mix.
- On Chroma Cloud, a metadata key has at most 36 bytes in UTF-8. With a data property whose storage name is longer, the collection is created, but every upsert throws a `VectorStoreException` with the quota error of Chroma Cloud.
- With two or more full-text properties, each has a BM25 index of its own, whose key is its storage name with `_bm25`. On Chroma Cloud, a storage name of more than 31 bytes makes that key longer than 36 bytes: the collection is created without that index, and a hybrid search on that property throws an `InvalidOperationException` that says why.
- On Chroma Cloud, `Contains` on the full-text property throws an `ArgumentException` for a text of fewer than 3 characters. Once Chroma Cloud has indexed the records, such a filter would find no record, and its negation every record.
- On distributed Chroma, the one Chroma Cloud runs on, `Contains` on the full-text property throws an `ArgumentException` for a text of fewer than 3 characters: on Chroma Cloud, and elsewhere with `Distributed=true` in the connection string or `WithDistributedChroma()` in the options of the client. Once the server has indexed the records, such a filter would find no record, and its negation every record.

The connection string does not set `WithUpsertStrategy` and `WithReadsBeyondKeyQuota()`. Set them on the options of a `ChromaClient` created in code, and pass it to the vector store:

Expand Down
20 changes: 10 additions & 10 deletions MEVD/test/Chroma.ConformanceTests/ChromaHybridSearchTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -9,55 +9,55 @@
namespace Chroma.ConformanceTests;

/// <summary>
/// Hybrid search needs the Search API and the sparse vector indexes of Chroma, which only Chroma Cloud has:
/// these tests run only when Chroma:ConnectionString has a Chroma Cloud address, like https://api.trychroma.com, and are skipped otherwise. See the README for the settings.
/// Hybrid search needs the Search API and the sparse vector indexes of Chroma, which distributed Chroma, the one Chroma Cloud runs on, and the
/// servers that declare sparse vector indexes have: these tests run only against such a server, and are skipped otherwise. See the README for the settings.
/// </summary>
public class ChromaHybridSearchTests(ChromaHybridSearchTests.VectorAndStringFixture vectorAndStringFixture, ChromaHybridSearchTests.MultiTextFixture multiTextFixture)
: HybridSearchTests<string>(vectorAndStringFixture, multiTextFixture),
IClassFixture<ChromaHybridSearchTests.VectorAndStringFixture>,
IClassFixture<ChromaHybridSearchTests.MultiTextFixture>
{
private const string SkipReason = "Hybrid search needs Chroma Cloud: set Chroma:ConnectionString to a Chroma Cloud address, like https://api.trychroma.com to run it.";
private const string SkipReason = "Hybrid search needs sparse vector indexes: set Chroma:ConnectionString to distributed Chroma, the one Chroma Cloud runs on, like https://api.trychroma.com or an address with Distributed=true, or to a server that declares sparse vector indexes, to run it.";

public override Task HybridSearchAsync()
{
Assert.SkipUnless(ChromaTestStore.IsChromaCloud, SkipReason);
Assert.SkipUnless(ChromaTestStore.Instance.SupportsSparseVectorIndexes, SkipReason);
return base.HybridSearchAsync();
}

public override Task HybridSearchAsync_with_filter()
{
Assert.SkipUnless(ChromaTestStore.IsChromaCloud, SkipReason);
Assert.SkipUnless(ChromaTestStore.Instance.SupportsSparseVectorIndexes, SkipReason);
return base.HybridSearchAsync_with_filter();
}

public override Task HybridSearchAsync_with_top()
{
Assert.SkipUnless(ChromaTestStore.IsChromaCloud, SkipReason);
Assert.SkipUnless(ChromaTestStore.Instance.SupportsSparseVectorIndexes, SkipReason);
return base.HybridSearchAsync_with_top();
}

public override Task HybridSearchAsync_with_Skip()
{
Assert.SkipUnless(ChromaTestStore.IsChromaCloud, SkipReason);
Assert.SkipUnless(ChromaTestStore.Instance.SupportsSparseVectorIndexes, SkipReason);
return base.HybridSearchAsync_with_Skip();
}

public override Task HybridSearchAsync_with_multiple_keywords_ranks_matched_keywords_higher()
{
Assert.SkipUnless(ChromaTestStore.IsChromaCloud, SkipReason);
Assert.SkipUnless(ChromaTestStore.Instance.SupportsSparseVectorIndexes, SkipReason);
return base.HybridSearchAsync_with_multiple_keywords_ranks_matched_keywords_higher();
}

public override Task HybridSearchAsync_with_multiple_text_properties()
{
Assert.SkipUnless(ChromaTestStore.IsChromaCloud, SkipReason);
Assert.SkipUnless(ChromaTestStore.Instance.SupportsSparseVectorIndexes, SkipReason);
return base.HybridSearchAsync_with_multiple_text_properties();
}

public override Task HybridSearchAsync_without_explicitly_specified_property_fails()
{
Assert.SkipUnless(ChromaTestStore.IsChromaCloud, SkipReason);
Assert.SkipUnless(ChromaTestStore.Instance.SupportsSparseVectorIndexes, SkipReason);
return base.HybridSearchAsync_without_explicitly_specified_property_fails();
}

Expand Down
2 changes: 1 addition & 1 deletion MEVD/test/Chroma.ConformanceTests/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ This project contains conformance tests for the Chroma Vector Store implementati

By default, the tests will automatically use a testcontainer to spin up a Chroma instance. Docker must be running on your machine for this to work.

The hybrid search tests need Chroma Cloud, the only Chroma with the sparse vector indexes that hybrid search uses: they run only when `Chroma:ConnectionString` has a Chroma Cloud address, like `https://api.trychroma.com`, and are skipped otherwise.
The hybrid search tests need the sparse vector indexes that hybrid search uses, which distributed Chroma, the one Chroma Cloud runs on, and the servers that declare sparse vector indexes have: they run only when `Chroma:ConnectionString` points to such a server, like Chroma Cloud at `https://api.trychroma.com`, or distributed Chroma installed elsewhere with `Distributed=true`, and are skipped otherwise.

### Using an External Chroma Instance

Expand Down
7 changes: 4 additions & 3 deletions MEVD/test/Chroma.ConformanceTests/Support/ChromaTestStore.cs
Original file line number Diff line number Diff line change
Expand Up @@ -42,10 +42,10 @@ internal sealed class ChromaTestStore : TestStore
public ChromaClient Client => this._client ?? throw new InvalidOperationException("Not initialized");

/// <summary>
/// Whether the tests run against Chroma Cloud, the only Chroma with the Search API and the sparse vector indexes that hybrid search needs.
/// Whether the server takes the sparse vector indexes that hybrid search needs: distributed Chroma, the one Chroma Cloud runs on,
/// and the servers that declare them. The client tells it when the store starts.
/// </summary>
public static bool IsChromaCloud => ChromaTestEnvironment.IsConnectionStringDefined
&& ChromaConfigurationOptions.FromConnectionString(ChromaTestEnvironment.ConnectionString!).Uri.Host.EndsWith(".trychroma.com", StringComparison.OrdinalIgnoreCase);
public bool SupportsSparseVectorIndexes { get; private set; }

public ChromaVectorStore GetVectorStore(ChromaVectorStoreOptions options)
=> new(this.Client, ownsClient: false, options); // The client is shared, it's not owned by the vector store.
Expand All @@ -69,6 +69,7 @@ protected override async Task StartAsync()
}

this._client = new ChromaClient(options);
this.SupportsSparseVectorIndexes = await this._client.SupportsSparseVectorIndexesAsync();

// It's a shared static instance, we don't want any of the tests to dispose the client.
this.DefaultVectorStore = new ChromaVectorStore(this._client, ownsClient: false);
Expand Down
2 changes: 1 addition & 1 deletion MEVD/test/Chroma.ConformanceTests/testsettings.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
// Optional: Provide a connection string to use an external Chroma instance instead of testcontainers
// This is useful for running tests against Chroma Cloud, which hybrid search needs
// This is useful for running tests against distributed Chroma, the one Chroma Cloud runs on, which hybrid search needs
// If not specified, tests will automatically use a testcontainer
"Chroma": {
"ConnectionString": ""
Expand Down
Loading
Loading