diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json new file mode 100644 index 0000000..eb76f37 --- /dev/null +++ b/.devcontainer/devcontainer.json @@ -0,0 +1,17 @@ +{ + "name": "ChromaDB.Client with Chroma", + "dockerComposeFile": "docker-compose.yml", + "service": "app", + "workspaceFolder": "/workspaces/${localWorkspaceFolderBasename}", + "forwardPorts": [8000], + "portsAttributes": { + "8000": { "label": "Chroma" } + }, + "postCreateCommand": "dotnet --version", + "postAttachCommand": "echo 'Chroma is on http://localhost:8000. Try the client: dotnet run Samples/quickstart.cs'", + "customizations": { + "vscode": { + "extensions": ["ms-dotnettools.csharp"] + } + } +} diff --git a/.devcontainer/docker-compose.yml b/.devcontainer/docker-compose.yml new file mode 100644 index 0000000..e0ccb13 --- /dev/null +++ b/.devcontainer/docker-compose.yml @@ -0,0 +1,12 @@ +services: + app: + image: mcr.microsoft.com/devcontainers/dotnet:1-10.0 + volumes: + - ../..:/workspaces:cached + command: sleep infinity + # Chroma answers on localhost:8000 inside the app container too. + network_mode: service:chroma + + chroma: + image: chromadb/chroma:1.5.9 + restart: unless-stopped diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 0000000..861196d --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1,2 @@ +# The maintainers review every change. +* @ChromaDotNet/maintainers diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..e2e1445 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,17 @@ +version: 2 +updates: + # Security updates only: the library keeps the lowest versions of its dependencies on purpose. + - package-ecosystem: nuget + directory: / + schedule: + interval: weekly + open-pull-requests-limit: 0 + + - package-ecosystem: github-actions + directory: / + schedule: + interval: weekly + groups: + actions: + patterns: + - '*' diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index fc224ff..c0a1605 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -4,23 +4,55 @@ on: push: branches: - master + - main tags: - v* pull_request: schedule: - cron: '4 13 * * 2' +permissions: + contents: read + env: CONFIGURATION: Release DOTNET_VERSION: 8.0.x + # The seed of the property tests, so that every run checks the same cases: without it, each run takes new ones. + FSCHECK_REPLAY: '(5123468595321266860,8907826574119387873)' jobs: ci: runs-on: ubuntu-latest - name: ${{ github.event_name != 'schedule' && 'CI' || 'CI (schedule)' }} + name: ${{ github.event_name != 'schedule' && 'CI' || 'CI (schedule)' }} (Chroma ${{ matrix.chroma }}) + strategy: + fail-fast: false + matrix: + # v2 API: 0.5.16, the first release with it; 0.5.20, the first that rejects embeddings of different dimensions; + # 0.6.3, the last 0.x; 1.0.0, the first 1.x; 1.5.9, the current release; latest catches new releases without blocking. + chroma: ['0.5.16', '0.5.20', '0.6.3', '1.0.0', '1.5.9', 'latest'] + include: + # v1 API, with ChromaApiVersion.V1: 0.4.10, without tenants and databases; 0.4.15, without count_collections + # and $not_contains; 0.4.23, with both; 0.5.15, the last release with only the v1 API. See docs/COMPATIBILITY.md. + - chroma: '0.4.10' + api: v1 + - chroma: '0.4.15' + api: v1 + - chroma: '0.4.23' + api: v1 + - chroma: '0.5.15' + api: v1 + - chroma: '1.5.9' + pack: true + continue-on-error: ${{ matrix.chroma == 'latest' }} + env: + CHROMA_IMAGE: chromadb/chroma:${{ matrix.chroma }} + CHROMA_TEST_API_VERSION: ${{ matrix.api }} + # An environment variable, so that the restore in the Build step reads it too: + # pack --no-build takes the package id of the referenced client project from that restore. + PackageIdPrefix: ${{ vars.PACKAGE_ID_PREFIX }} steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 - name: CI version suffix if: ${{ !startsWith(github.ref, 'refs/tags/v') }} @@ -31,7 +63,7 @@ jobs: run: echo 'VERSION_SUFFIX=' >> $GITHUB_ENV - name: Setup .NET - uses: actions/setup-dotnet@v4 + uses: actions/setup-dotnet@67a3573c9a986a3f9c594539f4ab511d57bb3ce9 # v4 with: dotnet-version: ${{ env.DOTNET_VERSION }} @@ -41,34 +73,166 @@ jobs: - name: Test run: dotnet test -c ${{ env.CONFIGURATION }} --no-build --verbosity normal --logger 'GitHubActions;summary.includePassedTests=true;summary.includeSkippedTests=true' + - name: Test in a non-default tenant and database + run: dotnet test -c ${{ env.CONFIGURATION }} --no-build --verbosity normal --logger 'GitHubActions;summary.includePassedTests=true;summary.includeSkippedTests=true' + env: + CHROMA_TEST_TENANT: test_tenant + CHROMA_TEST_DATABASE: test_database + - name: Pack + if: ${{ matrix.pack }} run: dotnet pack -c ${{ env.CONFIGURATION }} --no-build --version-suffix '${{ env.VERSION_SUFFIX }}' + # The release notes of every package link the GitHub release of its version, so they cannot stay at an older one. + - name: Check the release notes of the packages + if: ${{ matrix.pack }} + run: | + status=0 + for pkg in $(find . -path '*/bin/*' -name '*.nupkg'); do + nuspec=$(unzip -p "$pkg" '*.nuspec') + version=$(echo "$nuspec" | sed -n 's:.*\([0-9.]*\).*.*:\1:p') + if echo "$nuspec" | grep -Eq ".*/releases/tag/v$(echo "$version" | sed 's/\./\\./g')([^0-9]|$)"; then + echo "$(basename "$pkg"): the release notes link the release v$version" + else + echo "::error::$(basename "$pkg"): the release notes do not link the release v$version" + status=1 + fi + done + exit $status + - name: Upload - if: ${{ github.event_name != 'schedule' }} - uses: actions/upload-artifact@v4 + if: ${{ matrix.pack && github.event_name != 'schedule' }} + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 with: name: packages path: | **/*.nupkg **/*.snupkg + # An application on .NET 6 or 7 takes the netstandard2.0 build: the packages it brings must not warn that they do + # not support it. Built outside the repository, so its props do not apply, with -warnaserror, which turns the + # warnings of the targets of a package into errors (TreatWarningsAsErrors only covers the compiler). + - name: Build applications on .NET 6 and 7 with the package + if: ${{ matrix.pack }} + run: | + pkgdir="$GITHUB_WORKSPACE/ChromaDB.Client/bin/${{ env.CONFIGURATION }}" + file=$(basename "$(ls "$pkgdir"/*.nupkg | head -1)" .nupkg) + id=$(echo "$file" | sed -E 's/\.[0-9]+\.[0-9]+\.[0-9]+.*$//') + version="${file#$id.}" + mkdir -p "$RUNNER_TEMP/consumer" && cd "$RUNNER_TEMP/consumer" + cat > Consumer.csproj < + + Exe + net6.0;net7.0 + false + true + + + + + + XML + echo 'System.Console.WriteLine(new ChromaDB.Client.ChromaConfigurationOptions("http://localhost:8000").Uri);' > Program.cs + dotnet build -warnaserror -p:RestoreSources="https://api.nuget.org/v3/index.json;$pkgdir" + + # Runs after Pack: it rebuilds the netstandard2.0 client without the version suffix. + - name: Test the netstandard2.0 build + if: ${{ matrix.pack }} + run: dotnet test ChromaDB.Client.Tests -c ${{ env.CONFIGURATION }} -p:ChromaClientTargetFramework=netstandard2.0 --verbosity normal --logger 'GitHubActions;summary.includePassedTests=true;summary.includeSkippedTests=true' + + trimming: + # The client published with trimming and with NativeAOT, where reflection-based serialization is off, + # running real requests: Samples/ChromaDB.Client.TrimmingTest. Every trimming or AOT warning is an error. + # --lists: Chroma 1.5.9 stores lists in metadata, so a rejected list is a failure. + runs-on: ubuntu-latest + name: Trimming and NativeAOT + services: + chroma: + image: chromadb/chroma:1.5.9 + ports: + - 8000:8000 + + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + + - name: Setup .NET + uses: actions/setup-dotnet@67a3573c9a986a3f9c594539f4ab511d57bb3ce9 # v4 + with: + dotnet-version: ${{ env.DOTNET_VERSION }} + + - name: NativeAOT prerequisites + run: sudo apt-get install -y clang zlib1g-dev + + - name: Wait for Chroma + run: for i in $(seq 1 60); do curl -sf http://localhost:8000/api/v2/heartbeat && break; sleep 1; done + + - name: Publish with trimming + run: dotnet publish Samples/ChromaDB.Client.TrimmingTest -c ${{ env.CONFIGURATION }} -r linux-x64 -o trimmed -p:TreatWarningsAsErrors=true + + - name: Run with trimming + run: ./trimmed/ChromaDB.Client.TrimmingTest http://localhost:8000 --lists + + - name: Publish with NativeAOT + run: dotnet publish Samples/ChromaDB.Client.TrimmingTest -c ${{ env.CONFIGURATION }} -r linux-x64 -o aot -p:TrimmingTestAot=true -p:TreatWarningsAsErrors=true + + - name: Run with NativeAOT + run: ./aot/ChromaDB.Client.TrimmingTest http://localhost:8000 --lists + + framework: + # The tests whose outcome depends on the runtime, on .NET Framework 4.8, where the client runs its net462 build and + # the runtime formats and parses numbers in its own way: ChromaDB.Client.FrameworkTests, with no server. + runs-on: windows-latest + name: .NET Framework + + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + + - name: Setup .NET + uses: actions/setup-dotnet@67a3573c9a986a3f9c594539f4ab511d57bb3ce9 # v4 + with: + dotnet-version: ${{ env.DOTNET_VERSION }} + + - name: Test on .NET Framework 4.8 + run: dotnet test ChromaDB.Client.FrameworkTests -c ${{ env.CONFIGURATION }} --verbosity normal + publish: - needs: ci - if: ${{ github.repository == 'ssone95/ChromaDB.Client' && (github.ref == 'refs/heads/master' || startsWith(github.ref, 'refs/tags/v')) && github.event_name != 'schedule' }} + needs: [ci, trimming, framework] + # Publishes the version tags: with the NUGET_API_KEY secret in the original repository, and with NuGet trusted + # publishing where the NUGET_USER variable is set. The original repository publishes the default branch too; elsewhere + # its pushes leave the packages as artifacts of the run. + if: ${{ github.event_name != 'schedule' && (startsWith(github.ref, 'refs/tags/v') && (github.repository == 'ssone95/ChromaDB.Client' || vars.NUGET_USER != '') || github.repository == 'ssone95/ChromaDB.Client' && github.ref == format('refs/heads/{0}', github.event.repository.default_branch)) }} runs-on: ubuntu-latest name: Publish + environment: nuget + permissions: + attestations: write + contents: read + id-token: write steps: - name: Setup .NET - uses: actions/setup-dotnet@v4 + uses: actions/setup-dotnet@67a3573c9a986a3f9c594539f4ab511d57bb3ce9 # v4 with: dotnet-version: ${{ env.DOTNET_VERSION }} - name: Download - uses: actions/download-artifact@v4 + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 with: name: packages + - name: Attest build provenance + if: ${{ startsWith(github.ref, 'refs/tags/v') }} + uses: actions/attest-build-provenance@4d101475d8b20a2381f78447822ac1eab6504dd8 # v4.2.2 + with: + subject-path: '**/*.nupkg' + + - name: NuGet login + if: ${{ vars.NUGET_USER != '' }} + id: login + uses: NuGet/login@8d196754b4036150537f80ac539e15c2f1028841 # v1 + with: + user: ${{ vars.NUGET_USER }} + - name: Publish to NuGet.org - run: dotnet nuget push '**/*.nupkg' --api-key ${{ secrets.NUGET_API_KEY }} --source https://api.nuget.org/v3/index.json + run: dotnet nuget push '**/*.nupkg' --api-key ${{ steps.login.outputs.NUGET_API_KEY || secrets.NUGET_API_KEY }} --source https://api.nuget.org/v3/index.json --skip-duplicate diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml new file mode 100644 index 0000000..7043e27 --- /dev/null +++ b/.github/workflows/codeql.yml @@ -0,0 +1,36 @@ +name: CodeQL + +on: + push: + branches: + - main + pull_request: + schedule: + - cron: '17 5 * * 1' + +permissions: + contents: read + +jobs: + analyze: + name: Analyze C# + runs-on: ubuntu-latest + permissions: + actions: read + contents: read + security-events: write + + steps: + - name: Checkout + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + + - name: Initialize CodeQL + uses: github/codeql-action/init@2892aa5e19bbd11bc0cff5427e3b750a04d9e3c2 # v4.38.2 + with: + languages: csharp + build-mode: none + + - name: Analyze + uses: github/codeql-action/analyze@2892aa5e19bbd11bc0cff5427e3b750a04d9e3c2 # v4.38.2 + with: + category: /language:csharp diff --git a/.github/workflows/release-assets.yml b/.github/workflows/release-assets.yml new file mode 100644 index 0000000..02500e6 --- /dev/null +++ b/.github/workflows/release-assets.yml @@ -0,0 +1,100 @@ +name: Release assets + +# Attaches to each GitHub release the packages that the CI built from its tag, their Sigstore signatures and the build +# provenance that the CI attested, after checking that nuget.org serves the same packages. +on: + release: + types: + - published + workflow_dispatch: + inputs: + tag: + description: Release tag, like v2.10.0 + required: true + +permissions: + contents: read + +jobs: + assets: + name: Packages, signatures and provenance + runs-on: ubuntu-latest + permissions: + actions: read + attestations: read + contents: write + id-token: write + env: + TAG: ${{ github.event.release.tag_name || inputs.tag }} + GH_TOKEN: ${{ github.token }} + + steps: + - name: Install Cosign + uses: sigstore/cosign-installer@6f9f17788090df1f26f669e9d70d6ae9567deba6 # v4.1.2 + + - name: Download the packages from nuget.org + run: | + version="${TAG#v}" + mkdir nuget + for id in ChromaDotNet.Client ChromaDotNet.Client.DependencyInjection; do + lower=$(echo "$id" | tr '[:upper:]' '[:lower:]') + file="nuget/$id.$version.nupkg" + # nuget.org can take a few minutes to serve a new package. + for attempt in $(seq 1 60); do + if curl -fsSL -o "$file" "https://api.nuget.org/v3-flatcontainer/$lower/$version/$lower.$version.nupkg"; then + break + fi + # A failed transfer can leave a partial file. + rm -f "$file" + sleep 30 + done + test -f "$file" + done + + - name: Download the packages built by the CI + run: | + # The CI run of the tag publishes the packages, so it is about to finish once nuget.org serves them. + for attempt in $(seq 1 20); do + run=$(gh run list --repo "$GITHUB_REPOSITORY" --workflow ci.yml --branch "$TAG" --event push --status success --limit 1 --json databaseId --jq '.[].databaseId') + test -n "$run" && break + sleep 30 + done + test -n "$run" + gh run download "$run" --repo "$GITHUB_REPOSITORY" --name packages --dir ci + mkdir packages + find ci -name '*.nupkg' -exec mv {} packages/ \; + + - name: Check the provenance + run: | + for file in packages/*.nupkg; do + gh attestation verify "$file" --repo "$GITHUB_REPOSITORY" --signer-workflow "$GITHUB_REPOSITORY/.github/workflows/ci.yml" --source-ref "refs/tags/$TAG" --format json --jq '.[].attestation.bundle' >> bundles.jsonl + done + sort -u bundles.jsonl > "packages/provenance-$TAG.intoto.jsonl" + + - name: Compare with nuget.org + shell: python + run: | + # nuget.org adds its repository signature, .signature.p7s, to each package: every other file must be the same. + import glob, hashlib, os, sys, zipfile + + def files(path): + with zipfile.ZipFile(path) as package: + return {entry.filename: hashlib.sha256(package.read(entry)).hexdigest() for entry in package.infolist()} + + names = sorted(os.path.basename(path) for path in glob.glob('nuget/*.nupkg')) + if names != sorted(os.path.basename(path) for path in glob.glob('packages/*.nupkg')): + sys.exit('The CI built other packages than the ones on nuget.org.') + for name in names: + published = files(os.path.join('nuget', name)) + published.pop('.signature.p7s', None) + if published != files(os.path.join('packages', name)): + sys.exit(f'{name} on nuget.org differs from the package built by the CI.') + + - name: Sign + run: | + for file in packages/*.nupkg; do + cosign sign-blob --yes --bundle "$file.sigstore.json" "$file" + done + + - name: Upload to the release + run: gh release upload "$TAG" packages/* --clobber --repo "$GITHUB_REPOSITORY" diff --git a/.github/workflows/scorecard.yml b/.github/workflows/scorecard.yml new file mode 100644 index 0000000..08fbf50 --- /dev/null +++ b/.github/workflows/scorecard.yml @@ -0,0 +1,44 @@ +name: Scorecard + +on: + branch_protection_rule: + schedule: + - cron: '30 4 * * 1' + push: + branches: + - main + +permissions: read-all + +jobs: + analysis: + name: Scorecard analysis + runs-on: ubuntu-latest + permissions: + security-events: write + id-token: write + + steps: + - name: Checkout + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + with: + persist-credentials: false + + - name: Run analysis + uses: ossf/scorecard-action@2d1146689b8cda280b9bc96326124645441f03bc # v2.4.4 + with: + results_file: results.sarif + results_format: sarif + publish_results: true + + - name: Upload artifact + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: SARIF file + path: results.sarif + retention-days: 5 + + - name: Upload to code scanning + uses: github/codeql-action/upload-sarif@2892aa5e19bbd11bc0cff5427e3b750a04d9e3c2 # v4.38.2 + with: + sarif_file: results.sarif diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..6ea02e7 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,37 @@ +# Contributing + +Issues and pull requests are welcome. + +## Build and test + +``` +dotnet build +dotnet test +``` + +`dotnet test` starts a Chroma container for each test fixture with Testcontainers, so Docker must be running. The variables that choose the Chroma version, the v1 API, a tenant and a database, or a server already running like Chroma Cloud are described in [Tests](#tests). `-p:ChromaClientTargetFramework=netstandard2.0` runs the tests against the .NET Standard 2.0 builds of the packages. + +## Pull requests + +- A fix comes with a test that fails without it. +- New functionality comes with tests in the automated test suite, which the CI runs on every pull request. +- A behavior that differs between Chroma versions is tested on the versions where it changes, and [docs/COMPATIBILITY.md](docs/COMPATIBILITY.md) names those versions. +- The public API is listed in `PublicAPI.Shipped.txt`, the API marked as shipped, and `PublicAPI.Unshipped.txt`, what was added or removed since; the version bump after a release moves it to `PublicAPI.Shipped.txt`. A new public member goes in `PublicAPI.Unshipped.txt`, otherwise the build warns with RS0016; the code fix of the analyzer adds it. +- Public types and members have XML documentation, which goes in the package. +- Versions follow semantic versioning: removing or changing public API needs a major version. + +The CI runs the tests against several Chroma versions, and publishes the app in `Samples/ChromaDB.Client.TrimmingTest` with trimming and with NativeAOT. Only the version tags are published to NuGet; every change merged into `main` builds the packages, which its CI run keeps as artifacts. + +## Tests + +`dotnet test` starts a Chroma container for each test fixture: `chromadb/chroma:0.6.3`, unless `CHROMA_IMAGE` names another image. `CHROMA_TEST_API_VERSION=v1` runs the tests with the v1 API. + +With `CHROMA_TEST_URI`, the tests run against a server that is already running, like Chroma Cloud, and take it for the latest Chroma unless `CHROMA_IMAGE` says otherwise: + +- `CHROMA_TEST_TOKEN` goes in `X-Chroma-Token`; +- `CHROMA_TEST_TENANT` and `CHROMA_TEST_DATABASE` are used as they are, not created; +- `CHROMA_TEST_MAX_BATCH_SIZE` is a batch limit lower than the one the server declares, like 300 on Chroma Cloud. + +Each fixture deletes the collections and the databases its requests created, and nothing else. The tests that reset the server, create or look up other tenants and databases, or query an id that does not exist are skipped. + +The property tests, in the classes whose names end in `PropertyTests`, check random cases with [FsCheck](https://github.com/fscheck/FsCheck) and need no Chroma. Each run takes new cases. A failure prints the case and its seed, like `(5123468595321266860,8907826574119387873)`: `FSCHECK_REPLAY` with that seed runs the same cases again. The CI sets one, so that its runs check the same cases. diff --git a/ChromaDB.Client.DependencyInjection/ChromaClientExtensions.cs b/ChromaDB.Client.DependencyInjection/ChromaClientExtensions.cs index 0c69b02..93ebabc 100644 --- a/ChromaDB.Client.DependencyInjection/ChromaClientExtensions.cs +++ b/ChromaDB.Client.DependencyInjection/ChromaClientExtensions.cs @@ -1,23 +1,175 @@ using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Http; +using Microsoft.Extensions.Options; namespace ChromaDB.Client.DependencyInjection; +/// +/// The registration of the ChromaClient for Microsoft.Extensions.DependencyInjection. The client is a singleton +/// and sends each request with the current handler of IHttpClientFactory, which the factory renews after its handler +/// lifetime, two minutes by default. +/// public static class ChromaClientExtensions { - public static void AddChromaClient(this IServiceCollection services, Func? configurationOptions = null) + /// + /// Registers a ChromaClient and its options as singletons. configurationOptions takes the default options, + /// for http://localhost:8000, and returns the options of the client. Returns the services, for more registrations. + /// + /// The services. + /// Takes the default options, for http://localhost:8000, and returns the options of the client; null for the default ones. + /// The services, for more registrations. + public static IServiceCollection AddChromaClient(this IServiceCollection services, Func? configurationOptions = null) + => AddChromaClient(services, configurationOptions, _ => { }); + + /// + /// Registers a ChromaClient and its options as singletons. configureHttpClient configures the HttpClient + /// of the client, like a resilience handler, a proxy or a timeout. Returns the services, for more registrations. + /// + /// The services. + /// Takes the default options, for http://localhost:8000, and returns the options of the client; null for the default ones. + /// Configures the HttpClient of the client, like a resilience handler, a proxy or a timeout. + /// The services, for more registrations. + public static IServiceCollection AddChromaClient(this IServiceCollection services, Func? configurationOptions, Action configureHttpClient) + { + configurationOptions ??= DefaultConfigurationOptions; + + ChromaConfigurationOptions options = new(); + options = configurationOptions(options); + + services.AddSingleton(options); + configureHttpClient(services.AddHttpClient(nameof(ChromaClient))); + services.AddSingleton(serviceProvider => serviceProvider.CreateChromaClient(options, nameof(ChromaClient))); + return services; + } + + /// + /// A client and its options under a key, for an application that talks to more than one server, tenant or database. + /// Both are registered as singletons. Returns the services, for more registrations. + /// + /// The services. + /// The key of the client and of its options. + /// Takes the default options, for http://localhost:8000, and returns the options of the client; null for the default ones. + /// The services, for more registrations. + public static IServiceCollection AddKeyedChromaClient(this IServiceCollection services, object? serviceKey, Func? configurationOptions = null) + => AddKeyedChromaClient(services, serviceKey, configurationOptions, _ => { }); + + /// + /// Registers a ChromaClient and its options as singletons under serviceKey. configureHttpClient + /// configures the HttpClient of the client, like a resilience handler, a proxy or a timeout. Returns the services, for + /// more registrations. + /// + /// The services. + /// The key of the client and of its options. + /// Takes the default options, for http://localhost:8000, and returns the options of the client; null for the default ones. + /// Configures the HttpClient of the client, like a resilience handler, a proxy or a timeout. + /// The services, for more registrations. + public static IServiceCollection AddKeyedChromaClient(this IServiceCollection services, object? serviceKey, Func? configurationOptions, Action configureHttpClient) { + // A null key would register the client without a key, next to the one of AddChromaClient. + if (serviceKey is null) + { + throw new ArgumentNullException(nameof(serviceKey)); + } configurationOptions ??= DefaultConfigurationOptions; ChromaConfigurationOptions options = new(); options = configurationOptions(options); - services.AddScoped(); - services.AddHttpClient(o => + // The type with the text, so that keys like 1 and "1" get an HttpClient each. + var httpClientName = $"{nameof(ChromaClient)}:{serviceKey.GetType().FullName}:{serviceKey}"; + services.AddKeyedSingleton(serviceKey, options); + configureHttpClient(services.AddHttpClient(httpClientName)); + services.AddKeyedSingleton(serviceKey, (serviceProvider, _) => serviceProvider.CreateChromaClient(options, httpClientName)); + return services; + } + + /// + /// Registers a ChromaClient and its options as singletons, with options that configurationOptions makes from the + /// services when the client or the options are first resolved, like from IConfiguration or IOptions of the + /// application, also registered after this call. configureHttpClient configures the HttpClient of the client, like a + /// resilience handler, a proxy or a timeout. Returns the services, for more registrations. + /// + /// The services. + /// Makes the options of the client from the services. + /// Configures the HttpClient of the client; null for none. + /// The services, for more registrations. + public static IServiceCollection AddChromaClientFromServices(this IServiceCollection services, Func configurationOptions, Action? configureHttpClient = null) + { + if (configurationOptions is null) + { + throw new ArgumentNullException(nameof(configurationOptions)); + } + services.AddSingleton(serviceProvider => MadeOptions(configurationOptions, serviceProvider)); + var httpClientBuilder = services.AddHttpClient(nameof(ChromaClient)); + configureHttpClient?.Invoke(httpClientBuilder); + services.AddSingleton(serviceProvider => serviceProvider.CreateChromaClient(serviceProvider.GetRequiredService(), nameof(ChromaClient))); + return services; + } + + /// + /// As AddChromaClientFromServices, for a client and its options under a key, for an application that talks to more than + /// one server, tenant or database. Returns the services, for more registrations. + /// + /// The services. + /// The key of the client and of its options. + /// Makes the options of the client from the services. + /// Configures the HttpClient of the client; null for none. + /// The services, for more registrations. + public static IServiceCollection AddKeyedChromaClientFromServices(this IServiceCollection services, object serviceKey, Func configurationOptions, Action? configureHttpClient = null) + { + // A null key would register the client without a key, next to the one of AddChromaClient. + if (serviceKey is null) + { + throw new ArgumentNullException(nameof(serviceKey)); + } + if (configurationOptions is null) + { + throw new ArgumentNullException(nameof(configurationOptions)); + } + // The type with the text, so that keys like 1 and "1" get an HttpClient each, as in AddKeyedChromaClient. + var httpClientName = $"{nameof(ChromaClient)}:{serviceKey.GetType().FullName}:{serviceKey}"; + services.AddKeyedSingleton(serviceKey, (serviceProvider, _) => MadeOptions(configurationOptions, serviceProvider)); + var httpClientBuilder = services.AddHttpClient(httpClientName); + configureHttpClient?.Invoke(httpClientBuilder); + services.AddKeyedSingleton(serviceKey, (serviceProvider, key) => serviceProvider.CreateChromaClient(serviceProvider.GetRequiredKeyedService(key), httpClientName)); + return services; + } + + private static ChromaConfigurationOptions MadeOptions(Func configurationOptions, IServiceProvider serviceProvider) + => configurationOptions(serviceProvider) ?? throw new InvalidOperationException("The function of the options of the ChromaClient returned null."); + + /// + /// A ChromaClient to keep, like a singleton of an application or of an integration, that sends each request with the + /// current handler of IHttpClientFactory for httpClientName: the factory renews it after its handler lifetime, two + /// minutes by default, so a change of the address of the server in the DNS is seen. The settings of that HttpClient, like + /// its timeout, are applied too. AddChromaClient and AddKeyedChromaClient create their clients with it. + /// + /// The services. + /// The options of the client. + /// The name of the HttpClient of IHttpClientFactory that sends the requests. + /// The client. + public static ChromaClient CreateChromaClient(this IServiceProvider services, ChromaConfigurationOptions options, string httpClientName) + => new(options, CreateHttpClient(services, httpClientName)); + + // The client keeps its HttpClient, which sends each request with the current handler of the factory, on every target, also + // .NET Framework. The settings of the HttpClient itself, like ConfigureHttpClient, are applied here. + private static HttpClient CreateHttpClient(IServiceProvider serviceProvider, string name) + { + var httpClient = new HttpClient(new CurrentHandler(serviceProvider.GetRequiredService(), name)); + foreach (var action in serviceProvider.GetRequiredService>().Get(name).HttpClientActions) { - o.BaseAddress = options.Uri; - }); + action(httpClient); + } + return httpClient; + } + + private sealed class CurrentHandler(IHttpMessageHandlerFactory factory, string name) : HttpMessageHandler + { + // The factory owns the handler, so the invoker does not dispose it. + protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + => new HttpMessageInvoker(factory.CreateHandler(name), disposeHandler: false).SendAsync(request, cancellationToken); } private static ChromaConfigurationOptions DefaultConfigurationOptions(ChromaConfigurationOptions? options = null) => options ?? new ChromaConfigurationOptions(); -} \ No newline at end of file +} diff --git a/ChromaDB.Client.DependencyInjection/ChromaDB.Client.DependencyInjection.csproj b/ChromaDB.Client.DependencyInjection/ChromaDB.Client.DependencyInjection.csproj index 1d47e71..5817d01 100644 --- a/ChromaDB.Client.DependencyInjection/ChromaDB.Client.DependencyInjection.csproj +++ b/ChromaDB.Client.DependencyInjection/ChromaDB.Client.DependencyInjection.csproj @@ -5,20 +5,42 @@ enable enable true - 1.0.1 + + true + 2.18.0 ChromaDB.Client.DependencyInjection - .NET SDK for Chroma database - chroma chromadb vector database + + $(PackageIdPrefix).Client.DependencyInjection + ssone95, cincuranet, ChromaDotNet contributors + Dependency injection for ChromaDotNet.Client, the .NET client for Chroma and Chroma Cloud: AddChromaClient, AddKeyedChromaClient and CreateChromaClient. Tested with Chroma 0.4.10 to 1.5.9. Community project, not affiliated with Chroma. + chroma chromadb vector-database chroma-cloud ai embeddings rag README.md + + icon.png MIT git https://github.com/ssone95/ChromaDB.Client https://github.com/ssone95/ChromaDB.Client + + https://github.com/$(GITHUB_REPOSITORY) + https://github.com/$(GITHUB_REPOSITORY) true + + true + $(NoWarn);RS0026;RS0027 snupkg + + With ChromaDotNet.Client $(VersionPrefix). Release notes: $(PackageProjectUrl)/releases/tag/v$(VersionPrefix). + + + + + + diff --git a/ChromaDB.Client.DependencyInjection/PublicAPI.Shipped.txt b/ChromaDB.Client.DependencyInjection/PublicAPI.Shipped.txt new file mode 100644 index 0000000..49fda8d --- /dev/null +++ b/ChromaDB.Client.DependencyInjection/PublicAPI.Shipped.txt @@ -0,0 +1,7 @@ +#nullable enable +ChromaDB.Client.DependencyInjection.ChromaClientExtensions +static ChromaDB.Client.DependencyInjection.ChromaClientExtensions.AddChromaClient(this Microsoft.Extensions.DependencyInjection.IServiceCollection! services, System.Func? configurationOptions = null) -> Microsoft.Extensions.DependencyInjection.IServiceCollection! +static ChromaDB.Client.DependencyInjection.ChromaClientExtensions.AddChromaClient(this Microsoft.Extensions.DependencyInjection.IServiceCollection! services, System.Func? configurationOptions, System.Action! configureHttpClient) -> Microsoft.Extensions.DependencyInjection.IServiceCollection! +static ChromaDB.Client.DependencyInjection.ChromaClientExtensions.AddKeyedChromaClient(this Microsoft.Extensions.DependencyInjection.IServiceCollection! services, object? serviceKey, System.Func? configurationOptions = null) -> Microsoft.Extensions.DependencyInjection.IServiceCollection! +static ChromaDB.Client.DependencyInjection.ChromaClientExtensions.AddKeyedChromaClient(this Microsoft.Extensions.DependencyInjection.IServiceCollection! services, object? serviceKey, System.Func? configurationOptions, System.Action! configureHttpClient) -> Microsoft.Extensions.DependencyInjection.IServiceCollection! +static ChromaDB.Client.DependencyInjection.ChromaClientExtensions.CreateChromaClient(this System.IServiceProvider! services, ChromaDB.Client.ChromaConfigurationOptions! options, string! httpClientName) -> ChromaDB.Client.ChromaClient! diff --git a/ChromaDB.Client.DependencyInjection/PublicAPI.Unshipped.txt b/ChromaDB.Client.DependencyInjection/PublicAPI.Unshipped.txt new file mode 100644 index 0000000..c54fcbc --- /dev/null +++ b/ChromaDB.Client.DependencyInjection/PublicAPI.Unshipped.txt @@ -0,0 +1,3 @@ +#nullable enable +static ChromaDB.Client.DependencyInjection.ChromaClientExtensions.AddChromaClientFromServices(this Microsoft.Extensions.DependencyInjection.IServiceCollection! services, System.Func! configurationOptions, System.Action? configureHttpClient = null) -> Microsoft.Extensions.DependencyInjection.IServiceCollection! +static ChromaDB.Client.DependencyInjection.ChromaClientExtensions.AddKeyedChromaClientFromServices(this Microsoft.Extensions.DependencyInjection.IServiceCollection! services, object! serviceKey, System.Func! configurationOptions, System.Action? configureHttpClient = null) -> Microsoft.Extensions.DependencyInjection.IServiceCollection! diff --git a/ChromaDB.Client.FrameworkTests/ChromaDB.Client.FrameworkTests.csproj b/ChromaDB.Client.FrameworkTests/ChromaDB.Client.FrameworkTests.csproj new file mode 100644 index 0000000..0ecf49e --- /dev/null +++ b/ChromaDB.Client.FrameworkTests/ChromaDB.Client.FrameworkTests.csproj @@ -0,0 +1,27 @@ + + + + net48 + latest + enable + enable + false + + + + + + + + + + + + + + + + + diff --git a/ChromaDB.Client.Tests/ApiV2CompleteTests.cs b/ChromaDB.Client.Tests/ApiV2CompleteTests.cs new file mode 100644 index 0000000..9e76574 --- /dev/null +++ b/ChromaDB.Client.Tests/ApiV2CompleteTests.cs @@ -0,0 +1,352 @@ +using System.Net; +using ChromaDB.Client.Models; +using NUnit.Framework; + +namespace ChromaDB.Client.Tests; + +// The operations added to cover the v2 API, against each version of Chroma, and against Chroma Cloud with CHROMA_TEST_URI. +[TestFixture] +public class ApiV2CompleteTests : ChromaTestsBase +{ + // Chroma Cloud rejects a regex pattern beyond 256 bytes in UTF-8, the default quota of a tenant: the client says it with the pattern, + // its bytes and the limit. + [TestCase("a", 257, 257)] + [TestCase("é", 129, 258)] + public async Task RegexPatternQuotaOfChromaCloud(string character, int count, int bytes) + { + Assume.That(ChromaCloud, Is.True, "Only Chroma Cloud has the quota of regex patterns."); + var client = new ChromaClient(BaseConfigurationOptions, HttpClient); + var collectionClient = client.GetCollectionClient(await client.CreateCollectionAsync($"collection{Random.Shared.Next()}")); + var pattern = string.Concat(Enumerable.Repeat(character, count)); + var ex = Assert.ThrowsAsync(() => collectionClient.GetAsync(whereDocument: ChromaWhereDocumentOperator.Regex(pattern))); + Assert.That(ex!.Message, Does.StartWith($"Chroma Cloud rejects the pattern \"{pattern}\" of $regex, of {bytes} bytes in UTF-8: the quota of the tenant for a regex pattern is 256 bytes")); + Assert.That(ex.StatusCode, Is.EqualTo(HttpStatusCode.UnprocessableEntity)); + } + + [Test] + public async Task Healthcheck() + { + var client = new ChromaClient(BaseConfigurationOptions, HttpClient); + if (!HealthcheckSupported) + { + await Assert.ThatAsync(() => client.HealthcheckAsync(), Throws.InstanceOf().With.Property(nameof(ChromaException.StatusCode)).EqualTo(HttpStatusCode.NotFound)); + return; + } + var result = await client.HealthcheckAsync(); + Assert.That(result.IsExecutorReady, Is.True); + } + + [Test] + public async Task RegexAndNotRegex() + { + var collection = await Init(); + if (!RegexSupported) + { + await Assert.ThatAsync(() => collection.GetAsync(whereDocument: ChromaWhereDocumentOperator.Regex("^apple")), Throws.InstanceOf()); + return; + } + Assert.That((await collection.GetAsync(whereDocument: ChromaWhereDocumentOperator.Regex("^apple"))).Select(x => x.Id), Is.EquivalentTo(new[] { "a", "d" })); + Assert.That((await collection.GetAsync(whereDocument: ChromaWhereDocumentOperator.NotRegex("^apple"))).Select(x => x.Id), Is.EquivalentTo(new[] { "b", "c" })); + } + + // KD-56: on Chroma Cloud a pattern that takes an empty text, and a $contains of fewer than 3 characters, throw before the request, also + // in a delete, which would delete every record. On every server Regex("(?s).") finds the records with a document that is not empty, + // also of only a new line, NotRegex("(?s).") the others, and a $regex with a short text the documents that contain it. + [Test] + public async Task DocumentFiltersThatChromaCloudGetsWrong() + { + Assume.That(RegexSupported, Is.True, "Chroma 1.0.11 and earlier have no $regex."); + var client = new ChromaClient(BaseConfigurationOptions, HttpClient); + var collection = client.GetCollectionClient(await client.CreateCollectionAsync($"collection{Random.Shared.Next()}")); + await collection.AddAsync(new ChromaRecords(["none", "empty", "text", "line", "abc"]) + { + Embeddings = [new([1f, 0f]), new([0f, 1f]), new([1f, 1f]), new([1f, 2f]), new([2f, 1f])], + Documents = [null, "", "x", "\n", "abc"], + }); + var empty = ChromaWhereDocumentOperator.Regex("^$"); + var shortText = ChromaWhereDocumentOperator.Contains("ab"); + if (ChromaCloud) + { + Assert.That(() => collection.GetAsync(whereDocument: empty), Throws.ArgumentException.With.Message.Contains("takes an empty text")); + Assert.That(() => collection.GetAsync(whereDocument: ChromaWhereDocumentOperator.Regex("^[[:alpha:]]*$")), Throws.ArgumentException); + Assert.That(() => collection.GetAsync(whereDocument: shortText), Throws.ArgumentException.With.Message.Contains("fewer than 3 characters")); + Assert.That(() => collection.DeleteAsync(new ChromaDelete { WhereDocument = empty }), Throws.ArgumentException); + Assert.That(() => collection.DeleteAsync(new ChromaDelete { WhereDocument = ChromaWhereDocumentOperator.NotContains("") }), Throws.ArgumentException); + Assert.That(await collection.CountAsync(), Is.EqualTo(5)); + } + else + { + Assert.That((await collection.GetAsync(whereDocument: empty, include: ChromaGetInclude.None)).Select(x => x.Id), Is.EqualTo(new[] { "empty" })); + Assert.That((await collection.GetAsync(whereDocument: shortText, include: ChromaGetInclude.None)).Select(x => x.Id), Is.EqualTo(new[] { "abc" })); + } + Assert.That((await collection.GetAsync(whereDocument: ChromaWhereDocumentOperator.Regex("(?s)."), include: ChromaGetInclude.None)).Select(x => x.Id), Is.EquivalentTo(new[] { "text", "line", "abc" })); + Assert.That((await collection.GetAsync(whereDocument: ChromaWhereDocumentOperator.NotRegex("(?s)."), include: ChromaGetInclude.None)).Select(x => x.Id), Is.EquivalentTo(new[] { "none", "empty" })); + Assert.That((await collection.GetAsync(whereDocument: ChromaWhereDocumentOperator.Regex("ab"), include: ChromaGetInclude.None)).Select(x => x.Id), Is.EqualTo(new[] { "abc" })); + Assert.That((await collection.GetAsync(whereDocument: ChromaWhereDocumentOperator.Contains("abc"), include: ChromaGetInclude.None)).Select(x => x.Id), Is.EqualTo(new[] { "abc" })); + } + + // On the servers that ignore the limit, nothing is deleted: without the check they would delete both apples. + [Test] + public async Task DeleteWithLimit() + { + var collection = await Init(); + var delete = new ChromaDelete { WhereDocument = ChromaWhereDocumentOperator.Contains("apple"), Limit = 1 }; + if (!DeleteLimitSupported) + { + await Assert.ThatAsync(() => collection.DeleteAsync(delete), Throws.InstanceOf().With.Message.Contains("Chroma 1.5.3")); + Assert.That(await collection.CountAsync(), Is.EqualTo(4)); + return; + } + Assert.That(await collection.DeleteAsync(delete), Is.EqualTo(1)); + Assert.That(await collection.CountAsync(), Is.EqualTo(3)); + } + + [Test] + public async Task DeleteByFilterOnly() + { + var collection = await Init(); + var deleted = await collection.DeleteAsync(new ChromaDelete { WhereDocument = ChromaWhereDocumentOperator.Contains("apple") }); + Assert.That(deleted, DeleteLimitSupported ? Is.EqualTo(2) : Is.Null); + Assert.That((await collection.GetAsync()).Select(x => x.Id), Is.EquivalentTo(new[] { "b", "c" })); + } + + // A single server indexes at once; Chroma Cloud may leave out of IndexOnly the records not indexed yet. + [Test] + public async Task CountAtAReadLevel() + { + var collection = await Init(); + Assert.That(await collection.CountAsync(ChromaReadLevel.IndexAndWal), Is.EqualTo(4)); + Assert.That(await collection.CountAsync(ChromaReadLevel.IndexOnly), ChromaCloud ? Is.InRange(0, 4) : Is.EqualTo(4)); + } + + // A single server has an HNSW index, Chroma Cloud a SPANN one. + [Test] + public async Task ModifyConfiguration() + { + var collection = await Init(); + var update = ChromaCloud + ? new ChromaCollectionConfigurationUpdate { Spann = new() { SearchNprobe = 32 } } + : new ChromaCollectionConfigurationUpdate { Hnsw = new() { EfSearch = 200 } }; + if (!NewConfigurationApplied) + { + await Assert.ThatAsync(() => collection.ModifyConfigurationAsync(update), Throws.InstanceOf().With.Message.Contains("Chroma 1.0.6")); + return; + } + await collection.ModifyConfigurationAsync(update); + var configuration = (await new ChromaClient(BaseConfigurationOptions, HttpClient).GetCollectionAsync(collection.Collection.Name)).ConfigurationJson!.Value; + Assert.That(ChromaCloud + ? configuration.GetProperty("spann").GetProperty("search_nprobe").GetInt32() + : configuration.GetProperty("hnsw").GetProperty("ef_search").GetInt32(), Is.EqualTo(ChromaCloud ? 32 : 200)); + } + + // Fork, its count and the indexing status exist only on Chroma Cloud: a single server answers with an error. + [Test] + public async Task ForkAndIndexingStatus() + { + var collection = await Init(); + if (!ChromaCloud) + { + await Assert.ThatAsync(() => collection.ForkAsync($"collection{Random.Shared.Next()}"), Throws.InstanceOf()); + await Assert.ThatAsync(() => collection.ForkCountAsync(), Throws.InstanceOf()); + await Assert.ThatAsync(() => collection.GetIndexingStatusAsync(), Throws.InstanceOf()); + return; + } + var fork = await collection.ForkAsync($"collection{Random.Shared.Next()}"); + Assert.That(await new ChromaClient(BaseConfigurationOptions, HttpClient).GetCollectionClient(fork).CountAsync(), Is.EqualTo(4)); + Assert.That(await collection.ForkCountAsync(), Is.EqualTo(1)); + Assert.That((await collection.GetIndexingStatusAsync()).TotalOps, Is.GreaterThanOrEqualTo(4)); + } + + // On Chroma Cloud the tenant and the database of the API key; a single server, also one already running, always answers + // with the default ones. + [Test] + public async Task TenantAndDatabaseFromIdentity() + { + Assume.That(ApiVersion, Is.EqualTo(ChromaApiVersion.V2), "The v1 API has no auth/identity."); + var options = new ChromaConfigurationOptions(uri: BaseConfigurationOptions.Uri.ToString()).WithApiVersion(ApiVersion); + options = BaseConfigurationOptions.ChromaToken is { } token ? options.WithChromaToken(token) : options; + var client = await new ChromaClient(options, HttpClient).WithTenantAndDatabaseFromIdentityAsync(); + Assert.That(client.Options.Tenant, Is.EqualTo(ChromaCloud ? BaseConfigurationOptions.Tenant ?? "default_tenant" : "default_tenant")); + Assert.That(client.Options.Database, Is.EqualTo(ChromaCloud ? BaseConfigurationOptions.Database ?? "default_database" : "default_database")); + Assert.That(await client.ListCollectionsAsync(), Is.Not.Null); + } + + // The Search API exists only on Chroma Cloud: a single server answers with an error (501 from Chroma 1.x). + [Test] + public async Task Search() + { + var collection = await Init(); + var search = new ChromaSearch + { + WhereDocument = ChromaWhereDocumentOperator.Contains("apple"), + Rank = ChromaRank.Knn(new([1f, 0f])), + Limit = 3, + Select = [ChromaSearchKeys.Document, ChromaSearchKeys.Score], + }; + if (!ChromaCloud) + { + await Assert.ThatAsync(() => collection.SearchAsync(search), Throws.InstanceOf()); + return; + } + var apples = await collection.SearchAsync(search); + Assert.That(apples.Select(x => (x.Id, x.Document, x.Score)), Is.EqualTo(new[] { ("a", "apple pie", (float?)0f), ("d", "apple juice", (float?)0.5f) })); + var rrf = await collection.SearchAsync(new ChromaSearch + { + Rank = ChromaRank.Rrf([ChromaRank.Knn(new([1f, 0f]), returnRank: true), ChromaRank.Knn(new([0f, 1f]), returnRank: true)]), + Limit = 4, + Select = [ChromaSearchKeys.Score], + }); + Assert.That(rrf.Select(x => x.Score), Is.Ordered.Ascending); + Assert.That(rrf, Has.Count.EqualTo(4)); + var two = await collection.SearchAsync([new ChromaSearch { Ids = ["b", "c"], Select = [ChromaSearchKeys.Document] }, new ChromaSearch { Rank = ChromaRank.Knn(new([0f, 1f])), Limit = 1 }]); + Assert.That(two[0].Select(x => x.Document), Is.EquivalentTo(new[] { "banana split", "cherry tart" })); + Assert.That(two[1].Single().Id, Is.EqualTo("b")); + } + + // Sparse vector indexes and sparse vectors in metadata exist only on Chroma Cloud. A single server rejects the schema from Chroma 1.3.0, + // and the earlier ones create the collection without it: then the client deletes it. Sparse vectors in metadata fail on a single + // server; Chroma 0.x would drop them, so the client rejects them first. + [Test] + public async Task SchemaAndSparseVectors() + { + var client = new ChromaClient(BaseConfigurationOptions.WithMetadataValues(ChromaMetadataValues.Exact), HttpClient); + var name = $"collection{Random.Shared.Next()}"; + var definition = new ChromaCollectionDefinition(name) + { + Schema = new ChromaCollectionSchema().WithSparseVectorIndex("doc_bm25", ChromaSearchKeys.Document, bm25: true, ChromaEmbeddingFunctionReference.ChromaBm25()), + }; + var records = new ChromaRecords(["a", "b"]) + { + Embeddings = [new([1f, 0f]), new([0f, 1f])], + Documents = ["apple pie", "banana split"], + Metadatas = [new Dictionary { ["doc_bm25"] = new ChromaSparseVector([1, 5], [0.5f, 0.7f]) }, new Dictionary { ["doc_bm25"] = new ChromaSparseVector([2], [0.9f]) }], + }; + if (!ChromaCloud) + { + await Assert.ThatAsync(() => client.CreateCollectionAsync(definition), Throws.InstanceOf()); + Assert.That(await client.CollectionExistsAsync(name), Is.False); + if (!RejectedSparseVectorsBreakTheServer) + { + var plain = client.GetCollectionClient(await client.CreateCollectionAsync($"collection{Random.Shared.Next()}")); + await Assert.ThatAsync(() => plain.AddAsync(records), Throws.InstanceOf()); + } + return; + } + var collection = await client.CreateCollectionAsync(definition); + var index = collection.SparseVectorIndexes.Single(); + Assert.That((index.Key, index.SourceKey, index.Bm25, index.EmbeddingFunction), Is.EqualTo(("doc_bm25", "#document", true, "chroma_bm25"))); + var collectionClient = client.GetCollectionClient(collection); + await collectionClient.AddAsync(records); + var stored = (ChromaSparseVector)(await collectionClient.GetAsync("a", include: ChromaGetInclude.Metadatas))!.Metadata!["doc_bm25"]; + Assert.That((stored.Indices, stored.Values), Is.EqualTo(((IReadOnlyList)[1, 5], (IReadOnlyList)[0.5f, 0.7f]))); + var found = await collectionClient.SearchAsync(new ChromaSearch { Rank = ChromaRank.SparseKnn(new ChromaSparseVector([1, 5], [1f, 1f]), "doc_bm25"), Limit = 1 }); + Assert.That(found.Single().Id, Is.EqualTo("a")); + } + + // The options of Chroma Cloud from a connection string, as the settings of an application keep them. + [Test] + public async Task ConnectionStringOfChromaCloud() + { + Assume.That(ChromaCloud, Is.True, "A connection string with a token, a tenant and a database is for Chroma Cloud."); + var options = ChromaConfigurationOptions.FromConnectionString( + $"Endpoint={BaseConfigurationOptions.Uri};Token={BaseConfigurationOptions.ChromaToken};Tenant={BaseConfigurationOptions.Tenant};Database={BaseConfigurationOptions.Database}"); + using var client = new ChromaClient(options); + var name = $"collection{Random.Shared.Next()}"; + // Its own HttpClient, which the fixture does not see: the collection is deleted here. + var collection = await client.CreateCollectionAsync(name); + try + { + Assert.That((collection.Tenant, collection.Database), Is.EqualTo((BaseConfigurationOptions.Tenant, BaseConfigurationOptions.Database))); + Assert.That(await client.CollectionExistsAsync(name), Is.True); + } + finally + { + await client.DeleteCollectionAsync(name); + } + } + + // Hybrid search on Chroma Cloud: the client computes the BM25 vectors of the documents and of the text queries from the schema, + // searched alone and fused with the dense vectors. + [Test] + public async Task HybridSearchWithBm25() + { + Assume.That(ChromaCloud, Is.True, "Only Chroma Cloud has sparse vector indexes."); + var bm25 = new ChromaBm25(k: 1.5); + var client = new ChromaClient(BaseConfigurationOptions, HttpClient); + // The space goes in the schema: Chroma Cloud rejects a configuration together with a schema. + var collection = client.GetCollectionClient(await client.CreateCollectionAsync(new ChromaCollectionDefinition($"collection{Random.Shared.Next()}") + { + Configuration = new() { Space = ChromaSpace.Cosine }, + Schema = new ChromaCollectionSchema().WithSparseVectorIndex("doc_bm25", ChromaSearchKeys.Document, bm25: true, bm25.Reference), + })); + Assert.That(collection.Collection.Space, Is.EqualTo(ChromaSpace.Cosine)); + string[] documents = ["apple pie with cinnamon", "banana split with chocolate", "cherry tart", "apple juice and apple cider"]; + await collection.AddAsync(new ChromaRecords(["a", "b", "c", "d"]) + { + Embeddings = [new([1f, 0f]), new([0f, 1f]), new([1f, 1f]), new([0.5f, 0.5f])], + Documents = [.. documents], + }); + var stored = (ChromaSparseVector)(await client.WithMetadataValues(ChromaMetadataValues.Exact).GetCollectionClient(collection.Collection).GetAsync("b"))!.Metadata!["doc_bm25"]; + Assert.That(stored.ToString(), Is.EqualTo(bm25.Embed(documents[1]).ToString())); + var keyword = await collection.SearchAsync(new ChromaSearch { Rank = ChromaRank.SparseKnn("apples", "doc_bm25"), Limit = 2 }); + Assert.That(keyword.Select(x => x.Id), Is.EquivalentTo(new[] { "a", "d" })); + var hybrid = await collection.SearchAsync(new ChromaSearch + { + Rank = ChromaRank.Rrf([ChromaRank.Knn(new([0f, 1f]), returnRank: true), ChromaRank.SparseKnn("banana", "doc_bm25", returnRank: true)]), + Limit = 1, + }); + Assert.That(hybrid.Single().Id, Is.EqualTo("b")); + } + + // Chroma Cloud takes a metadata value of at most 8,182 bytes, and a document of 16,384: a longer document goes without its copy, and + // its record, without metadata keys, comes back with an empty Metadata. A key of the schema beyond 36 bytes, which Chroma Cloud takes + // and then rejects on every write, stops before the request. + [Test] + public async Task QuotasOfChromaCloud() + { + Assume.That(ChromaCloud, Is.True, "Only Chroma Cloud has these quotas."); + var client = new ChromaClient(BaseConfigurationOptions.WithMetadataValues(ChromaMetadataValues.Exact), HttpClient); + var name = $"collection{Random.Shared.Next()}"; + var tooLong = new ChromaCollectionDefinition(name) { Schema = new ChromaCollectionSchema().WithIndex(ChromaSchemaIndex.StringInverted, new string('k', 37)) }; + Assert.That(() => client.CreateCollectionAsync(tooLong), Throws.ArgumentException); + Assert.That(await client.CollectionExistsAsync(name), Is.False); + var collection = client.GetCollectionClient(await client.CreateCollectionAsync(name)).WithDocumentCopyKey("text"); + var longText = new string('a', ChromaCloudQuotas.MaxMetadataValueBytes + 1); + await collection.AddAsync(new ChromaRecords(["short", "long"]) { Embeddings = [new([1f, 0f]), new([0f, 1f])], Documents = ["apple pie", longText] }); + var records = (await collection.GetAsync(include: ChromaGetInclude.Metadatas | ChromaGetInclude.Documents)).ToDictionary(x => x.Id); + Assert.That(records["short"].Metadata!["text"], Is.EqualTo("apple pie")); + Assert.That(records["long"].Metadata, Is.Not.Null.And.Empty); + Assert.That(records["long"].Document, Is.EqualTo(longText)); + } + + // With HybridRrf the records without the terms of the text get no points from it: they keep the order of the dense search. + [Test] + public async Task HybridRrfOnChromaCloud() + { + Assume.That(ChromaCloud, Is.True, "Only Chroma Cloud has sparse vector indexes."); + var client = new ChromaClient(BaseConfigurationOptions, HttpClient); + var collection = client.GetCollectionClient(await client.CreateCollectionAsync(new ChromaCollectionDefinition($"collection{Random.Shared.Next()}") + { + Configuration = new() { Space = ChromaSpace.Cosine }, + Schema = new ChromaCollectionSchema().WithSparseVectorIndex("doc_bm25", ChromaSearchKeys.Document, bm25: true, new ChromaBm25().Reference), + })); + await collection.AddAsync(new ChromaRecords(["a", "b", "c", "d"]) + { + Embeddings = [new([1f, 0f]), new([0f, 1f]), new([0.6f, 0.8f]), new([0.8f, 0.6f])], + Documents = ["apple pie", "banana split", "cherry tart", "date cake"], + }); + var bm25Key = collection.Collection.FindBm25Index(ChromaSearchKeys.Document)!.Key; + var results = await collection.SearchAsync(new ChromaSearch { Rank = ChromaRank.HybridRrf(new([0f, 1f]), "banana", bm25Key, limit: 4), Limit = 4, Select = [ChromaSearchKeys.Score] }); + Assert.That(results.Select(x => x.Id), Is.EqualTo(new[] { "b", "c", "d", "a" })); + Assert.That(results[1].Score!.Value, Is.EqualTo(1 / 61.0).Within(1e-6)); + } + + async Task Init() + { + var client = new ChromaClient(BaseConfigurationOptions, HttpClient); + var collection = client.GetCollectionClient(await client.CreateCollectionAsync($"collection{Random.Shared.Next()}")); + await collection.AddAsync(["a", "b", "c", "d"], embeddings: [new([1f, 0f]), new([0f, 1f]), new([1f, 1f]), new([0.5f, 0.5f])], + documents: ["apple pie", "banana split", "cherry tart", "apple juice"]); + return collection; + } +} diff --git a/ChromaDB.Client.Tests/ApiV2RequestsTests.cs b/ChromaDB.Client.Tests/ApiV2RequestsTests.cs new file mode 100644 index 0000000..a7df35f --- /dev/null +++ b/ChromaDB.Client.Tests/ApiV2RequestsTests.cs @@ -0,0 +1,347 @@ +using System.Net; +using System.Text.Json; +using ChromaDB.Client.Models; +using NUnit.Framework; + +namespace ChromaDB.Client.Tests; + +// The requests of the operations of the v2 API as the OpenAPI description of Chroma 1.5.9 defines them, against a fake server: +// some of them exist only on Chroma Cloud. +[TestFixture] +public class ApiV2RequestsTests +{ + static readonly Guid Id = Guid.Parse("11111111-2222-3333-4444-555555555555"); + const string CollectionPath = "/api/v2/tenants/default_tenant/databases/default_database/collections/11111111-2222-3333-4444-555555555555"; + const string OpenApiWithDeleteLimit = """{"components":{"schemas":{"DeleteCollectionRecordsPayload":{"allOf":[{"properties":{"ids":{},"limit":{}}}]}}}}"""; + const string OpenApiWithoutDeleteLimit = """{"components":{"schemas":{"DeleteCollectionRecordsPayload":{"allOf":[{"properties":{"ids":{}}}]}}}}"""; + + [Test] + public async Task Healthcheck() + { + var server = new FakeServer(_ => (HttpStatusCode.OK, """{"is_executor_ready":true,"is_log_client_ready":false}""")); + var result = await Client(server).HealthcheckAsync(); + Assert.That(server.Requests.Single().Line, Is.EqualTo("GET /api/v2/healthcheck")); + Assert.That(result.IsExecutorReady, Is.True); + Assert.That(result.IsLogClientReady, Is.False); + } + + [Test] + public async Task GetCollectionByCrn() + { + var server = new FakeServer(_ => (HttpStatusCode.OK, $$"""{"id":"{{Id}}","name":"c"}""")); + var result = await Client(server).GetCollectionByCrnAsync("tenant:database:c"); + Assert.That(Uri.UnescapeDataString(server.Requests.Single().Line), Is.EqualTo("GET /api/v2/collections/tenant:database:c")); + Assert.That(result.Id, Is.EqualTo(Id)); + } + + [Test] + public async Task UpdateTenant() + { + var server = new FakeServer(_ => (HttpStatusCode.OK, "{}")); + await Client(server).UpdateTenantAsync("t", "resource"); + Assert.That(server.Requests.Single().Line, Is.EqualTo("PATCH /api/v2/tenants/t")); + Assert.That(server.Requests.Single().Body.GetProperty("resource_name").GetString(), Is.EqualTo("resource")); + } + + [Test] + public async Task ResourceNameOfTheTenant() + { + var server = new FakeServer(_ => (HttpStatusCode.OK, """{"name":"t","resource_name":"resource"}""")); + Assert.That((await Client(server).GetTenantAsync("t")).ResourceName, Is.EqualTo("resource")); + } + + [Test] + public void RegexFilters() + { + Assert.That(ChromaWhereDocumentOperator.Regex("^a").ToString(), Is.EqualTo("""{"$regex":"^a"}""")); + Assert.That(ChromaWhereDocumentOperator.NotRegex("^a").ToString(), Is.EqualTo("""{"$not_regex":"^a"}""")); + Assert.That((ChromaWhereDocumentOperator.Regex("^a") | ChromaWhereDocumentOperator.Contains("b")).ToString(), Is.EqualTo("""{"$or":[{"$regex":"^a"},{"$contains":"b"}]}""")); + } + + [Test] + public async Task DeleteWithLimitAndFilters() + { + var server = new FakeServer(r => r.Path == "/openapi.json" ? (HttpStatusCode.OK, OpenApiWithDeleteLimit) : (HttpStatusCode.OK, """{"deleted":2}""")); + var deleted = await CollectionClient(server).DeleteAsync(new ChromaDelete { WhereDocument = ChromaWhereDocumentOperator.Contains("x"), Limit = 2 }); + Assert.That(deleted, Is.EqualTo(2)); + var delete = server.Requests.Single(x => x.Path.EndsWith("/delete")); + Assert.That(delete.Line, Is.EqualTo($"POST {CollectionPath}/delete")); + Assert.That(delete.Body.GetProperty("limit").GetInt32(), Is.EqualTo(2)); + Assert.That(delete.Body.GetProperty("where_document").GetProperty("$contains").GetString(), Is.EqualTo("x")); + Assert.That(delete.Body.TryGetProperty("ids", out _), Is.False); + } + + // Behind a proxy with a path, the base URI names the API, as https://proxy/chroma/api/v2/: the description is at the root of the + // server, next to it. + [Test] + public async Task DeleteWithLimitBehindAProxy() + { + var server = new FakeServer(r => r.Path == "/chroma/openapi.json" ? (HttpStatusCode.OK, OpenApiWithDeleteLimit) : r.Path.EndsWith("/delete") ? (HttpStatusCode.OK, """{"deleted":2}""") : (HttpStatusCode.NotFound, "{}")); + var deleted = await CollectionClient(server, new ChromaConfigurationOptions("http://localhost:8000/chroma/api/v2/")).DeleteAsync(new ChromaDelete { WhereDocument = ChromaWhereDocumentOperator.Contains("x"), Limit = 2 }); + Assert.That(deleted, Is.EqualTo(2)); + } + + // The servers that do not declare the limit ignore it and would delete every matching record: nothing is sent. + [TestCase(HttpStatusCode.OK, OpenApiWithoutDeleteLimit)] + [TestCase(HttpStatusCode.NotFound, "")] + public async Task DeleteWithLimitOnAServerThatIgnoresIt(HttpStatusCode status, string description) + { + var server = new FakeServer(r => r.Path == "/openapi.json" ? (status, description) : (HttpStatusCode.OK, "{}")); + await Assert.ThatAsync(() => CollectionClient(server).DeleteAsync(new ChromaDelete { Ids = ["a"], WhereDocument = ChromaWhereDocumentOperator.Contains("x"), Limit = 1 }), + Throws.InstanceOf().With.Message.Contains("Chroma 1.5.3")); + Assert.That(server.Requests.Any(x => x.Path.EndsWith("/delete")), Is.False); + } + + // The limit left after each batch goes in the next one, and the batches stop when it is used up. + [Test] + public async Task DeleteWithLimitAcrossBatches() + { + var server = new FakeServer(r => r.Path switch + { + "/openapi.json" => (HttpStatusCode.OK, OpenApiWithDeleteLimit), + "/api/v2/pre-flight-checks" => (HttpStatusCode.OK, """{"max_batch_size":2}"""), + _ => (HttpStatusCode.OK, $$"""{"deleted":{{Math.Min(r.Body.GetProperty("ids").GetArrayLength(), r.Body.GetProperty("limit").GetInt32())}}}"""), + }); + var client = CollectionClient(server, new ChromaConfigurationOptions("http://localhost:8000").WithBatchSplitting()); + var deleted = await client.DeleteAsync(new ChromaDelete { Ids = ["a", "b", "c", "d", "e"], WhereDocument = ChromaWhereDocumentOperator.Contains("x"), Limit = 3 }); + Assert.That(deleted, Is.EqualTo(3)); + var limits = server.Requests.Where(x => x.Path.EndsWith("/delete")).Select(x => x.Body.GetProperty("limit").GetInt32()); + Assert.That(limits, Is.EqualTo(new[] { 3, 1 })); + } + + // {} from Chroma 1.0.0 to 1.5.2, null from 0.x, and a list of ids from some older 0.x servers: the count is unknown. + [TestCase("{}")] + [TestCase("null")] + [TestCase("""["a"]""")] + public async Task DeleteWithoutTheCount(string response) + { + var server = new FakeServer(_ => (HttpStatusCode.OK, response)); + Assert.That(await CollectionClient(server).DeleteAsync(new ChromaDelete { Ids = ["a"] }), Is.Null); + } + + // What Chroma or its Python client reject: nothing is sent, not even the request for the OpenAPI description. + [Test] + public async Task DeleteThatChromaRejects() + { + var server = new FakeServer(_ => (HttpStatusCode.OK, OpenApiWithDeleteLimit)); + var client = CollectionClient(server); + var filter = ChromaWhereDocumentOperator.Contains("x"); + await Assert.ThatAsync(() => client.DeleteAsync(new ChromaDelete()), Throws.ArgumentException); + await Assert.ThatAsync(() => client.DeleteAsync(new ChromaDelete { Ids = [] }), Throws.ArgumentException); + await Assert.ThatAsync(() => client.DeleteAsync(new ChromaDelete { Ids = [], WhereDocument = filter }), Throws.ArgumentException); + await Assert.ThatAsync(() => client.DeleteAsync(new ChromaDelete { Ids = ["a"], Limit = 1 }), Throws.ArgumentException); + await Assert.ThatAsync(() => client.DeleteAsync(new ChromaDelete { WhereDocument = filter, Limit = -1 }), Throws.InstanceOf()); + Assert.That(server.Requests, Is.Empty); + } + + // Chroma answers {"deleted":0} to a limit of 0. + [Test] + public async Task DeleteWithLimitZero() + { + var server = new FakeServer(r => r.Path == "/openapi.json" ? (HttpStatusCode.OK, OpenApiWithDeleteLimit) : (HttpStatusCode.OK, """{"deleted":0}""")); + Assert.That(await CollectionClient(server).DeleteAsync(new ChromaDelete { WhereDocument = ChromaWhereDocumentOperator.Contains("x"), Limit = 0 }), Is.EqualTo(0)); + Assert.That(server.Requests.Single(x => x.Path.EndsWith("/delete")).Body.GetProperty("limit").GetInt32(), Is.EqualTo(0)); + } + + [TestCase(ChromaReadLevel.IndexAndWal, "index_and_wal")] + [TestCase(ChromaReadLevel.IndexOnly, "index_only")] + [TestCase(ChromaReadLevel.IndexAndBoundedWal, "index_and_bounded_wal")] + public async Task CountAtAReadLevel(ChromaReadLevel readLevel, string name) + { + var server = new FakeServer(_ => (HttpStatusCode.OK, "4")); + Assert.That(await CollectionClient(server).CountAsync(readLevel), Is.EqualTo(4)); + Assert.That(server.Requests.Single().Line, Is.EqualTo($"GET {CollectionPath}/count?read_level={name}")); + } + + // A single server sends "hnsw" with "spann" null, Chroma Cloud the other way around. + [TestCase("""{"hnsw":{"ef_search":100},"spann":null}""", """{"hnsw":{"ef_search":200}}""")] + [TestCase("""{"hnsw":null,"spann":{"search_nprobe":64}}""", """{"spann":{"search_nprobe":32}}""")] + public async Task ModifyConfiguration(string configuration, string expected) + { + var server = new FakeServer(_ => (HttpStatusCode.OK, "{}")); + var update = configuration.StartsWith("""{"hnsw":{""") + ? new ChromaCollectionConfigurationUpdate { Hnsw = new() { EfSearch = 200 } } + : new ChromaCollectionConfigurationUpdate { Spann = new() { SearchNprobe = 32 } }; + await CollectionClient(server, collection: Collection(configuration)).ModifyConfigurationAsync(update); + var request = server.Requests.Single(); + Assert.That(request.Line, Is.EqualTo($"PUT {CollectionPath}")); + Assert.That(request.Body.GetProperty("new_configuration").GetRawText(), Is.EqualTo(expected)); + } + + // Chroma Cloud answers 500 to HNSW settings on its SPANN index; a single server answers 200 to SPANN settings and keeps "spann" null. + [TestCase("""{"hnsw":null,"spann":{"search_nprobe":64}}""", true, "set Spann")] + [TestCase("""{"hnsw":{"ef_search":100},"spann":null}""", false, "set Hnsw")] + public async Task ModifyConfigurationOfAnotherIndex(string configuration, bool hnsw, string message) + { + var server = new FakeServer(_ => (HttpStatusCode.OK, "{}")); + var update = hnsw + ? new ChromaCollectionConfigurationUpdate { Hnsw = new() { EfSearch = 200 } } + : new ChromaCollectionConfigurationUpdate { Spann = new() { SearchNprobe = 32 } }; + await Assert.ThatAsync(() => CollectionClient(server, collection: Collection(configuration)).ModifyConfigurationAsync(update), + Throws.InstanceOf().With.Message.Contains(message)); + Assert.That(server.Requests, Is.Empty); + } + + // Chroma 0.5.4 to 1.0.5 send "hnsw_configuration", 0.4.10 to 0.5.3 no configuration: they answer without applying it. + [TestCase("""{"_type":"CollectionConfigurationInternal","hnsw_configuration":{"ef_search":10}}""")] + [TestCase(null)] + public async Task ModifyConfigurationOnAServerThatIgnoresIt(string? configuration) + { + var server = new FakeServer(_ => (HttpStatusCode.OK, "{}")); + var collection = new ChromaCollection("c") { Id = Id, ConfigurationJson = configuration is null ? JsonDocument.Parse("null").RootElement : JsonDocument.Parse(configuration).RootElement }; + await Assert.ThatAsync(() => CollectionClient(server, collection: collection).ModifyConfigurationAsync(new() { Hnsw = new() { EfSearch = 200 } }), + Throws.InstanceOf().With.Message.Contains("Chroma 1.0.6")); + Assert.That(server.Requests.Any(x => x.Method == "PUT"), Is.False); + } + + // A collection client made from the id and the name has no configuration at hand: it reads the collection first. + [Test] + public async Task ModifyConfigurationReadsTheCollection() + { + var server = new FakeServer(r => r.Method == "GET" ? (HttpStatusCode.OK, $$$$"""{"id":"{{{{Id}}}}","name":"c","configuration_json":{"hnsw":{}}}""") : (HttpStatusCode.OK, "{}")); + await CollectionClient(server).ModifyConfigurationAsync(new() { Hnsw = new() { EfSearch = 200 } }); + Assert.That(server.Requests.Select(x => x.Line), Is.EqualTo(new[] { "GET /api/v2/tenants/default_tenant/databases/default_database/collections/c", $"PUT {CollectionPath}" })); + } + + [Test] + public async Task Fork() + { + var server = new FakeServer(_ => (HttpStatusCode.OK, """{"id":"22222222-2222-3333-4444-555555555555","name":"copy"}""")); + var result = await CollectionClient(server).ForkAsync("copy"); + Assert.That(server.Requests.Single().Line, Is.EqualTo($"POST {CollectionPath}/fork")); + Assert.That(server.Requests.Single().Body.GetProperty("new_name").GetString(), Is.EqualTo("copy")); + Assert.That(result.Name, Is.EqualTo("copy")); + } + + [Test] + public async Task ForkCount() + { + var server = new FakeServer(_ => (HttpStatusCode.OK, """{"count":3}""")); + Assert.That(await CollectionClient(server).ForkCountAsync(), Is.EqualTo(3)); + Assert.That(server.Requests.Single().Line, Is.EqualTo($"GET {CollectionPath}/fork_count")); + } + + [Test] + public async Task IndexingStatus() + { + var server = new FakeServer(_ => (HttpStatusCode.OK, """{"op_indexing_progress":0.25,"num_unindexed_ops":3,"num_indexed_ops":1,"total_ops":4}""")); + var result = await CollectionClient(server).GetIndexingStatusAsync(); + Assert.That(server.Requests.Single().Line, Is.EqualTo($"GET {CollectionPath}/indexing_status")); + Assert.That((result.OpIndexingProgress, result.NumUnindexedOps, result.NumIndexedOps, result.TotalOps), Is.EqualTo((0.25f, 3L, 1L, 4L))); + } + + [Test] + public async Task AttachFunction() + { + var server = new FakeServer(_ => (HttpStatusCode.OK, """{"attached_function":{"id":"33333333-2222-3333-4444-555555555555","name":"stats","function_name":"statistics"},"created":true}""")); + var (attached, created) = await CollectionClient(server).AttachFunctionAsync(ChromaFunctions.Statistics, "stats", "stats_output", new Dictionary { ["k"] = 1 }); + var request = server.Requests.Single(); + Assert.That(request.Line, Is.EqualTo($"POST {CollectionPath}/functions/attach")); + Assert.That(request.Body.GetRawText(), Is.EqualTo("""{"name":"stats","function_id":"statistics","output_collection":"stats_output","params":{"k":1}}""")); + Assert.That((attached.Id, attached.Name, attached.FunctionName, created), Is.EqualTo((Guid.Parse("33333333-2222-3333-4444-555555555555"), "stats", "statistics", true))); + } + + [TestCase(""","created":false""", false)] + [TestCase("", true)] + public async Task AttachFunctionCreated(string created, bool expected) + { + var server = new FakeServer(_ => (HttpStatusCode.OK, $$"""{"attached_function":{"id":"33333333-2222-3333-4444-555555555555","name":"stats","function_name":"statistics"}{{created}}}""")); + Assert.That((await CollectionClient(server).AttachFunctionAsync(ChromaFunctions.Statistics, "stats", "stats_output")).Created, Is.EqualTo(expected)); + } + + [Test] + public async Task GetAttachedFunction() + { + var server = new FakeServer(_ => (HttpStatusCode.OK, $$$""" + {"attached_function":{"id":"33333333-2222-3333-4444-555555555555","name":"stats","function_name":"statistics","input_collection_id":"{{{Id}}}", + "output_collection":"stats_output","output_collection_id":null,"tenant_id":"t","database_id":"d","params":"{\"k\":1}","completion_offset":7,"min_records_for_invocation":100}} + """)); + var result = await CollectionClient(server).GetAttachedFunctionAsync("stats"); + Assert.That(server.Requests.Single().Line, Is.EqualTo($"GET {CollectionPath}/functions/stats")); + Assert.That((result.Name, result.FunctionName, result.InputCollectionId, result.OutputCollection, result.OutputCollectionId), Is.EqualTo(("stats", "statistics", (Guid?)Id, "stats_output", (Guid?)null))); + Assert.That((result.Tenant, result.Database, result.Params, result.CompletionOffset, result.MinRecordsForInvocation), Is.EqualTo(("t", "d", """{"k":1}""", (long?)7, (long?)100))); + } + + [Test] + public async Task DetachFunction() + { + var server = new FakeServer(_ => (HttpStatusCode.OK, """{"success":true}""")); + Assert.That(await CollectionClient(server).DetachFunctionAsync("stats", deleteOutputCollection: true), Is.True); + Assert.That(server.Requests.Single().Line, Is.EqualTo($"POST {CollectionPath}/attached_functions/stats/detach")); + Assert.That(server.Requests.Single().Body.GetProperty("delete_output").GetBoolean(), Is.True); + } + + // Like the CloudClient of the Python client of Chroma: the tenant unless it is "*", the database only when there is exactly one. + [TestCase("""{"tenant":"t1","databases":["d1"]}""", "t1", "d1")] + [TestCase("""{"tenant":"t1","databases":["d1","d1"]}""", "t1", "d1")] + [TestCase("""{"tenant":"t1","databases":[]}""", "t1", null)] + [TestCase("""{"tenant":"t1","databases":["d1","d2"]}""", "t1", null)] + [TestCase("""{"tenant":"*","databases":["*"]}""", null, null)] + [TestCase("""{"tenant":null,"databases":null}""", null, null)] + public async Task TenantAndDatabaseFromIdentity(string identity, string? tenant, string? database) + { + var server = new FakeServer(r => r.Path.EndsWith("/auth/identity") ? (HttpStatusCode.OK, identity) : (HttpStatusCode.OK, "[]")); + var client = await Client(server).WithTenantAndDatabaseFromIdentityAsync(); + Assert.That((client.Options.Tenant, client.Options.Database), Is.EqualTo((tenant, database))); + await client.ListCollectionsAsync(); + Assert.That(server.Requests.Last().Line, Is.EqualTo($"GET /api/v2/tenants/{tenant ?? "default_tenant"}/databases/{database ?? "default_database"}/collections")); + } + + // A tenant or a database of the options other than the default one must match the credentials. + [TestCase("other", null, "tenant other")] + [TestCase(null, "other", "database other")] + public async Task TenantAndDatabaseFromIdentityThatDoNotMatch(string? tenant, string? database, string message) + { + var server = new FakeServer(_ => (HttpStatusCode.OK, """{"tenant":"t1","databases":["d1"]}""")); + var options = new ChromaConfigurationOptions("http://localhost:8000", tenant: tenant, database: database); + await Assert.ThatAsync(() => new ChromaClient(options, new HttpClient(server)).WithTenantAndDatabaseFromIdentityAsync(), + Throws.InstanceOf().With.Message.Contains(message)); + } + + // With several databases in the credentials, the one of the options stays, as in the Python client: there is none to compare. + [Test] + public async Task TenantAndDatabaseFromIdentityWithSeveralDatabases() + { + var server = new FakeServer(_ => (HttpStatusCode.OK, """{"tenant":"t1","databases":["d1","d2"]}""")); + var options = new ChromaConfigurationOptions("http://localhost:8000", database: "d3"); + var client = await new ChromaClient(options, new HttpClient(server)).WithTenantAndDatabaseFromIdentityAsync(); + Assert.That((client.Options.Tenant, client.Options.Database), Is.EqualTo(("t1", "d3"))); + } + + [Test] + public async Task TenantAndDatabaseFromIdentityReplaceTheDefaults() + { + var server = new FakeServer(_ => (HttpStatusCode.OK, """{"tenant":"t1","databases":["d1"]}""")); + var options = new ChromaConfigurationOptions("http://localhost:8000", tenant: "default_tenant", database: "default_database"); + var client = await new ChromaClient(options, new HttpClient(server)).WithTenantAndDatabaseFromIdentityAsync(); + Assert.That((client.Options.Tenant, client.Options.Database), Is.EqualTo(("t1", "d1"))); + } + + static ChromaClient Client(HttpMessageHandler handler) + => new(new ChromaConfigurationOptions("http://localhost:8000"), new HttpClient(handler)); + + static ChromaCollection Collection(string configuration) + => new("c") { Id = Id, ConfigurationJson = JsonDocument.Parse(configuration).RootElement }; + + static ChromaCollectionClient CollectionClient(HttpMessageHandler handler, ChromaConfigurationOptions? options = null, ChromaCollection? collection = null) + => new(collection ?? new ChromaCollection("c") { Id = Id }, options ?? new ChromaConfigurationOptions("http://localhost:8000"), new HttpClient(handler)); + + sealed record Request(string Method, string Path, string Line, JsonElement Body); + + // Answers each request with the status and body the function returns for it, and keeps the requests. + sealed class FakeServer(Func answer) : HttpMessageHandler + { + public List Requests { get; } = []; + + protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + var text = request.Content is null ? null : await request.Content.ReadAsStringAsync(cancellationToken); + var body = text is { Length: > 0 } ? JsonDocument.Parse(text).RootElement.Clone() : default; + var path = request.RequestUri!.AbsolutePath; + var recorded = new Request(request.Method.Method, path, $"{request.Method.Method} {request.RequestUri.PathAndQuery}", body); + Requests.Add(recorded); + var (status, response) = answer(recorded); + return new HttpResponseMessage(status) { Content = new StringContent(response) }; + } + } +} diff --git a/ChromaDB.Client.Tests/ApiVersionTests.cs b/ChromaDB.Client.Tests/ApiVersionTests.cs new file mode 100644 index 0000000..0d8c578 --- /dev/null +++ b/ChromaDB.Client.Tests/ApiVersionTests.cs @@ -0,0 +1,121 @@ +using System.Net; +using ChromaDB.Client.Models; +using NUnit.Framework; + +namespace ChromaDB.Client.Tests; + +[TestFixture] +public class ApiVersionTests +{ + const string Id = "11111111-2222-3333-4444-555555555555"; + static readonly ReadOnlyMemory Embedding = new([1f, 0f]); + + static IEnumerable Requests() + { + yield return Case("ListCollections", c => c.ListCollectionsAsync(), "GET", "tenants/t/databases/d/collections", "collections?tenant=t&database=d"); + yield return Case("ListCollectionsPage", c => c.ListCollectionsAsync(limit: 2, offset: 1), "GET", "tenants/t/databases/d/collections?limit=2&offset=1", "collections?tenant=t&database=d&limit=2&offset=1"); + yield return Case("GetCollection", c => c.GetCollectionAsync("c"), "GET", "tenants/t/databases/d/collections/c", "collections/c?tenant=t&database=d"); + yield return Case("CollectionExists", c => c.CollectionExistsAsync("c"), "GET", "tenants/t/databases/d/collections/c", "collections/c?tenant=t&database=d"); + yield return Case("GetCollectionById", c => c.GetCollectionByIdAsync(Guid.Parse(Id)), "GET", $"tenants/t/databases/d/collections/by-id/{Id}", $"collections/by-id/{Id}?tenant=t&database=d"); + yield return Case("Heartbeat", c => c.HeartbeatAsync(), "GET", "heartbeat", "heartbeat"); + yield return Case("CreateCollection", c => c.CreateCollectionAsync("c"), "POST", "tenants/t/databases/d/collections", "collections?tenant=t&database=d"); + yield return Case("CreateCollectionDefinition", c => c.CreateCollectionAsync(new ChromaCollectionDefinition("c") { Configuration = new() { Space = ChromaSpace.Cosine } }), "POST", "tenants/t/databases/d/collections", "collections?tenant=t&database=d"); + yield return Case("GetOrCreateCollectionDefinition", c => c.GetOrCreateCollectionAsync(new ChromaCollectionDefinition("c")), "POST", "tenants/t/databases/d/collections", "collections?tenant=t&database=d"); + yield return Case("GetOrCreateCollection", c => c.GetOrCreateCollectionAsync("c"), "POST", "tenants/t/databases/d/collections", "collections?tenant=t&database=d"); + yield return Case("DeleteCollection", c => c.DeleteCollectionAsync("c"), "DELETE", "tenants/t/databases/d/collections/c", "collections/c?tenant=t&database=d"); + yield return Case("GetVersion", c => c.GetVersionAsync(), "GET", "version", "version"); + yield return Case("GetUserIdentity", c => c.GetUserIdentityAsync(), "GET", "auth/identity", "auth/identity"); + yield return Case("GetPreFlightChecks", c => c.GetPreFlightChecksAsync(), "GET", "pre-flight-checks", "pre-flight-checks"); + yield return Case("Reset", c => c.ResetAsync(), "POST", "reset", "reset"); + yield return Case("CountCollections", c => c.CountCollectionsAsync(), "GET", "tenants/t/databases/d/collections_count", "count_collections?tenant=t&database=d"); + yield return Case("CreateTenant", c => c.CreateTenantAsync("t"), "POST", "tenants", "tenants"); + yield return Case("GetTenant", c => c.GetTenantAsync("t"), "GET", "tenants/t", "tenants/t"); + yield return Case("CreateDatabase", c => c.CreateDatabaseAsync("d"), "POST", "tenants/t/databases", "databases?tenant=t"); + yield return Case("GetDatabase", c => c.GetDatabaseAsync("d"), "GET", "tenants/t/databases/d", "databases/d?tenant=t"); + yield return Case("ListDatabases", c => c.ListDatabasesAsync(), "GET", "tenants/t/databases", "databases?tenant=t"); + yield return Case("ListDatabasesPage", c => c.ListDatabasesAsync(limit: 2, offset: 1), "GET", "tenants/t/databases?limit=2&offset=1", "databases?tenant=t&limit=2&offset=1"); + yield return Case("DeleteDatabase", c => c.DeleteDatabaseAsync("d"), "DELETE", "tenants/t/databases/d", "databases/d?tenant=t"); + yield return Case("Get", c => c.GetAsync(), "POST", $"tenants/t/databases/d/collections/{Id}/get", $"collections/{Id}/get"); + yield return Case("Query", c => c.QueryAsync(Embedding), "POST", $"tenants/t/databases/d/collections/{Id}/query", $"collections/{Id}/query"); + yield return Case("QueryWithIds", c => c.QueryAsync(new ChromaQuery([Embedding]) { Ids = ["a"] }), "POST", $"tenants/t/databases/d/collections/{Id}/query", $"collections/{Id}/query"); + yield return Case("Add", c => c.AddAsync(["a"], embeddings: [Embedding]), "POST", $"tenants/t/databases/d/collections/{Id}/add", $"collections/{Id}/add"); + yield return Case("Update", c => c.UpdateAsync(["a"]), "POST", $"tenants/t/databases/d/collections/{Id}/update", $"collections/{Id}/update"); + yield return Case("Upsert", c => c.UpsertAsync(["a"], embeddings: [Embedding]), "POST", $"tenants/t/databases/d/collections/{Id}/upsert", $"collections/{Id}/upsert"); + yield return Case("Delete", c => c.DeleteAsync(["a"]), "POST", $"tenants/t/databases/d/collections/{Id}/delete", $"collections/{Id}/delete"); + yield return Case("Count", c => c.CountAsync(), "GET", $"tenants/t/databases/d/collections/{Id}/count", $"collections/{Id}/count"); + yield return Case("CountFromGetCollectionClient", c => c.GetCollectionClient(Guid.Parse(Id), "c").CountAsync(), "GET", $"tenants/t/databases/d/collections/{Id}/count", $"collections/{Id}/count"); + yield return Case("Peek", c => c.PeekAsync(), "POST", $"tenants/t/databases/d/collections/{Id}/get", $"collections/{Id}/get"); + yield return Case("Modify", c => c.ModifyAsync(name: "c2"), "PUT", $"tenants/t/databases/d/collections/{Id}", $"collections/{Id}"); + } + + static TestCaseData Case(string name, Func call, string method, string v2, string v1) + => new TestCaseData(new Func((c, _) => call(c)), method, v2, v1).SetName($"ChromaClient.{name}"); + + static TestCaseData Case(string name, Func call, string method, string v2, string v1) + => new TestCaseData(new Func((_, c) => call(c)), method, v2, v1).SetName($"ChromaCollectionClient.{name}"); + + [TestCaseSource(nameof(Requests))] + public async Task V2(Func call, string method, string v2, string v1) + => await AssertRequest(ChromaApiVersion.V2, call, method, "/api/v2/" + v2); + + [TestCaseSource(nameof(Requests))] + public async Task V1(Func call, string method, string v2, string v1) + => await AssertRequest(ChromaApiVersion.V1, call, method, "/api/v1/" + v1); + + // The default URI is the address of the server, so it follows the chosen version. + [TestCase(ChromaApiVersion.V2, "http://localhost:8000/api/v2/heartbeat")] + [TestCase(ChromaApiVersion.V1, "http://localhost:8000/api/v1/heartbeat")] + public async Task DefaultOptions(ChromaApiVersion apiVersion, string expected) + { + var handler = new RecordingHandler(); + using var httpClient = new HttpClient(handler); + try + { + await new ChromaClient(new ChromaConfigurationOptions().WithApiVersion(apiVersion), httpClient).HeartbeatAsync(); + } + catch (ChromaException) + { + } + Assert.That(handler.Uri, Is.EqualTo(expected)); + } + + [Test] + public void DefaultIsV2() + => Assert.That(new ChromaConfigurationOptions().ApiVersion, Is.EqualTo(ChromaApiVersion.V2)); + + static async Task AssertRequest(ChromaApiVersion apiVersion, Func call, string method, string expected) + { + var handler = new RecordingHandler(); + using var httpClient = new HttpClient(handler); + var options = new ChromaConfigurationOptions("http://localhost:8000", tenant: "t", database: "d").WithApiVersion(apiVersion); + var collection = new ChromaCollection("c") { Id = Guid.Parse(Id) }; + try + { + await call(new ChromaClient(options, httpClient), new ChromaCollectionClient(collection, options, httpClient)); + } + catch (ChromaException) + { + // The empty answer does not fit every response type: only the request matters here. + } + Assert.That(handler.Method, Is.EqualTo(method)); + Assert.That(handler.PathAndQuery, Is.EqualTo(expected)); + } + + sealed class RecordingHandler : HttpMessageHandler + { + public string? Method { get; private set; } + public string? PathAndQuery { get; private set; } + public string? Uri { get; private set; } + + protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + Method = request.Method.Method; + PathAndQuery = request.RequestUri?.PathAndQuery; + Uri = request.RequestUri?.AbsoluteUri; + // A 0.x version: the deletes go without deleting the records first, so the request is the delete. A collection with its + // space, which needs no version to tell it. + var body = request.RequestUri?.AbsolutePath.EndsWith("/version") == true ? "\"0.6.3\"" : """{"name":"c","metadata":{"hnsw:space":"cosine"}}"""; + return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent(body) }); + } + } +} diff --git a/ChromaDB.Client.Tests/AuthenticationHeaderTests.cs b/ChromaDB.Client.Tests/AuthenticationHeaderTests.cs new file mode 100644 index 0000000..391f804 --- /dev/null +++ b/ChromaDB.Client.Tests/AuthenticationHeaderTests.cs @@ -0,0 +1,98 @@ +using System.Net; +using NUnit.Framework; + +namespace ChromaDB.Client.Tests; + +[TestFixture] +public class AuthenticationHeaderTests +{ + static readonly ChromaConfigurationOptions Options = new(uri: "http://localhost:8000/api/v2/"); + + [Test] + public async Task NoCredentials() + { + var request = (await Send(Options)).Single(); + Assert.That(request.ChromaToken, Is.Null); + Assert.That(request.Authorization, Is.Null); + } + + [Test] + public async Task ChromaTokenInXChromaTokenHeader() + { + var request = (await Send(Options.WithChromaToken("token"))).Single(); + Assert.That(request.ChromaToken, Is.EqualTo("token")); + Assert.That(request.Authorization, Is.Null); + } + + [Test] + public async Task ChromaTokenInAuthorizationHeader() + { + var request = (await Send(Options.WithChromaToken("token", ChromaTokenTransportHeader.Authorization))).Single(); + Assert.That(request.ChromaToken, Is.Null); + Assert.That(request.Authorization, Is.EqualTo("Bearer token")); + } + + [Test] + public async Task BasicAuth() + { + var request = (await Send(Options.WithBasicAuth("admin", "secret"))).Single(); + Assert.That(request.ChromaToken, Is.Null); + Assert.That(request.Authorization, Is.EqualTo("Basic YWRtaW46c2VjcmV0")); + } + + [Test] + public async Task CredentialsKeptByTheOtherOptions() + { + var basic = (await Send(Options.WithBasicAuth("admin", "secret").WithTenant("tenant").WithDatabase("database").WithUri("http://localhost:8001/api/v2/"))).Single(); + Assert.That(basic.Authorization, Is.EqualTo("Basic YWRtaW46c2VjcmV0")); + var bearer = (await Send(Options.WithChromaToken("token", ChromaTokenTransportHeader.Authorization).WithTenant("tenant"))).Single(); + Assert.That(bearer.Authorization, Is.EqualTo("Bearer token")); + } + + [Test] + public async Task ClientsSharingHttpClientSendTheirOwnCredentials() + { + var handler = new RecordingHandler(); + using var httpClient = new HttpClient(handler); + var first = new ChromaClient(Options.WithChromaToken("first"), httpClient); + var second = new ChromaClient(Options.WithChromaToken("second", ChromaTokenTransportHeader.Authorization), httpClient); + var third = new ChromaClient(Options, httpClient); + await first.HeartbeatAsync(); + await second.HeartbeatAsync(); + await third.HeartbeatAsync(); + await first.HeartbeatAsync(); + Assert.That(handler.Requests.Select(x => x.ChromaToken), Is.EqualTo(new[] { "first", null, null, "first" })); + Assert.That(handler.Requests.Select(x => x.Authorization), Is.EqualTo(new[] { null, "Bearer second", null, null })); + } + + [Test] + public void BasicAuthAndTokenInAuthorizationHeader() + { + var options = Options.WithBasicAuth("admin", "secret").WithChromaToken("token", ChromaTokenTransportHeader.Authorization); + Assert.That(() => new ChromaClient(options, new HttpClient()), Throws.ArgumentException); + Assert.That(() => new ChromaClient(options), Throws.ArgumentException); + } + + static async Task> Send(ChromaConfigurationOptions options) + { + var handler = new RecordingHandler(); + using var httpClient = new HttpClient(handler); + await new ChromaClient(options, httpClient).HeartbeatAsync(); + return handler.Requests; + } + + record RecordedRequest(string? ChromaToken, string? Authorization); + + sealed class RecordingHandler : HttpMessageHandler + { + public List Requests { get; } = []; + + protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + Requests.Add(new( + request.Headers.TryGetValues("X-Chroma-Token", out var values) ? values.Single() : null, + request.Headers.Authorization?.ToString())); + return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent("""{"nanosecond heartbeat":1}""") }); + } + } +} diff --git a/ChromaDB.Client.Tests/Base64AndLimitsTests.cs b/ChromaDB.Client.Tests/Base64AndLimitsTests.cs new file mode 100644 index 0000000..e7e9337 --- /dev/null +++ b/ChromaDB.Client.Tests/Base64AndLimitsTests.cs @@ -0,0 +1,211 @@ +using System.Net; +using System.Runtime.InteropServices; +using System.Text.Json; +using ChromaDB.Client.DependencyInjection; +using ChromaDB.Client.Models; +using Microsoft.Extensions.DependencyInjection; +using NUnit.Framework; + +namespace ChromaDB.Client.Tests; + +[TestFixture] +public class Base64AndLimitsTests +{ + static readonly ReadOnlyMemory Embedding = new([0.1f, -2.5f, 3.25f, 1e-7f]); + static readonly ChromaConfigurationOptions Options = new("http://localhost:8000"); + const string WithBase64 = """{"max_batch_size":1000,"supports_base64_encoding":true}"""; + const string WithoutBase64 = """{"max_batch_size":1000}"""; + + [Test] + public async Task EmbeddingsInBase64WhereTheServerDeclaresIt() + { + var handler = new Handler(WithBase64); + var client = Client(Options, handler); + await client.AddAsync(["a"], embeddings: [Embedding]); + await client.UpdateAsync(["a"], embeddings: [Embedding]); + await client.UpsertAsync(["a"], embeddings: [Embedding]); + Assert.That(handler.Paths.Select(Last), Is.EqualTo(new[] { "pre-flight-checks", "add", "update", "upsert" })); + foreach (var body in handler.Bodies) + { + var base64 = body.GetProperty("embeddings")[0].GetString()!; + // Little-endian float32 values, whatever the order of the bytes of the machine. + var bytes = Convert.FromBase64String(base64); + Assert.That(Enumerable.Range(0, bytes.Length / 4).Select(i => System.Buffers.Binary.BinaryPrimitives.ReadSingleLittleEndian(bytes.AsSpan(i * 4))), Is.EqualTo(Embedding.ToArray())); + } + } + + // Queries always send numbers: the servers reject base64 there. + [Test] + public async Task QueriesSendNumbers() + { + var handler = new Handler(WithBase64); + await Client(Options, handler).QueryAsync(Embedding); + Assert.That(handler.Bodies.Single().GetProperty("query_embeddings")[0].GetRawText(), Is.EqualTo("[0.1,-2.5,3.25,1E-07]")); + } + + [TestCase(WithoutBase64)] + [TestCase("""{"max_batch_size":1000,"supports_base64_encoding":false}""")] + [TestCase(null)] + // An answer the client cannot read: the write still goes, with numbers. + [TestCase("\"broken\"")] + public async Task NumbersWhereTheServerDoesNotDeclareIt(string? preFlightChecks) + { + var handler = new Handler(preFlightChecks); + await Client(Options, handler).AddAsync(["a"], embeddings: [Embedding]); + Assert.That(handler.Bodies.Single().GetProperty("embeddings")[0].GetRawText(), Is.EqualTo("[0.1,-2.5,3.25,1E-07]")); + } + + // Without embeddings the client does not need to know. + [Test] + public async Task NoPreFlightChecksWithoutEmbeddings() + { + var handler = new Handler(WithBase64); + await Client(Options.WithBatchSplitting(false), handler).UpdateAsync(["a"], documents: ["d"]); + Assert.That(handler.Paths.Select(Last), Is.EqualTo(new[] { "update" })); + } + + [TestCase(2, """{"max_batch_size":1000}""", new[] { 2, 2, 1 })] + [TestCase(3, """{"max_batch_size":2}""", new[] { 2, 2, 1 })] + [TestCase(2, null, new[] { 2, 2, 1 })] + [TestCase(10, """{"max_batch_size":1000}""", new[] { 5 })] + public async Task BatchesOfTheSmallerLimit(int maxBatchSize, string? preFlightChecks, int[] batches) + { + var handler = new Handler(preFlightChecks); + await Client(Options.WithBatchSplitting(maxBatchSize), handler).AddAsync(["a", "b", "c", "d", "e"], embeddings: Enumerable.Repeat(Embedding, 5).ToList()); + Assert.That(handler.Bodies.Select(b => b.GetProperty("ids").GetArrayLength()), Is.EqualTo(batches)); + } + + [Test] + public void MaxBatchSizeOption() + { + Assert.Throws(() => Options.WithBatchSplitting(0)); + Assert.Throws(() => _ = new ChromaConfigurationOptions { BatchSplitting = true, MaxBatchSize = 0 }); + var options = Options.WithBatchSplitting(300).WithTenant("t"); + Assert.That(options.BatchSplitting, Is.True); + Assert.That(options.MaxBatchSize, Is.EqualTo(300)); + Assert.That(Options.WithBatchSplitting().MaxBatchSize, Is.Null); + } + + [TestCase(HttpStatusCode.NotFound, """{"error":"NotFoundError","message":"Collection [c] does not exist"}""", "NotFoundError", "Collection [c] does not exist")] + [TestCase(HttpStatusCode.BadRequest, """{"error":"InvalidCollection","message":"Collection c does not exist."}""", "InvalidCollection", "Collection c does not exist.")] + [TestCase(HttpStatusCode.InternalServerError, """{"error":"ValueError('Collection c does not exist.')"}""", "ValueError", "Collection c does not exist.")] + [TestCase(HttpStatusCode.InternalServerError, """{"detail":"Internal Server Error"}""", null, "Internal Server Error")] + [TestCase(HttpStatusCode.NotFound, """{"error":"NotFoundError","detail":"Not found"}""", "NotFoundError", "Not found")] + // The validation errors of FastAPI, from the 0.x servers. + [TestCase((HttpStatusCode)422, """{"detail":[{"type":"int_parsing","loc":["body","n_results"],"msg":"Input should be a valid integer","input":"x"}]}""", null, "body.n_results: Input should be a valid integer")] + [TestCase((HttpStatusCode)422, """{"detail":[{"loc":["body","include",0],"msg":"Input should be 'documents'"},{"loc":["body","include",0],"msg":"Input should be 'embeddings'"}]}""", null, "body.include.0: Input should be 'documents'; body.include.0: Input should be 'embeddings'")] + [TestCase((HttpStatusCode)422, """{"detail":[{"msg":"Field required"}]}""", null, "Field required")] + [TestCase(HttpStatusCode.NotFound, """{"error":"NotFoundError"}""", "NotFoundError", "Couldn't identify the error message: {\"error\":\"NotFoundError\"}")] + [TestCase(HttpStatusCode.NotFound, "", null, "NotFound: GET /api/v2/tenants/default_tenant/databases/default_database/collections/c")] + public async Task ErrorTypeOfTheServer(HttpStatusCode statusCode, string body, string? errorType, string message) + { + using var httpClient = new HttpClient(new FixedHandler(statusCode, body)); + var ex = Assert.ThrowsAsync(() => new ChromaClient(Options, httpClient).GetCollectionAsync("c")); + Assert.That(ex!.ErrorType, Is.EqualTo(errorType)); + Assert.That(ex.Message, Is.EqualTo(message)); + await Task.CompletedTask; + } + + // A missing collection named by the kind of error, even if the wording of the message changes. + [TestCase("""{"error":"NotFoundError","message":"Collection [c] not found"}""", false)] + [TestCase("""{"error":"NotFoundError","message":"Tenant [t] not found"}""", null)] + public async Task CollectionExistsByTheErrorType(string body, bool? expected) + { + using var httpClient = new HttpClient(new FixedHandler(HttpStatusCode.NotFound, body)); + var client = new ChromaClient(Options, httpClient); + if (expected is { } exists) + { + Assert.That(await client.CollectionExistsAsync("c"), Is.EqualTo(exists)); + } + else + { + Assert.ThrowsAsync(() => client.CollectionExistsAsync("c")); + } + } + + // The settings of the HttpClient reach the singleton: a handler in the pipeline and a timeout. + [Test] + public async Task ConfigureTheHttpClient() + { + var headers = new List(); + var services = new ServiceCollection(); + services.AddChromaClient(_ => Options, builder => builder + .AddHttpMessageHandler(() => new HeaderHandler("plain", headers)) + .ConfigurePrimaryHttpMessageHandler(() => new HeartbeatOrWaitHandler()) + .ConfigureHttpClient(c => c.Timeout = TimeSpan.FromMilliseconds(300))); + services.AddKeyedChromaClient("k", _ => Options, builder => builder + .AddHttpMessageHandler(() => new HeaderHandler("keyed", headers)) + .ConfigurePrimaryHttpMessageHandler(() => new HeartbeatOrWaitHandler()) + .ConfigureHttpClient(c => c.Timeout = TimeSpan.FromMilliseconds(300))); + using var provider = services.BuildServiceProvider(); + var plain = provider.GetRequiredService(); + var keyed = provider.GetRequiredKeyedService("k"); + await plain.HeartbeatAsync(); + await keyed.HeartbeatAsync(); + Assert.That(headers, Is.EqualTo(new[] { "plain", "keyed" })); + // The timeout of 300 ms, not the 100 s of a default HttpClient. + foreach (var client in new[] { plain, keyed }) + { + var watch = System.Diagnostics.Stopwatch.StartNew(); + var timeout = Assert.ThrowsAsync(() => client.GetVersionAsync()); + Assert.That(timeout!.InnerException, Is.InstanceOf()); + Assert.That(watch.Elapsed, Is.LessThan(TimeSpan.FromSeconds(10))); + } + } + + static string Last(string path) => path.Substring(path.LastIndexOf('/') + 1); + + static ChromaCollectionClient Client(ChromaConfigurationOptions options, HttpMessageHandler handler) + => new(new ChromaCollection("collection") { Id = Guid.NewGuid() }, options, new HttpClient(handler)); + + // Answers pre-flight-checks with the given body, or 404 when it is null, and the other requests as a server would. + sealed class Handler(string? preFlightChecks) : HttpMessageHandler + { + public List Paths { get; } = []; + public List Bodies { get; } = []; + + protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + Paths.Add(request.RequestUri!.AbsolutePath); + if (request.RequestUri.AbsolutePath.EndsWith("/pre-flight-checks")) + { + return preFlightChecks is null + ? new HttpResponseMessage(HttpStatusCode.NotFound) { Content = new StringContent("""{"detail":"Not Found"}""") } + : new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent(preFlightChecks) }; + } + Bodies.Add(JsonDocument.Parse(await request.Content!.ReadAsStringAsync(cancellationToken)).RootElement.Clone()); + return new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent(request.RequestUri.AbsolutePath.EndsWith("/query") ? """{"ids":[[]]}""" : "true") }; + } + } + + sealed class FixedHandler(HttpStatusCode statusCode, string body) : HttpMessageHandler + { + protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + => Task.FromResult(new HttpResponseMessage(statusCode) { Content = new StringContent(body) }); + } + + sealed class HeaderHandler(string name, List headers) : DelegatingHandler + { + protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + if (request.RequestUri!.AbsolutePath.EndsWith("/heartbeat")) + { + lock (headers) headers.Add(name); + } + return base.SendAsync(request, cancellationToken); + } + } + + // Answers the heartbeat, and makes the other requests wait until they time out. + sealed class HeartbeatOrWaitHandler : HttpMessageHandler + { + protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + if (!request.RequestUri!.AbsolutePath.EndsWith("/heartbeat")) + { + await Task.Delay(Timeout.Infinite, cancellationToken); + } + return new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent("""{"nanosecond heartbeat":1}""") }; + } + } +} diff --git a/ChromaDB.Client.Tests/BatchSplittingTests.cs b/ChromaDB.Client.Tests/BatchSplittingTests.cs new file mode 100644 index 0000000..fee06ee --- /dev/null +++ b/ChromaDB.Client.Tests/BatchSplittingTests.cs @@ -0,0 +1,119 @@ +using ChromaDB.Client.Models; +using NUnit.Framework; + +namespace ChromaDB.Client.Tests; + +[TestFixture] +public class BatchSplittingTests : ChromaTestsBase +{ + // One record beyond the max_batch_size of the server: up to Chroma 1.0.13 a single request fails, in batches it works. + [Test] + public async Task AddAndDeleteBeyondTheMaxBatchSize() + { + Assume.That(PreFlightChecksSupported, Is.True, "Chroma 0.4.10 has no pre-flight-checks, so the client does not know its limit."); + var options = TestMaxBatchSize is { } limit ? BaseConfigurationOptions.WithBatchSplitting(limit) : BaseConfigurationOptions.WithBatchSplitting(); + var client = new ChromaClient(options, HttpClient); + var count = (await client.GetPreFlightChecksAsync()).MaxBatchSize + 1; + var collectionClient = client.GetCollectionClient(await client.CreateCollectionAsync($"collection{Random.Shared.Next()}")); + var ids = Enumerable.Range(0, count).Select(i => $"r{i}").ToList(); + + await collectionClient.AddAsync(new ChromaRecords(ids) { Embeddings = Enumerable.Repeat(new ReadOnlyMemory([0.5f, 0.5f]), count).ToList() }); + Assert.That(await collectionClient.CountAsync(), Is.EqualTo(count)); + await collectionClient.DeleteAsync(ids); + Assert.That(await collectionClient.CountAsync(), Is.EqualTo(0)); + } + + // DeleteAllAsync deletes a page of the batch size at a time, until none is left. + [Test] + public async Task DeleteAllInPages() + { + var client = new ChromaClient(BaseConfigurationOptions.WithBatchSplitting(1000), HttpClient); + var collectionClient = client.GetCollectionClient(await client.CreateCollectionAsync($"collection{Random.Shared.Next()}")); + var ids = Enumerable.Range(0, 3000).Select(i => $"r{i}").ToList(); + await collectionClient.AddAsync(new ChromaRecords(ids) { Embeddings = Enumerable.Repeat(new ReadOnlyMemory([0.5f, 0.5f]), 3000).ToList() }); + + Assert.That(await collectionClient.DeleteAllAsync(), Is.EqualTo(3000)); + Assert.That(await collectionClient.CountAsync(), Is.EqualTo(0)); + Assert.That(await collectionClient.DeleteAllAsync(), Is.EqualTo(0)); + } + + // Chroma Cloud answers at most 300 records per read and rejects a larger limit: a fresh client with WithBatchSplitting(1000), on a + // collection another client filled, learns it from the first get, reads all the records and deletes them all. + [Test] + public async Task QuotaOfTheReadsOfChromaCloud() + { + Assume.That(ChromaCloud, Is.True, "Only Chroma Cloud has the quota of 300 records per read."); + var filler = new ChromaClient(BaseConfigurationOptions, HttpClient); + var collection = await filler.CreateCollectionAsync($"collection{Random.Shared.Next()}"); + var ids = Enumerable.Range(0, 350).Select(i => $"r{i}").ToList(); + await filler.GetCollectionClient(collection).AddAsync(new ChromaRecords(ids) { Embeddings = Enumerable.Repeat(new ReadOnlyMemory([0.5f, 0.5f]), 350).ToList() }); + + var reader = new ChromaClient(BaseConfigurationOptions.WithBatchSplitting(1000), NewHttpClient()).GetCollectionClient(collection); + Assert.That((await reader.GetAsync(include: ChromaGetInclude.None)).Count, Is.EqualTo(350)); + var deleter = new ChromaClient(BaseConfigurationOptions.WithBatchSplitting(1000), NewHttpClient()).GetCollectionClient(collection); + Assert.That(await deleter.DeleteAllAsync(), Is.EqualTo(350)); + Assert.That(await filler.GetCollectionClient(collection).CountAsync(), Is.EqualTo(0)); + } + + // Chroma Cloud rejects a get of more ids than its quota of 300, with a limit or without: a fresh client with WithBatchSplitting(1000) + // learns it and reads the ids in batches. + [TestCase(350)] + [TestCase(null)] + public async Task QuotaOfAReadByIdsOfChromaCloud(int? limit) + { + Assume.That(ChromaCloud, Is.True, "Only Chroma Cloud has the quota of 300 records per read."); + var filler = new ChromaClient(BaseConfigurationOptions, HttpClient); + var collection = await filler.CreateCollectionAsync($"collection{Random.Shared.Next()}"); + var ids = Enumerable.Range(0, 350).Select(i => $"r{i}").ToList(); + await filler.GetCollectionClient(collection).AddAsync(new ChromaRecords(ids) { Embeddings = Enumerable.Repeat(new ReadOnlyMemory([0.5f, 0.5f]), 350).ToList() }); + + var reader = new ChromaClient(BaseConfigurationOptions.WithBatchSplitting(1000), NewHttpClient()).GetCollectionClient(collection); + Assert.That((await reader.GetAsync(ids, limit: limit, include: ChromaGetInclude.None)).Select(x => x.Id), Is.EquivalentTo(ids)); + } + + // More ids than the batch size of the caller: its batches go beyond the quota of Chroma Cloud, and the client reads them again smaller. + [Test] + public async Task QuotaOfReadsByIdsInBatchesOfChromaCloud() + { + Assume.That(ChromaCloud, Is.True, "Only Chroma Cloud has the quota of 300 records per read."); + var filler = new ChromaClient(BaseConfigurationOptions, HttpClient); + var collection = await filler.CreateCollectionAsync($"collection{Random.Shared.Next()}"); + var ids = Enumerable.Range(0, 1001).Select(i => $"r{i}").ToList(); + await filler.GetCollectionClient(collection).AddAsync(new ChromaRecords(ids) { Embeddings = Enumerable.Repeat(new ReadOnlyMemory([0.5f, 0.5f]), 1001).ToList() }); + + var reader = new ChromaClient(BaseConfigurationOptions.WithBatchSplitting(1000), NewHttpClient()).GetCollectionClient(collection); + Assert.That((await reader.GetAsync(ids, include: ChromaGetInclude.None)).Select(x => x.Id), Is.EquivalentTo(ids)); + } + + // Get in pages of the batch size: all the records, a limit and an offset across pages, and ids beyond the batch size. + [Test] + public async Task GetBeyondTheBatchSize() + { + var client = new ChromaClient(BaseConfigurationOptions.WithBatchSplitting(3), HttpClient); + var collectionClient = client.GetCollectionClient(await client.CreateCollectionAsync($"collection{Random.Shared.Next()}")); + var ids = Enumerable.Range(0, 7).Select(i => $"r{i}").ToList(); + await collectionClient.AddAsync(new ChromaRecords(ids) { Embeddings = Enumerable.Repeat(new ReadOnlyMemory([0.5f, 0.5f]), 7).ToList() }); + + var all = await collectionClient.GetAsync(); + Assert.That(all.Select(x => x.Id), Is.EquivalentTo(ids)); + var page = await collectionClient.GetAsync(limit: 5, offset: 1); + Assert.That(page.Select(x => x.Id), Is.EqualTo(all.Skip(1).Take(5).Select(x => x.Id))); + Assert.That((await collectionClient.GetAsync(ids)).Select(x => x.Id), Is.EquivalentTo(ids)); + } + + // Chroma Cloud declares a max_batch_size of 1000 but takes 300 records per write: by default the client sends 300 at a time; with a + // larger limit of the caller, the rejected batch and the rest go in batches of the quota that its message tells. + [TestCase(null)] + [TestCase(1000)] + public async Task QuotaOfRecordsOfChromaCloud(int? maxBatchSize) + { + Assume.That(ChromaCloud, Is.True, "Only Chroma Cloud has the quota of 300 records."); + var options = maxBatchSize is { } limit ? BaseConfigurationOptions.WithBatchSplitting(limit) : BaseConfigurationOptions; + var client = new ChromaClient(options, HttpClient); + var collectionClient = client.GetCollectionClient(await client.CreateCollectionAsync($"collection{Random.Shared.Next()}")); + var ids = Enumerable.Range(0, 301).Select(i => $"r{i}").ToList(); + await collectionClient.AddAsync(new ChromaRecords(ids) { Embeddings = Enumerable.Repeat(new ReadOnlyMemory([0.5f, 0.5f]), 301).ToList() }); + Assert.That(await collectionClient.CountAsync(), Is.EqualTo(301)); + Assert.That((await collectionClient.GetAsync()).Count, Is.EqualTo(301)); + } +} diff --git a/ChromaDB.Client.Tests/BatchesAndFiltersTests.cs b/ChromaDB.Client.Tests/BatchesAndFiltersTests.cs new file mode 100644 index 0000000..789463d --- /dev/null +++ b/ChromaDB.Client.Tests/BatchesAndFiltersTests.cs @@ -0,0 +1,383 @@ +using System.Net; +using System.Text.Json; +using ChromaDB.Client.Models; +using NUnit.Framework; + +namespace ChromaDB.Client.Tests; + +[TestFixture] +public class BatchesAndFiltersTests +{ + static readonly ReadOnlyMemory Embedding = new([1f, 0f]); + static readonly ChromaConfigurationOptions Options = new("http://localhost:8000"); + + static ChromaRecords FiveRecords() => new(["a", "b", "c", "d", "e"]) + { + Embeddings = Enumerable.Repeat(Embedding, 5).ToList(), + Metadatas = Enumerable.Range(1, 5).Select(i => new Dictionary { ["i"] = i }).ToList(), + Documents = ["1", "2", "3", "4", "5"], + Uris = ["u1", null, "u3", null, "u5"], + }; + + // With WithBatchSplitting(false) the records go in one request; pre-flight-checks is asked only for the base64 of the embeddings. + [Test] + public async Task OneRequestWithoutBatchSplitting() + { + var handler = new Handler("""{"max_batch_size":2}"""); + await Client(Options.WithBatchSplitting(false), handler).AddAsync(FiveRecords()); + Assert.That(handler.Paths.Select(Last), Is.EqualTo(new[] { "pre-flight-checks", "add" })); + Assert.That(handler.Bodies.Single().GetProperty("ids").GetArrayLength(), Is.EqualTo(5)); + } + + // Batch splitting is on by default. + [Test] + public async Task BatchesByDefault() + { + var handler = new Handler("""{"max_batch_size":2}"""); + await Client(new ChromaConfigurationOptions("http://localhost:8000"), handler).AddAsync(FiveRecords()); + Assert.That(handler.Bodies.Select(b => b.GetProperty("ids").GetArrayLength()), Is.EqualTo(new[] { 2, 2, 1 })); + } + + [Test] + public async Task BatchesOfTheMaxBatchSize() + { + var handler = new Handler("""{"max_batch_size":2}"""); + await Client(Options.WithBatchSplitting(), handler).AddAsync(FiveRecords()); + Assert.That(handler.Paths.Select(Last), Is.EqualTo(new[] { "pre-flight-checks", "add", "add", "add" })); + Assert.That(handler.Bodies.Select(b => Strings(b, "ids")), Is.EqualTo(new[] { new[] { "a", "b" }, new[] { "c", "d" }, new[] { "e" } })); + Assert.That(handler.Bodies.Select(b => Strings(b, "documents")), Is.EqualTo(new[] { new[] { "1", "2" }, new[] { "3", "4" }, new[] { "5" } })); + Assert.That(handler.Bodies.Select(b => Strings(b, "uris")), Is.EqualTo(new[] { new[] { "u1", null }, new[] { "u3", null }, new[] { "u5" } })); + Assert.That(handler.Bodies.Select(b => b.GetProperty("metadatas").EnumerateArray().Select(m => m.GetProperty("i").GetInt32())), Is.EqualTo(new[] { new[] { 1, 2 }, new[] { 3, 4 }, new[] { 5 } })); + Assert.That(handler.Bodies.Select(b => b.GetProperty("embeddings").GetArrayLength()), Is.EqualTo(new[] { 2, 2, 1 })); + } + + // The limit is asked once for all the calls of a client. + [Test] + public async Task UpdateUpsertAndDeleteToo() + { + var handler = new Handler("""{"max_batch_size":2}"""); + var client = Client(Options.WithBatchSplitting(), handler); + await client.UpdateAsync(FiveRecords()); + await client.UpsertAsync(FiveRecords()); + await client.DeleteAsync(["a", "b", "c"], where: ChromaWhereOperator.Equal("k", 1)); + Assert.That(handler.Paths.Select(Last), Is.EqualTo(new[] { "pre-flight-checks", "update", "update", "update", "upsert", "upsert", "upsert", "delete", "delete" })); + Assert.That(handler.Bodies.Skip(6).Select(b => Strings(b, "ids")), Is.EqualTo(new[] { new[] { "a", "b" }, new[] { "c" } })); + Assert.That(handler.Bodies.Skip(6).Select(b => b.GetProperty("where").GetRawText()), Is.All.EqualTo("""{"k":{"$eq":1}}""")); + } + + [Test] + public async Task OneRequestWithinTheLimit() + { + var handler = new Handler("""{"max_batch_size":5}"""); + await Client(Options.WithBatchSplitting(), handler).AddAsync(FiveRecords()); + Assert.That(handler.Paths.Select(Last), Is.EqualTo(new[] { "pre-flight-checks", "add" })); + } + + // Chroma 0.4.10 has no pre-flight-checks: the records go in one request. + [Test] + public async Task OneRequestWithoutPreFlightChecks() + { + var handler = new Handler(null); + await Client(Options.WithBatchSplitting(), handler).AddAsync(FiveRecords()); + Assert.That(handler.Paths.Select(Last), Is.EqualTo(new[] { "pre-flight-checks", "add" })); + Assert.That(handler.Bodies.Single().GetProperty("ids").GetArrayLength(), Is.EqualTo(5)); + } + + // A failed batch stops the others; the earlier batches stay written. + [Test] + public async Task FailedBatchStops() + { + var handler = new Handler("""{"max_batch_size":2}""") { FailAt = 2 }; + Assert.ThrowsAsync(() => Client(Options.WithBatchSplitting(), handler).AddAsync(FiveRecords())); + Assert.That(handler.Paths.Select(Last), Is.EqualTo(new[] { "pre-flight-checks", "add", "add" })); + } + + [Test] + public void OtherOptionsKeepBatchSplitting() + => Assert.That(Options.WithBatchSplitting().WithTenant("t").WithMetadataValues(ChromaMetadataValues.Exact).BatchSplitting, Is.True); + + [Test] + public async Task FiltersAsTheJsonTheClientSends() + { + var where = ChromaWhereOperator.Equal("k", 1) & (ChromaWhereOperator.In("t", "a", "b") | ChromaWhereOperator.Contains("tags", "x")); + var whereDocument = ChromaWhereDocumentOperator.Contains("foo") | ChromaWhereDocumentOperator.NotContains("bar"); + Assert.That(where.ToString(), Is.EqualTo("""{"$and":[{"k":{"$eq":1}},{"$or":[{"t":{"$in":["a","b"]}},{"tags":{"$contains":"x"}}]}]}""")); + Assert.That(whereDocument.ToString(), Is.EqualTo("""{"$or":[{"$contains":"foo"},{"$not_contains":"bar"}]}""")); + + var handler = new Handler("""{"ids":[]}"""); + await Client(Options, handler).GetAsync(where: where, whereDocument: whereDocument); + Assert.That(handler.Bodies.Single().GetProperty("where").GetRawText(), Is.EqualTo(where.ToString())); + Assert.That(handler.Bodies.Single().GetProperty("where_document").GetRawText(), Is.EqualTo(whereDocument.ToString())); + } + + // An empty metadata goes as null in add, upsert and update: every tested Chroma treats the two the same. + [Test] + public async Task EmptyMetadataGoesAsNull() + { + var records = new ChromaRecords(["a", "b"]) + { + Embeddings = [Embedding, Embedding], + Metadatas = [new Dictionary(), new Dictionary { ["k"] = 1 }], + }; + foreach (var write in new Func[] { x => x.AddAsync(records), x => x.UpsertAsync(records), x => x.UpdateAsync(records) }) + { + var handler = new Handler("""{"max_batch_size":10}"""); + await write(Client(Options, handler)); + Assert.That(handler.Bodies.Single().GetProperty("metadatas").GetRawText(), Is.EqualTo("""[null,{"k":1}]""")); + } + } + + // A long $and or $or given as a dictionary, with an array, another list or from JSON, goes split as the same filter written with the + // operators (KD-4), in where and where_document, also nested; a short one goes as it is. + [Test] + public async Task LongListsFromDictionaries() + { + static string Contains(int i) => $$"""{"$contains":"w{{i:D4}}"}"""; + static string NotEqual(int i) => "{\"k\":{\"$ne\":" + i + "}}"; + var documents = "{\"$or\":[{\"$and\":[" + string.Join(",", Enumerable.Range(0, 1000).Select(Contains)) + "]},{\"$contains\":\"xyz\"}]}"; + var where = "{\"$and\":[" + string.Join(",", Enumerable.Range(0, 1000).Select(NotEqual)) + "]}"; + var documentsWithOperators = Enumerable.Range(0, 1000).Select(i => ChromaWhereDocumentOperator.Contains($"w{i:D4}")).Aggregate((a, b) => a & b) | ChromaWhereDocumentOperator.Contains("xyz"); + var whereWithOperators = Enumerable.Range(0, 1000).Select(i => ChromaWhereOperator.NotEqual("k", i)).Aggregate((a, b) => a & b); + + var expected = await Sent(whereWithOperators, documentsWithOperators); + Assert.That(JsonDocument.Parse(expected.WhereDocument).RootElement.GetProperty("$or")[0].GetProperty("$and").GetArrayLength(), Is.EqualTo(32)); + foreach (var (whereFilter, documentFilter) in new[] + { + (ChromaWhereOperator.FromDictionary(FromJson(where)), ChromaWhereDocumentOperator.FromDictionary(FromJson(documents))), + (ChromaWhereOperator.FromDictionary(AsLists(FromJson(where))), ChromaWhereDocumentOperator.FromDictionary(AsLists(FromJson(documents)))), + }) + { + var sent = await Sent(whereFilter, documentFilter); + Assert.That(sent.Where, Is.EqualTo(expected.Where)); + Assert.That(sent.WhereDocument, Is.EqualTo(expected.WhereDocument)); + } + var shortList = ChromaWhereDocumentOperator.FromDictionary(FromJson("""{"$and":[{"$contains":"abc"},{"$contains":"def"}]}""")); + Assert.That((await Sent(null, shortList)).WhereDocument, Is.EqualTo("""{"$and":[{"$contains":"abc"},{"$contains":"def"}]}""")); + } + + // Operands that can be read only once: the client copies the dictionary when it gets it and reads each list once, so the request has + // them all, short or long, in where and where_document; later changes to the dictionary do not change the filter. + [TestCase(2)] + [TestCase(1000)] + public async Task OperandsThatCanBeReadOnce(int count) + { + var documents = ChromaWhereDocumentOperator.FromDictionary(new Dictionary { ["$and"] = new Once(Enumerable.Range(0, count).Select(i => (object)new Dictionary { ["$contains"] = $"w{i:D4}" })) }); + var whereDictionary = new Dictionary { ["$and"] = new Once(Enumerable.Range(0, count).Select(i => (object)new Dictionary { ["k"] = new Dictionary { ["$ne"] = i } })) }; + var where = ChromaWhereOperator.FromDictionary(whereDictionary); + whereDictionary["$or"] = "changed"; + var expected = await Sent( + Enumerable.Range(0, count).Select(i => ChromaWhereOperator.NotEqual("k", i)).Aggregate((a, b) => a & b), + Enumerable.Range(0, count).Select(i => ChromaWhereDocumentOperator.Contains($"w{i:D4}")).Aggregate((a, b) => a & b)); + var sent = await Sent(where, documents); + Assert.That(sent.Where, Is.EqualTo(expected.Where)); + Assert.That(sent.WhereDocument, Is.EqualTo(expected.WhereDocument)); + Assert.That(where.ToString(), Is.EqualTo(expected.Where)); + } + + static async Task<(string Where, string WhereDocument)> Sent(ChromaWhereOperator? where, ChromaWhereDocumentOperator whereDocument) + { + var handler = new Handler("""{"ids":[]}"""); + await Client(Options, handler).GetAsync(where: where, whereDocument: whereDocument); + var body = handler.Bodies.Single(); + return (where is null ? "" : body.GetProperty("where").GetRawText(), body.GetProperty("where_document").GetRawText()); + } + + // A sequence that can be enumerated only once. + sealed class Once(IEnumerable items) : IEnumerable + { + bool _read; + + public IEnumerator GetEnumerator() + { + if (_read) + { + throw new InvalidOperationException("The sequence was read twice."); + } + _read = true; + return items.GetEnumerator(); + } + + System.Collections.IEnumerator System.Collections.IEnumerable.GetEnumerator() => GetEnumerator(); + } + + static Dictionary FromJson(string json) => JsonSerializer.Deserialize>(json)!; + + // The same filter with .NET values, its lists as List instead of arrays. + static Dictionary AsLists(Dictionary filter) + => filter.ToDictionary(pair => pair.Key, pair => Clr((JsonElement)pair.Value)); + + static object Clr(JsonElement element) => element.ValueKind switch + { + JsonValueKind.Object => element.EnumerateObject().ToDictionary(property => property.Name, property => Clr(property.Value)), + JsonValueKind.Array => element.EnumerateArray().Select(Clr).ToList(), + JsonValueKind.String => element.GetString()!, + _ => element.GetInt64(), + }; + + // FromDictionary writes the dictionary as the request writes it: a JsonNode, a typed dictionary and a JsonArray go as their JSON. + [Test] + public async Task FromDictionaryWithValuesOfOtherTypes() + { + var where = ChromaWhereOperator.FromDictionary(new Dictionary + { + ["$and"] = new object[] + { + new Dictionary { ["year"] = System.Text.Json.Nodes.JsonNode.Parse("""{"$gte":2020}""")! }, + new Dictionary { ["lang"] = new Dictionary { ["$eq"] = "en" } }, + new Dictionary { ["n"] = new Dictionary { ["$in"] = System.Text.Json.Nodes.JsonNode.Parse("[1,2]")! } }, + }, + }); + var expected = """{"$and":[{"year":{"$gte":2020}},{"lang":{"$eq":"en"}},{"n":{"$in":[1,2]}}]}"""; + Assert.That(where.ToString(), Is.EqualTo(expected)); + var handler = new Handler("""{"ids":[]}"""); + await Client(Options, handler).GetAsync(where: where); + Assert.That(handler.Bodies.Single().GetProperty("where").GetRawText(), Is.EqualTo(expected)); + } + + // A value that the client cannot write throws when FromDictionary gets it, not when the filter goes. + [Test] + public void FromDictionaryWithAValueThatCannotBeWritten() + { + Action value = () => { }; + Assert.Throws(() => ChromaWhereOperator.FromDictionary(new Dictionary { ["k"] = value })); + Assert.Throws(() => ChromaWhereDocumentOperator.FromDictionary(new Dictionary { ["$contains"] = value })); + } + + // A filter given as a dictionary goes in the request as it is. + [Test] + public async Task FilterFromDictionaryAsItIs() + { + var where = ChromaWhereOperator.FromDictionary(new Dictionary + { + ["$and"] = new object[] + { + new Dictionary { ["k"] = new Dictionary { ["$eq"] = 1 } }, + new Dictionary { ["t"] = new Dictionary { ["$in"] = new object[] { "a", "b" } } }, + }, + }); + + var handler = new Handler("""{"ids":[]}"""); + await Client(Options, handler).GetAsync(where: where); + Assert.That(handler.Bodies.Single().GetProperty("where").GetRawText(), Is.EqualTo("""{"$and":[{"k":{"$eq":1}},{"t":{"$in":["a","b"]}}]}""")); + Assert.Throws(() => ChromaWhereOperator.FromDictionary(null!)); + } + + // A chain of the same operator goes as one list, as the Python client writes it: nested, a chain of 32 filters went beyond the + // 64 levels of System.Text.Json. + [Test] + public async Task ChainsOfTheSameOperatorInOneList() + { + ChromaWhereOperator Eq(int i) => ChromaWhereOperator.Equal("i", i); + Assert.That((Eq(1) & Eq(2) & Eq(3)).ToString(), Is.EqualTo("""{"$and":[{"i":{"$eq":1}},{"i":{"$eq":2}},{"i":{"$eq":3}}]}""")); + Assert.That((Eq(1) & (Eq(2) & Eq(3))).ToString(), Is.EqualTo((Eq(1) & Eq(2) & Eq(3)).ToString())); + Assert.That(((Eq(1) | Eq(2)) & Eq(3) & (Eq(4) | Eq(5) | Eq(6))).ToString(), + Is.EqualTo("""{"$and":[{"$or":[{"i":{"$eq":1}},{"i":{"$eq":2}}]},{"i":{"$eq":3}},{"$or":[{"i":{"$eq":4}},{"i":{"$eq":5}},{"i":{"$eq":6}}]}]}""")); + + // A filter used in two chains stays as it was. + var both = Eq(1) & Eq(2); + Assert.That(((both & Eq(3)).ToString(), (both | Eq(4)).ToString(), both.ToString()), Is.EqualTo(( + """{"$and":[{"i":{"$eq":1}},{"i":{"$eq":2}},{"i":{"$eq":3}}]}""", + """{"$or":[{"$and":[{"i":{"$eq":1}},{"i":{"$eq":2}}]},{"i":{"$eq":4}}]}""", + """{"$and":[{"i":{"$eq":1}},{"i":{"$eq":2}}]}"""))); + + ChromaWhereOperator Chain(int count, Func combine) + { + var chain = Eq(0); + for (var i = 1; i < count; i++) + { + chain = combine(chain, Eq(i)); + } + return chain; + } + // Up to 900 deep, the depth of the SQLite expression of a single server, one list; beyond, ⌈√n⌉ lists with the same filters in + // the same order. + Assert.That(Lists(Chain(900, (x, y) => x & y).ToString()), Is.EqualTo(new[] { 900 })); + Assert.That(Lists(Chain(901, (x, y) => x & y).ToString()), Is.EqualTo(new[] { 31, 30, 30 }.Concat(Enumerable.Repeat(29, 29)).ToArray())); + var and = Chain(10_000, (x, y) => x & y); + Assert.That(Lists(and.ToString()), Is.EqualTo(new[] { 100 }.Concat(Enumerable.Repeat(100, 100)).ToArray())); + Assert.That(Leaves(and.ToString()), Is.EqualTo(Enumerable.Range(0, 10_000))); + var or = Chain(10_000, (x, y) => x || y); + Assert.That(JsonDocument.Parse(or.ToString()).RootElement.GetProperty("$or")[99].GetProperty("$or")[99].GetRawText(), Is.EqualTo("""{"i":{"$eq":9999}}""")); + Assert.That(Leaves(or.ToString()), Is.EqualTo(Enumerable.Range(0, 10_000))); + // Two lists of 600 under an $or are 601 deep: they go as they are. + var lists = Chain(600, (x, y) => x & y) | Chain(600, (x, y) => x & y); + Assert.That(Lists(lists.ToString()), Is.EqualTo(new[] { 2, 600, 600 })); + + var document = ChromaWhereDocumentOperator.Contains("w0"); + for (var i = 1; i < 10_000; i++) + { + document &= ChromaWhereDocumentOperator.Contains($"w{i}"); + } + Assert.That(Lists(document.ToString()), Is.EqualTo(new[] { 100 }.Concat(Enumerable.Repeat(100, 100)).ToArray())); + + var handler = new Handler("""{"ids":[]}"""); + await Client(Options, handler).GetAsync(where: and, whereDocument: document); + Assert.That((handler.Bodies.Single().GetProperty("where").GetRawText(), handler.Bodies.Single().GetProperty("where_document").GetRawText()), + Is.EqualTo((and.ToString(), document.ToString()))); + } + + // The lengths of the $and and $or lists of a filter, depth first. + static int[] Lists(string filter) + { + var lengths = new List(); + void Walk(JsonElement element) + { + if (element.ValueKind == JsonValueKind.Object && element.EnumerateObject().ToList() is [{ Name: "$and" or "$or" } list]) + { + lengths.Add(list.Value.GetArrayLength()); + foreach (var item in list.Value.EnumerateArray()) + { + Walk(item); + } + } + } + Walk(JsonDocument.Parse(filter).RootElement); + return [.. lengths]; + } + + // The values of the {"i":{"$eq":n}} filters, in their order. + static int[] Leaves(string filter) + => [.. System.Text.RegularExpressions.Regex.Matches(filter, """\{"i":\{"\$eq":(\d+)\}\}""").Select(x => int.Parse(x.Groups[1].Value))]; + + // Every tested Chroma rejects $in and $nin without values: they are None and All, which FilterConstantsTests tests. + [Test] + public void InAndNotIn() + { + Assert.That(ChromaWhereOperator.In("k", 1).ToString(), Is.EqualTo("""{"k":{"$in":[1]}}""")); + Assert.That(ChromaWhereOperator.NotIn("k", "a").ToString(), Is.EqualTo("""{"k":{"$nin":["a"]}}""")); + } + + static string Last(string path) => path.Substring(path.LastIndexOf('/') + 1); + + static string?[] Strings(JsonElement body, string property) + => body.GetProperty(property).EnumerateArray().Select(x => x.GetString()).ToArray(); + + static ChromaCollectionClient Client(ChromaConfigurationOptions options, HttpMessageHandler handler) + => new(new ChromaCollection("collection") { Id = Guid.NewGuid() }, options, new HttpClient(handler)); + + // Answers pre-flight-checks with the given body, or 404 when it is null, and the other requests with true. + sealed class Handler(string? preFlightChecks) : HttpMessageHandler + { + public List Paths { get; } = []; + public List Bodies { get; } = []; + public int FailAt { get; init; } = -1; + int _records; + + protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + Paths.Add(request.RequestUri!.AbsolutePath); + if (request.RequestUri.AbsolutePath.EndsWith("/pre-flight-checks")) + { + return preFlightChecks is null + ? new HttpResponseMessage(HttpStatusCode.NotFound) { Content = new StringContent("""{"detail":"Not Found"}""") } + : new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent(preFlightChecks) }; + } + Bodies.Add(JsonDocument.Parse(await request.Content!.ReadAsStringAsync(cancellationToken)).RootElement.Clone()); + if (++_records == FailAt) + { + return new HttpResponseMessage(HttpStatusCode.InternalServerError) { Content = new StringContent("""{"error":"InternalError","message":"failed"}""") }; + } + return new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent(request.RequestUri.AbsolutePath.EndsWith("/get") ? """{"ids":[]}""" : "true") }; + } + } +} diff --git a/ChromaDB.Client.Tests/Bm25IndexTests.cs b/ChromaDB.Client.Tests/Bm25IndexTests.cs new file mode 100644 index 0000000..cfcfb54 --- /dev/null +++ b/ChromaDB.Client.Tests/Bm25IndexTests.cs @@ -0,0 +1,107 @@ +using System.Net; +using System.Text.Json; +using ChromaDB.Client.Models; +using NUnit.Framework; + +namespace ChromaDB.Client.Tests; + +// A BM25 index on the text of a key: the client names its key, finds it, and keeps the key for the vectors it computes. +[TestFixture] +public class Bm25IndexTests +{ + static readonly ReadOnlyMemory Embedding = new([1f, 0f]); + + [Test] + public void KeysNamedAfterTheSource() + { + var indexes = new ChromaCollectionSchema().WithBm25Index("title").WithBm25Index(ChromaSearchKeys.Document).ToString(); + Assert.That(indexes, Does.Contain("\"title_bm25\":").And.Contain("\"document_bm25\":")); + Assert.That(indexes, Does.Contain("\"source_key\":\"title\"").And.Contain("\"source_key\":\"#document\"")); + Assert.That(() => new ChromaCollectionSchema().WithBm25Index(null!), Throws.ArgumentNullException); + } + + // The index of the document copy key can be the one on the documents. + [Test] + public async Task FindBm25Index() + { + var client = Client(new FakeServer(), Bm25Index("document_bm25", "#document") + "," + Bm25Index("title_bm25", "title")); + Assert.That((await client.FindBm25IndexAsync("title"))?.Key, Is.EqualTo("title_bm25")); + Assert.That(await client.FindBm25IndexAsync("text"), Is.Null); + Assert.That((await client.WithDocumentCopyKey("text").FindBm25IndexAsync("text"))?.Key, Is.EqualTo("document_bm25")); + } + + // A write gives the key of the index a sparse vector, or null to delete it, and nothing else. + [Test] + public async Task KeyOfTheIndex() + { + var client = Client(new FakeServer(), Bm25Index("title_bm25", "title")); + var records = new ChromaRecords(["a"]) { Embeddings = [Embedding], Metadatas = [new Dictionary { ["title"] = "apple", ["title_bm25"] = "mine" }] }; + Assert.That(() => client.AddAsync(records), Throws.ArgumentException.With.Message.Contains("title_bm25")); + await client.AddAsync(new ChromaRecords(["a"]) { Embeddings = [Embedding], Metadatas = [new Dictionary { ["title"] = "apple", ["title_bm25"] = new ChromaSparseVector([1], [1f]) }] }); + } + + // An index added with ifSupported goes to Chroma Cloud only: a single server rejects sparse vector indexes, and gets the schema + // without it, or no schema when nothing else is in it. + [TestCase("https://api.trychroma.com", "title_bm25", "title,title_bm25", "title_bm25", "title_bm25", "title_bm25")] + [TestCase("http://localhost:8000", null, "title", "title_bm25", "", "")] + public async Task IndexIfSupported(string uri, string? alone, string? withAnotherIndex, string? addedAgain, string? withDefaults, string? withCmek) + { + var server = new FakeServer(); + var client = new ChromaClient(new ChromaConfigurationOptions(uri), new HttpClient(server)); + var schema = new ChromaCollectionSchema().WithBm25Index("title", ifSupported: true); + foreach (var other in new[] { schema, schema.WithIndex(ChromaSchemaIndex.StringInverted, "title"), schema.WithBm25Index("title"), + schema.WithIndex(ChromaSchemaIndex.StringInverted), schema.WithGcpCmek("projects/p/locations/l/keyRings/r/cryptoKeys/k") }) + { + await client.GetOrCreateCollectionAsync(new ChromaCollectionDefinition("c") { Schema = other }); + } + await client.CreateCollectionAsync(new ChromaCollectionDefinition("c") { Schema = schema }); + Assert.That(server.Schemas, Is.EqualTo(new[] { alone, withAnotherIndex, addedAgain, withDefaults, withCmek, alone })); + } + + // On Chroma Cloud an index added with ifSupported whose key is beyond 36 bytes is left out: Chroma Cloud would reject every write + // with the key. Without ifSupported the client throws. + [Test] + public async Task KeyBeyondTheQuotaOfChromaCloud() + { + var server = new FakeServer(); + var client = new ChromaClient(new ChromaConfigurationOptions("https://api.trychroma.com"), new HttpClient(server)); + var longKey = new string('k', 32); + await client.CreateCollectionAsync(new ChromaCollectionDefinition("c") { Schema = new ChromaCollectionSchema().WithBm25Index(longKey, ifSupported: true) }); + await client.CreateCollectionAsync(new ChromaCollectionDefinition("c") { Schema = new ChromaCollectionSchema().WithBm25Index(longKey, ifSupported: true).WithBm25Index("title", ifSupported: true) }); + Assert.That(server.Schemas, Is.EqualTo(new[] { null, "title_bm25" })); + Assert.That(() => client.CreateCollectionAsync(new ChromaCollectionDefinition("c") { Schema = new ChromaCollectionSchema().WithBm25Index(longKey) }), Throws.ArgumentException); + } + + static ChromaCollectionClient Client(HttpMessageHandler handler, string indexes) + => new(new ChromaCollection("c") { Id = Guid.NewGuid(), SchemaJson = JsonDocument.Parse("{\"keys\":{" + indexes + "}}").RootElement.Clone() }, + new ChromaConfigurationOptions("http://localhost:8000"), new HttpClient(handler)); + + // A sparse vector index with chroma_bm25 as the schema of a collection gives it. + static string Bm25Index(string key, string sourceKey) + => "\"" + key + "\":{\"sparse_vector\":{\"sparse_vector_index\":{\"enabled\":true,\"config\":{\"source_key\":\"" + sourceKey + "\",\"bm25\":true," + + "\"embedding_function\":{\"type\":\"known\",\"name\":\"chroma_bm25\",\"config\":{\"k\":1.2,\"b\":0.75,\"avg_doc_length\":256,\"token_max_length\":40}}}}}}"; + + // Answers the version, pre-flight-checks with a batch size, a new collection with an empty schema, and the writes with nothing; + // records the keys of the schema of each new collection, or null without a schema. + sealed class FakeServer : HttpMessageHandler + { + public List Schemas { get; } = []; + + protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + var path = request.RequestUri!.AbsolutePath; + if (path.EndsWith("/collections")) + { + var body = JsonDocument.Parse(await request.Content!.ReadAsStringAsync(cancellationToken)).RootElement; + Schemas.Add(body.TryGetProperty("schema", out var schema) && schema.ValueKind == JsonValueKind.Object + ? string.Join(",", schema.GetProperty("keys").EnumerateObject().Select(x => x.Name).OrderBy(x => x, StringComparer.Ordinal)) + : null); + } + var answer = path.EndsWith("/version") ? "\"1.5.9\"" + : path.EndsWith("/pre-flight-checks") ? """{"max_batch_size":100,"supports_base64_encoding":false}""" + : path.EndsWith("/collections") ? """{"id":"11111111-2222-3333-4444-555555555555","name":"c","schema":{"defaults":{},"keys":{}}}""" + : "{}"; + return new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent(answer) }; + } + } +} diff --git a/ChromaDB.Client.Tests/CancellationTests.cs b/ChromaDB.Client.Tests/CancellationTests.cs new file mode 100644 index 0000000..c76e9ee --- /dev/null +++ b/ChromaDB.Client.Tests/CancellationTests.cs @@ -0,0 +1,98 @@ +using ChromaDB.Client.Models; +using NUnit.Framework; + +namespace ChromaDB.Client.Tests; + +[TestFixture] +public class CancellationTests +{ + static readonly ChromaConfigurationOptions Options = new(uri: "http://localhost:8000/api/v2/"); + static readonly ChromaCollection Collection = new("collection") { Id = Guid.NewGuid() }; + static readonly ReadOnlyMemory Embedding = new([1f, 0.5f, 0f, -0.5f, -1f]); + + static IEnumerable ClientCalls() + { + yield return Case("ListCollections", (c, t) => c.ListCollectionsAsync(cancellationToken: t)); + yield return Case("ListCollectionsPage", (c, t) => c.ListCollectionsAsync(limit: 2, cancellationToken: t)); + yield return Case("GetCollection", (c, t) => c.GetCollectionAsync("collection", cancellationToken: t)); + yield return Case("CollectionExists", (c, t) => c.CollectionExistsAsync("collection", cancellationToken: t)); + yield return Case("GetCollectionById", (c, t) => c.GetCollectionByIdAsync(Guid.NewGuid(), cancellationToken: t)); + yield return Case("Heartbeat", (c, t) => c.HeartbeatAsync(t)); + yield return Case("CreateCollection", (c, t) => c.CreateCollectionAsync("collection", cancellationToken: t)); + yield return Case("CreateCollectionDefinition", (c, t) => c.CreateCollectionAsync(new ChromaCollectionDefinition("collection"), cancellationToken: t)); + yield return Case("GetOrCreateCollectionDefinition", (c, t) => c.GetOrCreateCollectionAsync(new ChromaCollectionDefinition("collection"), cancellationToken: t)); + yield return Case("GetOrCreateCollection", (c, t) => c.GetOrCreateCollectionAsync("collection", cancellationToken: t)); + yield return Case("DeleteCollection", (c, t) => c.DeleteCollectionAsync("collection", cancellationToken: t)); + yield return Case("GetVersion", (c, t) => c.GetVersionAsync(t)); + yield return Case("GetUserIdentity", (c, t) => c.GetUserIdentityAsync(t)); + yield return Case("GetPreFlightChecks", (c, t) => c.GetPreFlightChecksAsync(t)); + yield return Case("Reset", (c, t) => c.ResetAsync(t)); + yield return Case("CountCollections", (c, t) => c.CountCollectionsAsync(cancellationToken: t)); + yield return Case("CreateTenant", (c, t) => c.CreateTenantAsync("tenant", t)); + yield return Case("GetTenant", (c, t) => c.GetTenantAsync("tenant", t)); + yield return Case("CreateDatabase", (c, t) => c.CreateDatabaseAsync("database", cancellationToken: t)); + yield return Case("GetDatabase", (c, t) => c.GetDatabaseAsync("database", cancellationToken: t)); + yield return Case("ListDatabases", (c, t) => c.ListDatabasesAsync(cancellationToken: t)); + yield return Case("ListDatabasesPage", (c, t) => c.ListDatabasesAsync(limit: 2, cancellationToken: t)); + yield return Case("DeleteDatabase", (c, t) => c.DeleteDatabaseAsync("database", cancellationToken: t)); + } + + static IEnumerable CollectionClientCalls() + { + yield return Case("GetById", (c, t) => c.GetAsync("id", cancellationToken: t)); + yield return Case("Get", (c, t) => c.GetAsync(cancellationToken: t)); + yield return Case("QuerySingle", (c, t) => c.QueryAsync(Embedding, cancellationToken: t)); + yield return Case("Query", (c, t) => c.QueryAsync([Embedding], cancellationToken: t)); + yield return Case("QueryWithIds", (c, t) => c.QueryAsync(new ChromaQuery([Embedding]) { Ids = ["id"] }, t)); + yield return Case("Add", (c, t) => c.AddAsync(["id"], embeddings: [Embedding], cancellationToken: t)); + yield return Case("AddRecords", (c, t) => c.AddAsync(new ChromaRecords(["id"]) { Embeddings = [Embedding] }, t)); + yield return Case("UpdateRecords", (c, t) => c.UpdateAsync(new ChromaRecords(["id"]), t)); + yield return Case("UpsertRecords", (c, t) => c.UpsertAsync(new ChromaRecords(["id"]) { Embeddings = [Embedding] }, t)); + yield return Case("Update", (c, t) => c.UpdateAsync(["id"], cancellationToken: t)); + yield return Case("Upsert", (c, t) => c.UpsertAsync(["id"], embeddings: [Embedding], cancellationToken: t)); + yield return Case("Delete", (c, t) => c.DeleteAsync(["id"], cancellationToken: t)); + yield return Case("Count", (c, t) => c.CountAsync(t)); + yield return Case("Peek", (c, t) => c.PeekAsync(cancellationToken: t)); + yield return Case("Modify", (c, t) => c.ModifyAsync(name: "collection", cancellationToken: t)); + } + + static TestCaseData Case(string name, Func call) + => new TestCaseData(call).SetName($"{typeof(TClient).Name}.{name}"); + + [TestCaseSource(nameof(ClientCalls))] + public async Task ClientPassesTheToken(Func call) + { + using var httpClient = new HttpClient(new PendingHandler()); + var client = new ChromaClient(Options, httpClient); + using var cts = new CancellationTokenSource(TimeSpan.FromMilliseconds(100)); + await Assert.ThatAsync(() => call(client, cts.Token), Throws.InstanceOf()); + } + + [TestCaseSource(nameof(CollectionClientCalls))] + public async Task CollectionClientPassesTheToken(Func call) + { + using var httpClient = new HttpClient(new PendingHandler()); + var client = new ChromaCollectionClient(Collection, Options, httpClient); + using var cts = new CancellationTokenSource(TimeSpan.FromMilliseconds(100)); + await Assert.ThatAsync(() => call(client, cts.Token), Throws.InstanceOf()); + } + + // A timeout of the HttpClient is not a cancellation requested by the caller: it stays a ChromaException. + [Test] + public async Task TimeoutIsChromaException() + { + using var httpClient = new HttpClient(new PendingHandler()) { Timeout = TimeSpan.FromMilliseconds(100) }; + var client = new ChromaClient(Options, httpClient); + await Assert.ThatAsync(() => client.HeartbeatAsync(), Throws.InstanceOf()); + } + + // Never answers: the request ends only when its token is canceled. + sealed class PendingHandler : HttpMessageHandler + { + protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + await Task.Delay(Timeout.Infinite, cancellationToken); + throw new InvalidOperationException(); + } + } +} diff --git a/ChromaDB.Client.Tests/ChromaBm25Tests.cs b/ChromaDB.Client.Tests/ChromaBm25Tests.cs new file mode 100644 index 0000000..b17036d --- /dev/null +++ b/ChromaDB.Client.Tests/ChromaBm25Tests.cs @@ -0,0 +1,95 @@ +using ChromaDB.Client.Models; +using NUnit.Framework; + +namespace ChromaDB.Client.Tests; + +// The vectors of the BM25 function of the Python client of Chroma 1.5.9 (ChromaBm25EmbeddingFunction, with snowballstemmer 3.1.1) +// for the same texts: the indices are equal, and the values are the float of the double that Python computes. +[TestFixture] +public class ChromaBm25Tests +{ + [TestCase("The quick brown fox jumps over the lazy dog.", new uint[] { 226376294, 741580288, 771291085, 1312749093, 1621867415, 1913189942 }, new double[] { 1.6652868125369606, 1.6652868125369606, 1.6652868125369606, 1.6652868125369606, 1.6652868125369606, 1.6652868125369606 })] + [TestCase("Running runners ran; they're RUNNING again!!! Generously, generalizations.", new uint[] { 243905464, 567658162, 946033505, 1026658409, 1068447005 }, new double[] { 1.8956580276001347, 1.6652868125369606, 1.6652868125369606, 1.6652868125369606, 1.6652868125369606 })] + [TestCase("the and of a", new uint[0], new double[0])] + [TestCase("", new uint[0], new double[0])] + [TestCase("repeated repeated repeated word word unique", new uint[] { 209724451, 641155872, 968174432 }, new double[] { 1.6652868125369606, 1.9872971065631617, 1.8956580276001347 })] + [TestCase("Hello, world \u2014 it's a beautiful day... e-mail foo_bar 3.14", new uint[] { 74040069, 116628897, 264741300, 520205939, 613153351, 1572539931, 1701593959, 1749031367, 2006997753 }, new double[] { 1.6520973892637139, 1.6520973892637139, 1.6520973892637139, 1.6520973892637139, 1.6520973892637139, 1.6520973892637139, 1.6520973892637139, 1.6520973892637139, 1.6520973892637139 })] + [TestCase("\u039F\u0394\u039F\u03A3 \u03A3\u039F\u03A6\u039F\u03A3 \u03BB\u03CC\u03B3\u03BF\u03C2", new uint[] { 17478605, 214956135, 1332225548 }, new double[] { 1.6786885245901642, 1.6786885245901642, 1.6786885245901642 })] + [TestCase("\u0130stanbul \u015E\u0130MD\u0130 caf\u00E9 na\u00EFve", new uint[] { 605818632, 756638508, 1467803446, 1524208432 }, new double[] { 1.6741973840665876, 1.6741973840665876, 1.6741973840665876, 1.6741973840665876 })] + [TestCase("emoji \U0001F600 test \U0001F680\U0001F680 rocket", new uint[] { 430021617, 515862427, 1167338989 }, new double[] { 1.6786885245901642, 1.6786885245901642, 1.6786885245901642 })] + [TestCase("supercalifragilisticexpialidocious xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", new uint[] { 158684264 }, new double[] { 1.6877434821696136 })] + [TestCase("tab\u0009separated\u000Anew line \u001Cfile\u001Dgroup", new uint[] { 422208903, 437367475, 515475255, 640477688, 892506137, 1021187622 }, new double[] { 1.6652868125369606, 1.6652868125369606, 1.6652868125369606, 1.6652868125369606, 1.6652868125369606, 1.6652868125369606 })] + [TestCase("1872d942 apple", new uint[] { 1085451005, 2147483648 }, new double[] { 1.6832038254632398, 1.6832038254632398 })] + public void SameVectorsAsPython(string text, uint[] indices, double[] values) + { + var vector = new ChromaBm25().Embed(text); + Assert.That(vector.Indices, Is.EqualTo(indices)); + Assert.That(vector.Values, Is.EqualTo(values.Select(x => (float)x))); + Assert.That(vector.Tokens, Is.Null); + } + + [Test] + public void SameVectorsAsPythonWithOtherSettings() + { + var bm25 = new ChromaBm25(k: 1.5, b: 0.6, avgDocLength: 100, tokenMaxLength: 10, stopwords: ["quick", "fox"], includeTokens: true); + var vector = bm25.Embed("The quick brown fox jumps over the lazy dog. Foxes!"); + Assert.That(vector.Indices, Is.EqualTo(new uint[] { 226376294, 741580288, 1132748958, 1312749093, 1621867415, 1656251611, 1913189942 })); + Assert.That(vector.Values, Is.EqualTo(new double[] { 1.4952153110047846, 1.4952153110047846, 1.87125748502994, 1.4952153110047846, 1.4952153110047846, 1.4952153110047846, 1.4952153110047846 }.Select(x => (float)x))); + Assert.That(vector.Tokens, Is.EqualTo(new[] { "lazi", "brown", "the", "dog", "fox", "over", "jump" })); + } + + // Python lowercases both the stopword and the text to "i\u0307\u03C2": the final sigma skips the combining dot, which is not a word character. + [Test] + public void StopwordsLowercasedAsPython() + { + Assert.That(new ChromaBm25(stopwords: ["i\u0307\u03A3"]).Embed("\u0130\u03A3").Indices, Is.Empty); + } + + // The configuration of chroma_bm25 as the Python client writes it in a schema: the stopwords only when they are not the default ones. + [Test] + public async Task Reference() + { + Assert.That(await Json(new ChromaBm25().Reference), Is.EqualTo("""{"type":"known","name":"chroma_bm25","config":{"k":1.2,"b":0.75,"avg_doc_length":256.0,"token_max_length":40,"include_tokens":false}}""")); + Assert.That(await Json(new ChromaBm25(k: 1.5, stopwords: ["quick", "fox"], includeTokens: true).Reference), + Is.EqualTo("""{"type":"known","name":"chroma_bm25","config":{"k":1.5,"b":0.75,"avg_doc_length":256.0,"token_max_length":40,"include_tokens":true,"stopwords":["quick","fox"]}}""")); + } + + [Test] + public void EmbedMany() + { + var bm25 = new ChromaBm25(); + var vectors = bm25.Embed(["apple pie", "", "banana split"]); + Assert.That(vectors.Select(x => x.Indices.Count), Is.EqualTo(new[] { 2, 0, 2 })); + Assert.That(vectors[0].Indices, Is.EqualTo(bm25.Embed("apple pie").Indices)); + } + + // The schema keeps the reference as JSON: the request of a collection created with it. + static async Task Json(ChromaEmbeddingFunctionReference reference) + { + var handler = new RecordingHandler(); + var client = new ChromaClient(new ChromaConfigurationOptions("http://localhost:8000"), new HttpClient(handler)); + await client.CreateCollectionAsync(new ChromaCollectionDefinition("c") { Schema = new ChromaCollectionSchema().WithSparseVectorIndex("v", "#document", bm25: true, reference) }); + using var body = System.Text.Json.JsonDocument.Parse(handler.Body!); + return body.RootElement.GetProperty("schema").GetProperty("keys").GetProperty("v").GetProperty("sparse_vector").GetProperty("sparse_vector_index") + .GetProperty("config").GetProperty("embedding_function").GetRawText(); + } + + sealed class RecordingHandler : HttpMessageHandler + { + public string? Body { get; private set; } + + protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + // The version, which the client asks for a collection without a space: a 1.x server. + if (request.RequestUri!.AbsolutePath.EndsWith("/version")) + { + return new HttpResponseMessage(System.Net.HttpStatusCode.OK) { Content = new StringContent("\"1.0.0\"") }; + } + Body = await request.Content!.ReadAsStringAsync(cancellationToken); + return new HttpResponseMessage(System.Net.HttpStatusCode.OK) + { + Content = new StringContent("""{"id":"11111111-2222-3333-4444-555555555555","name":"c","schema":{"defaults":{},"keys":{}}}"""), + }; + } + } +} diff --git a/ChromaDB.Client.Tests/ChromaDB.Client.Tests.csproj b/ChromaDB.Client.Tests/ChromaDB.Client.Tests.csproj index 8cb9a62..c7912fc 100644 --- a/ChromaDB.Client.Tests/ChromaDB.Client.Tests.csproj +++ b/ChromaDB.Client.Tests/ChromaDB.Client.Tests.csproj @@ -1,9 +1,11 @@ - + net8.0 enable enable false + 1.19.1 + $(DefineConstants);CHROMA_CLIENT_NETSTANDARD2_0 @@ -13,13 +15,21 @@ all runtime; build; native; contentfiles; analyzers; buildtransitive - + + + + + all runtime; build; native; contentfiles; analyzers; buildtransitive - + + + + + diff --git a/ChromaDB.Client.Tests/ChromaMetadataConvertTests.cs b/ChromaDB.Client.Tests/ChromaMetadataConvertTests.cs new file mode 100644 index 0000000..b4a8744 --- /dev/null +++ b/ChromaDB.Client.Tests/ChromaMetadataConvertTests.cs @@ -0,0 +1,134 @@ +using NUnit.Framework; + +namespace ChromaDB.Client.Tests; + +// The values that go in metadata, and back as the type of the caller: dates as round-trip text, a DateTimeOffset in UTC. +[TestFixture] +public class ChromaMetadataConvertTests +{ + static readonly DateTimeOffset Opened = new(2026, 10, 4, 12, 30, 0, TimeSpan.FromHours(2)); + + [Test] + public void ScalarsStayAsTheyAre() + { + foreach (var value in new object[] { "text", true, 1, 2L, 1.5f, 2.5 }) + { + Assert.That(ChromaMetadataConvert.ToMetadataValue(value), Is.EqualTo(value)); + } + Assert.That(ChromaMetadataConvert.ToMetadataValue(null), Is.Null); + } + + // Equal instants are equal text, as == compares them; a DateTime keeps its Kind. + [Test] + public void Dates() + { + Assert.That(ChromaMetadataConvert.ToMetadataValue(Opened), Is.EqualTo("2026-10-04T10:30:00.0000000+00:00")); + Assert.That(ChromaMetadataConvert.ToMetadataValue(Opened.ToUniversalTime()), Is.EqualTo("2026-10-04T10:30:00.0000000+00:00")); + Assert.That(ChromaMetadataConvert.ToMetadataValue(new DateTime(2026, 10, 4, 10, 30, 0, DateTimeKind.Utc)), Is.EqualTo("2026-10-04T10:30:00.0000000Z")); + Assert.That(ChromaMetadataConvert.ToMetadataValue(new DateTime(2026, 10, 4, 10, 30, 0, DateTimeKind.Unspecified)), Is.EqualTo("2026-10-04T10:30:00.0000000")); + foreach (var kind in new[] { DateTimeKind.Utc, DateTimeKind.Unspecified, DateTimeKind.Local }) + { + var date = new DateTime(2026, 10, 4, 10, 30, 0, kind); + var read = (DateTime)ChromaMetadataConvert.FromMetadataValue(ChromaMetadataConvert.ToMetadataValue(date), typeof(DateTime))!; + Assert.That((read, read.Kind), Is.EqualTo((date, kind))); + } + var opened = (DateTimeOffset)ChromaMetadataConvert.FromMetadataValue(ChromaMetadataConvert.ToMetadataValue(Opened), typeof(DateTimeOffset))!; + Assert.That((opened, opened.Offset), Is.EqualTo((Opened, TimeSpan.Zero))); + } + +#if !CHROMA_CLIENT_NETSTANDARD2_0 + [Test] + public void DateOnlyAsADate() + { + var day = new DateOnly(2026, 10, 4); + Assert.That(ChromaMetadataConvert.ToMetadataValue(day), Is.EqualTo("2026-10-04")); + Assert.That(ChromaMetadataConvert.FromMetadataValue("2026-10-04", typeof(DateOnly)), Is.EqualTo(day)); + Assert.That(ChromaMetadataConvert.FromMetadataValue(new List { "2026-10-04" }, typeof(DateOnly[])), Is.EqualTo(new[] { day })); + } +#endif + + // Chroma stores no empty list: it is no value, as null is. + [Test] + public void Lists() + { + Assert.That(ChromaMetadataConvert.ToMetadataValue(new[] { 1, 2 }), Is.EqualTo(new List { 1, 2 })); + Assert.That(ChromaMetadataConvert.ToMetadataValue(new List { "a" }), Is.EqualTo(new List { "a" })); + Assert.That(ChromaMetadataConvert.ToMetadataValue(new[] { Opened }), Is.EqualTo(new List { "2026-10-04T10:30:00.0000000+00:00" })); + Assert.That(ChromaMetadataConvert.ToMetadataValue(Array.Empty()), Is.Null); + Assert.That(ChromaMetadataConvert.ToMetadataValue(new List()), Is.Null); + // An int and a long are both integers, a float and a double both floating-point numbers. + Assert.That(ChromaMetadataConvert.ToMetadataValue(new object[] { 1, 2L }), Is.EqualTo(new List { 1, 2L })); + Assert.That(ChromaMetadataConvert.ToMetadataValue(new object[] { 1.5f, 2.5 }), Is.EqualTo(new List { 1.5f, 2.5 })); + } + + [Test] + public void ValuesChromaHasNot() + { + Assert.That(() => ChromaMetadataConvert.ToMetadataValue(1.5m), Throws.ArgumentException); + Assert.That(() => ChromaMetadataConvert.ToMetadataValue(new[] { Guid.Empty }), Throws.ArgumentException); + Assert.That(() => ChromaMetadataConvert.ToMetadataValue(new List { "a", null }), Throws.ArgumentException); + Assert.That(() => ChromaMetadataConvert.ToMetadataValue(new object[] { 1, "x" }), Throws.ArgumentException.With.Message.Contains("one type")); + Assert.That(() => ChromaMetadataConvert.ToMetadataValue(new object[] { 1.5, true }), Throws.ArgumentException.With.Message.Contains("one type")); + } + + [TestCase("text", typeof(string), "text")] + [TestCase(true, typeof(bool), true)] + [TestCase(2L, typeof(long), 2L)] + [TestCase(2L, typeof(int), 2)] + [TestCase(2L, typeof(int?), 2)] + [TestCase(2L, typeof(double), 2.0)] + [TestCase(2L, typeof(float), 2f)] + [TestCase(2.5, typeof(double), 2.5)] + [TestCase(2.5, typeof(float), 2.5f)] + public void ReadsScalars(object value, Type type, object expected) + { + var read = ChromaMetadataConvert.FromMetadataValue(value, type); + Assert.That((read, read!.GetType()), Is.EqualTo((expected, expected.GetType()))); + } + + [Test] + public void ReadsNull() + => Assert.That(ChromaMetadataConvert.FromMetadataValue(null, typeof(int?)), Is.Null); + + static IEnumerable Collections() + { + var date = new DateTime(2026, 10, 4, 10, 30, 0, DateTimeKind.Utc); + yield return new TestCaseData(new List { "a", "b" }, typeof(string[]), new[] { "a", "b" }); + yield return new TestCaseData(new List { "a" }, typeof(List), new List { "a" }); + yield return new TestCaseData(new List { true }, typeof(bool[]), new[] { true }); + yield return new TestCaseData(new List { 1L }, typeof(List), null).SetName("ReadsCollections(an integer as a bool)"); + yield return new TestCaseData(new List { 1L, 2L }, typeof(int[]), new[] { 1, 2 }); + yield return new TestCaseData(new List { 1L }, typeof(List), new List { 1 }); + yield return new TestCaseData(new List { 1L }, typeof(long[]), new[] { 1L }); + yield return new TestCaseData(new List { 1.5 }, typeof(List), new List { 1.5f }); + yield return new TestCaseData(new List { 1.5, 2L }, typeof(double[]), new[] { 1.5, 2.0 }); + yield return new TestCaseData(new List { "2026-10-04T10:30:00.0000000Z" }, typeof(List), new List { date }); + yield return new TestCaseData(new List { "2026-10-04T10:30:00.0000000+00:00" }, typeof(DateTimeOffset[]), new[] { new DateTimeOffset(date) }); + } + + [TestCaseSource(nameof(Collections))] + public void ReadsCollections(List value, Type type, object? expected) + { + if (expected is null) + { + Assert.That(() => ChromaMetadataConvert.FromMetadataValue(value, type), Throws.InstanceOf()); + return; + } + var read = ChromaMetadataConvert.FromMetadataValue(value, type); + Assert.That((read, read!.GetType()), Is.EqualTo((expected, type))); + } + + // One exception for any value that does not convert, with the cause inside when there is one. + [Test] + public void ValuesThatDoNotConvert() + { + Assert.That(() => ChromaMetadataConvert.FromMetadataValue(1.5, typeof(int)), Throws.InstanceOf().With.Message.EqualTo("Cannot read the metadata value of type Double as Int32.")); + Assert.That(() => ChromaMetadataConvert.FromMetadataValue("x", typeof(bool)), Throws.InstanceOf()); + Assert.That(() => ChromaMetadataConvert.FromMetadataValue(true, typeof(int)), Throws.InstanceOf()); + Assert.That(() => ChromaMetadataConvert.FromMetadataValue(long.MaxValue, typeof(int)), Throws.InstanceOf().With.InnerException.InstanceOf()); + Assert.That(() => ChromaMetadataConvert.FromMetadataValue("not a date", typeof(DateTime)), Throws.InstanceOf().With.InnerException.InstanceOf()); + Assert.That(() => ChromaMetadataConvert.FromMetadataValue(new List { 1L }, typeof(int)), Throws.InstanceOf()); + Assert.That(() => ChromaMetadataConvert.FromMetadataValue(new List { 1L }, typeof(Guid[])), Throws.InstanceOf()); + Assert.That(() => ChromaMetadataConvert.FromMetadataValue(new List { 1L, null }, typeof(long[])), Throws.InstanceOf()); + } +} diff --git a/ChromaDB.Client.Tests/ChromaTestsBase.cs b/ChromaDB.Client.Tests/ChromaTestsBase.cs index afd6089..b3d1436 100644 --- a/ChromaDB.Client.Tests/ChromaTestsBase.cs +++ b/ChromaDB.Client.Tests/ChromaTestsBase.cs @@ -1,31 +1,249 @@ -using ChromaDB.Client.Tests.TestContainer; +using ChromaDB.Client.Models; +using ChromaDB.Client.Tests.TestContainer; using NUnit.Framework; +using Testcontainers.Chroma; namespace ChromaDB.Client.Tests; public abstract class ChromaTestsBase { - protected static readonly HttpClient HttpClient = new(); + // On a server already running, the requests of the fixture are recorded: at its end it deletes what they created. + private readonly CreatedOnTheServer _created = new(); + protected HttpClient HttpClient { get; } - private ChromaDBContainer _container; + // CHROMA_TEST_TENANT and CHROMA_TEST_DATABASE run the tests in that tenant and database, created for each fixture, instead of the default ones. + private static readonly string? TestTenant = Environment.GetEnvironmentVariable("CHROMA_TEST_TENANT") is { Length: > 0 } tenant ? tenant : null; + private static readonly string? TestDatabase = Environment.GetEnvironmentVariable("CHROMA_TEST_DATABASE") is { Length: > 0 } database ? database : null; + + // CHROMA_TEST_URI runs the tests against a server already running, like Chroma Cloud, instead of a container: CHROMA_TEST_TOKEN + // goes in X-Chroma-Token, CHROMA_TEST_TENANT and CHROMA_TEST_DATABASE are used as they are, not created, and the collections and + // databases a fixture creates are deleted at its end. CHROMA_TEST_MAX_BATCH_SIZE is a limit lower than the declared one, like 300 on Chroma Cloud. + private static readonly string? TestUri = Environment.GetEnvironmentVariable("CHROMA_TEST_URI") is { Length: > 0 } uri ? uri : null; + private static readonly string? TestToken = Environment.GetEnvironmentVariable("CHROMA_TEST_TOKEN") is { Length: > 0 } token ? token : null; + protected static readonly int? TestMaxBatchSize = int.TryParse(Environment.GetEnvironmentVariable("CHROMA_TEST_MAX_BATCH_SIZE"), out var size) ? size : null; + protected static bool RunningServer => TestUri is not null; + protected static bool ChromaCloud => TestUri?.Contains(".trychroma.com") == true; + + // CHROMA_TEST_API_VERSION=v1 runs the tests with the v1 API, the only one of Chroma 0.5.15 and earlier. + protected static readonly ChromaApiVersion ApiVersion = Environment.GetEnvironmentVariable("CHROMA_TEST_API_VERSION") is "v1" ? ChromaApiVersion.V1 : ChromaApiVersion.V2; + + // Null when the fixture is skipped before its container starts. + private ChromaContainer? _container; private ChromaConfigurationOptions? _baseConfigurationOptions; + protected ChromaTestsBase() + { + HttpClient = NewHttpClient(); + } + + // An HttpClient whose requests to a server already running are recorded, for the tests that need an HttpClient of their own. + protected HttpClient NewHttpClient() => RunningServer ? _created.NewHttpClient() : new HttpClient(); + [OneTimeSetUp] public async Task OneTimeSetUp() { - _container = ConfigureContainer(new ChromaDBBuilder()).Build(); + if (SkipReason is { } reason) + { + Assert.Ignore(reason); + } + if ((TestTenant is not null || TestDatabase is not null) && !TenantsSupported) + { + Assert.Ignore("Chroma 0.4.14 and earlier have no tenants and databases."); + } + if ((TestTenant is not null || TestDatabase is not null) && !RecordsInOtherTenantsSupported) + { + Assert.Ignore("Chroma 0.4.15 does not add records to the collections of other tenants and databases."); + } + if (RunningServer) + { + _baseConfigurationOptions = new ChromaConfigurationOptions(uri: TestUri!).WithApiVersion(ApiVersion); + _baseConfigurationOptions = TestToken is not null ? _baseConfigurationOptions.WithChromaToken(TestToken) : _baseConfigurationOptions; + _baseConfigurationOptions = TestTenant is not null ? _baseConfigurationOptions.WithTenant(TestTenant) : _baseConfigurationOptions; + _baseConfigurationOptions = TestDatabase is not null ? _baseConfigurationOptions.WithDatabase(TestDatabase) : _baseConfigurationOptions; + return; + } + _container = ConfigureContainer(new ChromaBuilder(ChromaImage.Name)).Build(); await _container.StartAsync(); - _baseConfigurationOptions = new ChromaConfigurationOptions(uri: $"http://{_container.IpAddress}:{_container.GetMappedPublicPort(ChromaDBBuilder.ChromaDBPort)}/api/v1/"); + _baseConfigurationOptions = new ChromaConfigurationOptions(uri: $"{_container.GetConnectionString()}api/{(ApiVersion == ChromaApiVersion.V1 ? "v1" : "v2")}/") + .WithApiVersion(ApiVersion); + if (TestTenant is not null || TestDatabase is not null) + { + var client = new ChromaClient(WithServerCredentials(_baseConfigurationOptions), HttpClient); + if (TestTenant is not null) + { + await client.CreateTenantAsync(TestTenant); + _baseConfigurationOptions = _baseConfigurationOptions.WithTenant(TestTenant); + } + // A new tenant has no databases: without CHROMA_TEST_DATABASE, the tests use a default_database created in it. + var database = TestDatabase ?? (TestTenant is not null ? "default_database" : null); + if (database is not null) + { + await client.CreateDatabaseAsync(database, tenant: TestTenant); + _baseConfigurationOptions = _baseConfigurationOptions.WithDatabase(database); + } + } } [OneTimeTearDown] public async Task OneTimeTearDown() { + if (RunningServer && _baseConfigurationOptions is not null) + { + await DeleteWhatTheFixtureCreated(); + } _baseConfigurationOptions = null; - await _container.DisposeAsync(); + HttpClient.Dispose(); + if (_container is not null) + { + await _container.DisposeAsync(); + } } protected ChromaConfigurationOptions BaseConfigurationOptions => _baseConfigurationOptions ?? throw new InvalidOperationException(); - protected virtual ChromaDBBuilder ConfigureContainer(ChromaDBBuilder builder) => builder; + // On a running server what the tests create stays, and on Chroma Cloud it costs: the fixture deletes it. + private async Task DeleteWhatTheFixtureCreated() + { + var client = new ChromaClient(BaseConfigurationOptions, HttpClient); + foreach (var place in _created.Collections.GroupBy(x => x.Value, x => x.Key)) + { + IReadOnlyList collections; + try + { + collections = await client.ListCollectionsAsync(place.Key.Tenant, place.Key.Database); + } + catch (ChromaException) + { + // A test deleted the database. + continue; + } + foreach (var collection in collections.Where(x => place.Contains(x.Id))) + { + // On a Chroma 1.5 already running, the lists of the records would reach the records of the next tests. + await client.DeleteCollectionAsync(collection.Name, deleteRecordsFirst: true, place.Key.Tenant, place.Key.Database); + } + } + foreach (var (tenant, name) in _created.Databases.Keys) + { + try + { + await client.DeleteDatabaseAsync(name, deleteRecordsFirst: true, tenant); + } + catch (ChromaException) + { + // A test deleted it, or the server does not delete databases. + } + } + } + + // Chroma 1.0 removed the built-in authentication and reads its settings from a configuration file. + protected static bool IsChroma1 => ChromaImage.Version.Major >= 1; + + // A server already running, like Chroma Cloud, may not let the tests create or look up other tenants and databases: + // Chroma Cloud answers 403 "Permission denied." also for a tenant or a database that does not exist. + protected static bool OtherTenantsAndDatabasesTested => !RunningServer; + + // Chroma 0.4.14 has no tenants and databases, 0.4.15 has them. + protected static bool TenantsSupported => ChromaImage.Version >= new Version(0, 4, 15); + + // Missing in Chroma 0.4.15 and there in 0.4.23, the next version whose image starts: count_collections, the $not_contains + // document filter, and the tenant and database in the collections the server returns. + protected static bool CountCollectionsAndNotContainsSupported => ChromaImage.Version >= new Version(0, 4, 23); + + // Chroma 0.4.15 creates collections in other tenants and databases, but answers that they do not exist when records + // are added to them; 0.4.23 adds them. + protected static bool RecordsInOtherTenantsSupported => ChromaImage.Version >= new Version(0, 4, 23); + + // Chroma 0.4.10 to 0.4.15 ignore the limit and the offset of the list of the collections; 0.4.23 applies them. + protected static bool ListCollectionsPagingSupported => ChromaImage.Version >= new Version(0, 4, 23); + + // Chroma 0.4.10 has no pre-flight-checks; 0.4.12 has them (the 0.4.11 image does not start). + protected static bool PreFlightChecksSupported => ChromaImage.Version >= new Version(0, 4, 12); + + // Chroma 1.0.12 and earlier do not send supports_base64_encoding in the pre-flight checks; 1.0.13 sends it. + protected static bool Base64EncodingReported => ChromaImage.Version >= new Version(1, 0, 13); + + // Chroma 0.4.10 to 0.4.15 reject "uris" in include; 0.4.23 stores and returns the URIs of the records. + protected static bool UrisSupported => ChromaImage.Version >= new Version(0, 4, 23); + + // Only the v2 API of Chroma 0.6.3 and later lists and deletes databases: 0.6.2 and earlier answer 405 Method Not Allowed. + protected static bool DatabaseListingSupported => ApiVersion == ChromaApiVersion.V2 && ChromaImage.Version >= new Version(0, 6, 3); + + // Only the v2 API of Chroma 1.5.7 and later gets a collection by its id: 1.5.6 and earlier answer 404 Not Found. + protected static bool CollectionByIdSupported => ApiVersion == ChromaApiVersion.V2 && ChromaImage.Version >= new Version(1, 5, 7); + + // Chroma 0.6.3 and earlier ignore the ids of a query and search all the records; 1.0.0 searches only those. + protected static bool IdsInQuerySupported => ApiVersion == ChromaApiVersion.V2 && IsChroma1; + + // Chroma 0.x accepts lists in metadata but drops them; 1.0.0 to 1.4.1 reject them with 422; 1.5.0 stores them and filters them with $contains. + protected static bool MetadataListsSupported => IsChroma1 && ChromaImage.Version >= new Version(1, 5, 0); + protected static bool IsChroma0 => !IsChroma1; + + // Chroma 1.0.6 and later send "hnsw.space" in the configuration; 0.5.4 to 1.0.5 send "hnsw_configuration.space", always "l2". + protected static bool ConfigurationSpaceReported => ChromaImage.Version >= new Version(1, 0, 6); + + // The servers before Chroma 0.5.1 configure their built-in authentication with other settings, and Chroma 1.0 removed it. + protected static bool BuiltInAuthenticationTested => ChromaImage.Version >= new Version(0, 5, 1) && !IsChroma1; + + // Only the v2 API of Chroma 1.0.0 and later has the healthcheck: the 0.x servers answer 404. + protected static bool HealthcheckSupported => ApiVersion == ChromaApiVersion.V2 && IsChroma1; + + // Chroma 1.0.12 and later filter documents with $regex and $not_regex; 1.0.0 to 1.0.6 reject them, 1.0.10 closes the connection. + protected static bool RegexSupported => ChromaImage.Version >= new Version(1, 0, 12); + + // Chroma 1.0.21 to 1.1.1 reject a write with sparse vectors, but then fail the next write on the server, also to another collection, + // with "Error sending message to compactor". A server already running counts by CHROMA_IMAGE too. + protected static bool RejectedSparseVectorsBreakTheServer => ChromaImage.Version >= new Version(1, 0, 21) && ChromaImage.Version <= new Version(1, 1, 1); + + // Chroma 1.3.2 and later apply the space in a schema; 1.3.0 only when the schema also turns an index on or off, and keeps l2 otherwise. + protected static bool SpaceInSchemaApplied => ChromaCloud || ChromaImage.Version >= new Version(1, 3, 2); + + // Chroma 1.5.3 and later apply the limit of a delete, declare it in their OpenAPI description and answer a count, which for a delete by + // ids is the number of ids given. + protected static bool DeleteLimitSupported => ChromaImage.Version >= new Version(1, 5, 3); + + // $ne and $nin match the records without the key from Chroma 0.5.15; 0.4.10 to 0.4.23 leave them out. + protected static bool NotEqualMatchesRecordsWithoutTheKey => ChromaImage.Version >= new Version(0, 5, 15); + + // Chroma 1.0.6 and later apply a new configuration of a collection; the earlier versions answer without applying it. + protected static bool NewConfigurationApplied => ChromaImage.Version >= new Version(1, 0, 6); + + // Chroma 1.0.0 and later and Chroma Cloud keep the stored document when an update or an upsert sends a null one; every 0.x server + // deletes it. + protected static bool NullDocumentKept => ChromaCloud || ChromaImage.Version >= new Version(1, 0, 0); + + // Chroma 1.3.0 and later apply the indexes of a schema. + protected static bool SchemaApplied => ChromaCloud || ChromaImage.Version >= new Version(1, 3, 0); + + // Chroma 1.5.1 and later turn off the full-text search index of a schema; 1.3.0 to 1.5.0 keep it. + protected static bool FullTextSearchOffApplied => ChromaCloud || ChromaImage.Version >= new Version(1, 5, 1); + + // Chroma 0.5.1 and later send the dimension and the version of a collection. + protected static bool CollectionDimensionReported => ChromaImage.Version >= new Version(0, 5, 1); + + // Since Chroma 0.5.20, the server rejects embeddings of different dimensions in the same request. + protected static bool EmbeddingDimensionsChecked => ChromaImage.Version >= new Version(0, 5, 20); + + // Since Chroma 1.0.16, add and upsert require embeddings. + protected static bool EmbeddingsRequired => ChromaImage.Version >= new Version(1, 0, 16); + + protected static List> Embeddings(int count) + => Enumerable.Repeat(new ReadOnlyMemory([1f, 0.5f, 0f, -0.5f, -1f]), count).ToList(); + + // Before Chroma 1.0.16 these calls succeed; since then the server rejects them. + protected static async Task WithoutEmbeddings(Func action) + { + if (EmbeddingsRequired) + await Assert.ThatAsync(() => action(), Throws.InstanceOf()); + else + await action(); + } + + protected virtual ChromaBuilder ConfigureContainer(ChromaBuilder builder) => builder; + + // Skips the whole fixture before its container starts, when the server of this version cannot run it. + protected virtual string? SkipReason => null; + + // The credentials the server of the fixture requires, if any: the setup needs them to create the test tenant and database. + protected virtual ChromaConfigurationOptions WithServerCredentials(ChromaConfigurationOptions options) => options; } diff --git a/ChromaDB.Client.Tests/ClientAuthTests.cs b/ChromaDB.Client.Tests/ClientAuthTests.cs index f9fb103..5aee51b 100644 --- a/ChromaDB.Client.Tests/ClientAuthTests.cs +++ b/ChromaDB.Client.Tests/ClientAuthTests.cs @@ -1,5 +1,5 @@ -using ChromaDB.Client.Tests.TestContainer; -using NUnit.Framework; +using NUnit.Framework; +using Testcontainers.Chroma; namespace ChromaDB.Client.Tests; @@ -12,45 +12,124 @@ public class NoAuth : ChromaTestsBase public async Task Success() { var client = new ChromaClient(BaseConfigurationOptions, HttpClient); - await Assert.ThatAsync(() => client.CreateCollection($"collection{Random.Shared.Next()}"), Throws.Nothing); + await Assert.ThatAsync(() => client.CreateCollectionAsync($"collection{Random.Shared.Next()}"), Throws.Nothing); } } [TestFixture] public class ChromaTokenAuth : ChromaTestsBase { + protected override string? SkipReason => BuiltInAuthenticationTested ? null : "Chroma 1.0 removed the built-in authentication, and the servers before 0.5.1 configure it with other settings."; + [Test] public async Task Success() { var client = new ChromaClient(BaseConfigurationOptions.WithChromaToken("random-ToKen"), HttpClient); - await Assert.ThatAsync(() => client.CreateCollection($"collection{Random.Shared.Next()}"), Throws.Nothing); + await Assert.ThatAsync(() => client.CreateCollectionAsync($"collection{Random.Shared.Next()}"), Throws.Nothing); } [Test] public async Task NoToken() { var client = new ChromaClient(BaseConfigurationOptions, HttpClient); - await Assert.ThatAsync(() => client.CreateCollection($"collection{Random.Shared.Next()}"), Throws.InstanceOf().With.Message.Contains("Forbidden")); + await Assert.ThatAsync(() => client.CreateCollectionAsync($"collection{Random.Shared.Next()}"), Throws.InstanceOf().With.Message.Contains("Forbidden")); } [Test] public async Task WrongToken() { var client = new ChromaClient(BaseConfigurationOptions.WithChromaToken("wrong"), HttpClient); - await Assert.ThatAsync(() => client.CreateCollection($"collection{Random.Shared.Next()}"), Throws.InstanceOf().With.Message.Contains("Forbidden")); + await Assert.ThatAsync(() => client.CreateCollectionAsync($"collection{Random.Shared.Next()}"), Throws.InstanceOf().With.Message.Contains("Forbidden")); } [Test] public async Task WrongTokenCasing() { var client = new ChromaClient(BaseConfigurationOptions.WithChromaToken("random-token"), HttpClient); - await Assert.ThatAsync(() => client.CreateCollection($"collection{Random.Shared.Next()}"), Throws.InstanceOf().With.Message.Contains("Forbidden")); + await Assert.ThatAsync(() => client.CreateCollectionAsync($"collection{Random.Shared.Next()}"), Throws.InstanceOf().With.Message.Contains("Forbidden")); } - protected override ChromaDBBuilder ConfigureContainer(ChromaDBBuilder builder) + protected override ChromaConfigurationOptions WithServerCredentials(ChromaConfigurationOptions options) + => options.WithChromaToken("random-ToKen"); + + protected override ChromaBuilder ConfigureContainer(ChromaBuilder builder) => builder .WithEnvironment("CHROMA_SERVER_AUTHN_CREDENTIALS", "random-ToKen") .WithEnvironment("CHROMA_SERVER_AUTHN_PROVIDER", "chromadb.auth.token_authn.TokenAuthenticationServerProvider") .WithEnvironment("CHROMA_AUTH_TOKEN_TRANSPORT_HEADER", "X-Chroma-Token"); } + + // Without CHROMA_AUTH_TOKEN_TRANSPORT_HEADER the server reads the token from Authorization: Bearer. + [TestFixture] + public class ChromaTokenInAuthorizationHeader : ChromaTestsBase + { + protected override string? SkipReason => BuiltInAuthenticationTested ? null : "Chroma 1.0 removed the built-in authentication, and the servers before 0.5.1 configure it with other settings."; + + [Test] + public async Task Success() + { + var client = new ChromaClient(BaseConfigurationOptions.WithChromaToken("random-ToKen", ChromaTokenTransportHeader.Authorization), HttpClient); + await Assert.ThatAsync(() => client.CreateCollectionAsync($"collection{Random.Shared.Next()}"), Throws.Nothing); + } + + [Test] + public async Task NoToken() + { + var client = new ChromaClient(BaseConfigurationOptions, HttpClient); + await Assert.ThatAsync(() => client.CreateCollectionAsync($"collection{Random.Shared.Next()}"), Throws.InstanceOf().With.Message.Contains("Forbidden")); + } + + [Test] + public async Task TokenInXChromaTokenHeader() + { + var client = new ChromaClient(BaseConfigurationOptions.WithChromaToken("random-ToKen"), HttpClient); + await Assert.ThatAsync(() => client.CreateCollectionAsync($"collection{Random.Shared.Next()}"), Throws.InstanceOf().With.Message.Contains("Forbidden")); + } + + protected override ChromaConfigurationOptions WithServerCredentials(ChromaConfigurationOptions options) + => options.WithChromaToken("random-ToKen", ChromaTokenTransportHeader.Authorization); + + protected override ChromaBuilder ConfigureContainer(ChromaBuilder builder) + => builder + .WithEnvironment("CHROMA_SERVER_AUTHN_CREDENTIALS", "random-ToKen") + .WithEnvironment("CHROMA_SERVER_AUTHN_PROVIDER", "chromadb.auth.token_authn.TokenAuthenticationServerProvider"); + } + + [TestFixture] + public class BasicAuth : ChromaTestsBase + { + // The htpasswd line of the user "admin" with the password "secret", hashed with bcrypt. + const string Credentials = "admin:$2b$12$XkWBowOjcQqb09GdfnT0CurP1VJVZwbmcfkIeXQtkzZqmVQKrOug2"; + + protected override string? SkipReason => BuiltInAuthenticationTested ? null : "Chroma 1.0 removed the built-in authentication, and the servers before 0.5.1 configure it with other settings."; + + [Test] + public async Task Success() + { + var client = new ChromaClient(BaseConfigurationOptions.WithBasicAuth("admin", "secret"), HttpClient); + await Assert.ThatAsync(() => client.CreateCollectionAsync($"collection{Random.Shared.Next()}"), Throws.Nothing); + } + + [Test] + public async Task NoCredentials() + { + var client = new ChromaClient(BaseConfigurationOptions, HttpClient); + await Assert.ThatAsync(() => client.CreateCollectionAsync($"collection{Random.Shared.Next()}"), Throws.InstanceOf().With.Message.Contains("Forbidden")); + } + + [Test] + public async Task WrongPassword() + { + var client = new ChromaClient(BaseConfigurationOptions.WithBasicAuth("admin", "wrong"), HttpClient); + await Assert.ThatAsync(() => client.CreateCollectionAsync($"collection{Random.Shared.Next()}"), Throws.InstanceOf().With.Message.Contains("Forbidden")); + } + + protected override ChromaConfigurationOptions WithServerCredentials(ChromaConfigurationOptions options) + => options.WithBasicAuth("admin", "secret"); + + protected override ChromaBuilder ConfigureContainer(ChromaBuilder builder) + => builder + .WithEnvironment("CHROMA_SERVER_AUTHN_CREDENTIALS", Credentials) + .WithEnvironment("CHROMA_SERVER_AUTHN_PROVIDER", "chromadb.auth.basic_authn.BasicAuthenticationServerProvider"); + } } diff --git a/ChromaDB.Client.Tests/ClientDisposeTests.cs b/ChromaDB.Client.Tests/ClientDisposeTests.cs new file mode 100644 index 0000000..9e85eee --- /dev/null +++ b/ChromaDB.Client.Tests/ClientDisposeTests.cs @@ -0,0 +1,39 @@ +using ChromaDB.Client.Models; +using NUnit.Framework; + +namespace ChromaDB.Client.Tests; + +// Dispose closes the HttpClient the client created, and only that one. The requests have a cancelled token, so that none reaches the +// network: a disposed HttpClient throws ObjectDisposedException, which is not about Chroma and is not wrapped, before it looks at the token. +[TestFixture] +public class ClientDisposeTests +{ + [Test] + public async Task OwnHttpClient() + { + using var cancelled = new CancellationTokenSource(); + cancelled.Cancel(); + var client = new ChromaClient("http://localhost:8000"); + Assert.That(client.Options.Uri, Is.EqualTo(new ChromaConfigurationOptions("http://localhost:8000").Uri)); + var collectionClient = client.GetCollectionClient(Guid.Parse("11111111-2222-3333-4444-555555555555"), "c"); + client.WithMetadataValues(ChromaMetadataValues.Exact).Dispose(); + await Assert.ThatAsync(() => client.HeartbeatAsync(cancelled.Token), Throws.InstanceOf()); + + client.Dispose(); + await Assert.ThatAsync(() => client.HeartbeatAsync(cancelled.Token), Throws.InstanceOf()); + await Assert.ThatAsync(() => collectionClient.CountAsync(cancelled.Token), Throws.InstanceOf()); + } + + [Test] + public async Task GivenHttpClient() + { + using var cancelled = new CancellationTokenSource(); + cancelled.Cancel(); + using var httpClient = new HttpClient(); + var options = new ChromaConfigurationOptions("http://localhost:8000"); + new ChromaClient(options, httpClient).Dispose(); + await Assert.ThatAsync(() => httpClient.GetAsync("http://localhost:8000/", cancelled.Token), Throws.InstanceOf()); + using var client = new ChromaClient(options); + Assert.That(client.Options, Is.SameAs(options)); + } +} diff --git a/ChromaDB.Client.Tests/ClientResetTests.cs b/ChromaDB.Client.Tests/ClientResetTests.cs index 604b660..e809da2 100644 --- a/ChromaDB.Client.Tests/ClientResetTests.cs +++ b/ChromaDB.Client.Tests/ClientResetTests.cs @@ -1,5 +1,5 @@ -using ChromaDB.Client.Tests.TestContainer; -using NUnit.Framework; +using NUnit.Framework; +using Testcontainers.Chroma; namespace ChromaDB.Client.Tests; @@ -8,27 +8,32 @@ public class ClientResetTests [TestFixture] public class DefaultSettings : ChromaTestsBase { + protected override string? SkipReason => RunningServer ? "The reset of a server already running is not tested: Chroma Cloud answers 403." : null; + [Test] public async Task ResetSimple() { var client = new ChromaClient(BaseConfigurationOptions, HttpClient); - await Assert.ThatAsync(client.Reset, Throws.InstanceOf().With.Message.Contains("ALLOW_RESET")); + await Assert.ThatAsync(() => client.ResetAsync(), Throws.InstanceOf().With.Message.StartsWith(IsChroma1 ? "Reset is disabled by config" : "Resetting is not allowed by this configuration")); } } [TestFixture] public class AllowReset : ChromaTestsBase { + protected override string? SkipReason => RunningServer ? "Allowing the reset needs a container started with that setting." : null; + [Test] public async Task ResetSimple() { var client = new ChromaClient(BaseConfigurationOptions, HttpClient); - var result = await client.Reset(); + var result = await client.ResetAsync(); Assert.That(result, Is.True); } - protected override ChromaDBBuilder ConfigureContainer(ChromaDBBuilder builder) - => builder - .WithEnvironment("ALLOW_RESET", "TRUE"); + protected override ChromaBuilder ConfigureContainer(ChromaBuilder builder) + => IsChroma1 + ? builder.WithEnvironment("CHROMA_ALLOW_RESET", "true") + : builder.WithEnvironment("ALLOW_RESET", "TRUE"); } } \ No newline at end of file diff --git a/ChromaDB.Client.Tests/ClientTests.cs b/ChromaDB.Client.Tests/ClientTests.cs index 36264c1..c96709b 100644 --- a/ChromaDB.Client.Tests/ClientTests.cs +++ b/ChromaDB.Client.Tests/ClientTests.cs @@ -1,4 +1,5 @@ -using NUnit.Framework; +using ChromaDB.Client.Models; +using NUnit.Framework; namespace ChromaDB.Client.Tests; @@ -9,7 +10,7 @@ public class ClientTests : ChromaTestsBase public async Task HeartbeatSimple() { var client = new ChromaClient(BaseConfigurationOptions, HttpClient); - var result = await client.Heartbeat(); + var result = await client.HeartbeatAsync(); Assert.That(result.NanosecondHeartbeat, Is.GreaterThan(0)); } @@ -17,46 +18,301 @@ public async Task HeartbeatSimple() public async Task GetVersionSimple() { var client = new ChromaClient(BaseConfigurationOptions, HttpClient); - var result = await client.GetVersion(); + var result = await client.GetVersionAsync(); Assert.That(result, Does.Match(@"\d+\.\d+")); } + // The client with an HttpClient of its own. + [Test] + public async Task GetVersionWithoutAnHttpClient() + { + using var client = new ChromaClient(BaseConfigurationOptions); + var result = await client.GetVersionAsync(); + Assert.That(result, Does.Match(@"\d+\.\d+")); + } + + [Test] + public async Task GetUserIdentitySimple() + { + Assume.That(ApiVersion, Is.EqualTo(ChromaApiVersion.V2), "The v1 API has no auth/identity."); + var client = new ChromaClient(BaseConfigurationOptions, HttpClient); + var result = await client.GetUserIdentityAsync(); + // A container answers with the defaults whatever tenant the tests use; a server already running, like Chroma Cloud, + // with the tenant of the key and its databases. + Assert.That(result.Tenant, Is.EqualTo(RunningServer ? BaseConfigurationOptions.Tenant ?? "default_tenant" : "default_tenant")); + Assert.That(result.Databases, RunningServer ? Is.Not.Empty : Contains.Item("default_database")); + } + + [Test] + public async Task GetPreFlightChecksSimple() + { + Assume.That(PreFlightChecksSupported, Is.True, "Chroma 0.4.10 has no pre-flight-checks."); + var client = new ChromaClient(BaseConfigurationOptions, HttpClient); + var result = await client.GetPreFlightChecksAsync(); + Assert.That(result.MaxBatchSize, Is.GreaterThan(0)); + Assert.That(result.SupportsBase64Encoding, Base64EncodingReported ? Is.Not.Null : Is.Null); + } + + [Test] + public async Task SharedHttpClientIsNotModified() + { + using var httpClient = new HttpClient(); + var client = new ChromaClient(BaseConfigurationOptions.WithChromaToken("token"), httpClient); + await client.HeartbeatAsync(); + Assert.That(httpClient.BaseAddress, Is.Null); + Assert.That(httpClient.DefaultRequestHeaders.Contains("X-Chroma-Token"), Is.False); + } + + [Test] + public async Task SharedHttpClientWithAnotherUri() + { + using var httpClient = new HttpClient(); + var client = new ChromaClient(BaseConfigurationOptions, httpClient); + await client.HeartbeatAsync(); + _ = new ChromaClient(BaseConfigurationOptions.WithUri("http://localhost:1/api/v2/"), httpClient); + await Assert.ThatAsync(() => client.HeartbeatAsync(), Throws.Nothing); + } + + [Test] + public async Task SharedHttpClientWithAnotherUriForCollection() + { + using var httpClient = NewHttpClient(); + var client = new ChromaClient(BaseConfigurationOptions, httpClient); + var collection = await client.CreateCollectionAsync($"collection{Random.Shared.Next()}"); + Assert.That(() => new ChromaCollectionClient(collection, BaseConfigurationOptions.WithUri("http://localhost:1/api/v2/"), httpClient), Throws.Nothing); + await Assert.ThatAsync(() => new ChromaCollectionClient(collection, BaseConfigurationOptions, httpClient).CountAsync(), Throws.Nothing); + } + + [Test] + public async Task UriWithHostAndPortOnly() + { + var options = BaseConfigurationOptions.WithUri(BaseConfigurationOptions.Uri.GetLeftPart(UriPartial.Authority)); + var client = new ChromaClient(options, HttpClient); + var collection = await client.GetOrCreateCollectionAsync($"collection{Random.Shared.Next()}"); + var result = await new ChromaCollectionClient(collection, options, HttpClient).CountAsync(); + Assert.That(result, Is.EqualTo(0)); + } + [Test] - [Ignore("Failing because of bug on Chroma's side.", Until = "2025-04-21")] public async Task GetCollectionSimple() { var name = $"collection{Random.Shared.Next()}"; var client = new ChromaClient(BaseConfigurationOptions, HttpClient); - await client.CreateCollection(name); - var result = await client.GetCollection(name); + await client.CreateCollectionAsync(name); + var result = await client.GetCollectionAsync(name); Assert.That(result, Is.Not.Null); Assert.That(result.Name, Is.EqualTo(name)); } + // Two records in the same direction: 0 apart with cosine, 1 apart with l2, and 1 - 2 = -1 for the farther one with ip. + [TestCase(ChromaSpace.L2, new[] { 0f, 1f })] + [TestCase(ChromaSpace.Cosine, new[] { 0f, 0f })] + [TestCase(ChromaSpace.InnerProduct, new[] { -1f, 0f })] + public async Task CreateCollectionWithSpace(ChromaSpace space, float[] distances) + { + var client = new ChromaClient(BaseConfigurationOptions, HttpClient); + var collection = await client.CreateCollectionAsync(new ChromaCollectionDefinition($"collection{Random.Shared.Next()}") { Configuration = new() { Space = space } }); + Assert.That(collection.Space, Is.EqualTo(space)); + Assert.That((await client.GetCollectionAsync(collection.Name)).Space, Is.EqualTo(space)); + + var collectionClient = new ChromaCollectionClient(collection, BaseConfigurationOptions, HttpClient); + await collectionClient.AddAsync(["a", "b"], embeddings: [new([1f, 0f]), new([2f, 0f])]); + var result = await collectionClient.QueryAsync(new ReadOnlyMemory([1f, 0f]), nResults: 2, include: ChromaQueryInclude.Distances); + Assert.That(result.Select(x => x.Distance).OrderBy(x => x), Is.EqualTo(distances).Within(0.0001f)); + } + + // With a schema the space goes in the schema, which Chroma 1.3.2 and later apply. + [TestCase(ChromaSpace.Cosine, new[] { 0f, 0f })] + [TestCase(ChromaSpace.InnerProduct, new[] { -1f, 0f })] + public async Task CreateCollectionWithSpaceAndSchema(ChromaSpace space, float[] distances) + { + Assume.That(SpaceInSchemaApplied, Is.True, "Chroma 1.3.2 and later apply the space in the schema."); + await SpaceWithSchema(space, new ChromaCollectionSchema(), distances); + } + + // Chroma 1.3.0 applies the space of a schema that also turns an index on or off, here one turned off. + [TestCase(ChromaSpace.Cosine, new[] { 0f, 0f })] + [TestCase(ChromaSpace.InnerProduct, new[] { -1f, 0f })] + public async Task CreateCollectionWithSpaceAndSchemaWithAnIndexTurnedOff(ChromaSpace space, float[] distances) + { + Assume.That(SchemaApplied, Is.True, "Chroma 1.3.0 and later apply the schema."); + await SpaceWithSchema(space, new ChromaCollectionSchema().WithoutIndex(ChromaSchemaIndex.StringInverted, "k"), distances); + } + + private async Task SpaceWithSchema(ChromaSpace space, ChromaCollectionSchema schema, float[] distances) + { + var client = new ChromaClient(BaseConfigurationOptions, HttpClient); + var collection = await client.CreateCollectionAsync(new ChromaCollectionDefinition($"collection{Random.Shared.Next()}") { Configuration = new() { Space = space }, Schema = schema }); + Assert.That(collection.Space, Is.EqualTo(space)); + Assert.That((await client.GetCollectionAsync(collection.Name)).Space, Is.EqualTo(space)); + + var collectionClient = client.GetCollectionClient(collection); + await collectionClient.AddAsync(["a", "b"], embeddings: [new([1f, 0f]), new([2f, 0f])]); + var result = await collectionClient.QueryAsync(new ReadOnlyMemory([1f, 0f]), nResults: 2, include: ChromaQueryInclude.Distances); + Assert.That(result.Select(x => x.Distance).OrderBy(x => x), Is.EqualTo(distances).Within(0.0001f)); + } + + [Test] + public async Task GetOrCreateCollectionWithSpace() + { + var client = new ChromaClient(BaseConfigurationOptions, HttpClient); + var definition = new ChromaCollectionDefinition($"collection{Random.Shared.Next()}") { Configuration = new() { Space = ChromaSpace.Cosine } }; + var created = await client.GetOrCreateCollectionAsync(definition); + var existing = await client.GetOrCreateCollectionAsync(definition); + Assert.That(existing.Id, Is.EqualTo(created.Id)); + Assert.That(existing.Space, Is.EqualTo(ChromaSpace.Cosine)); + } + + // A collection that exists keeps its space: GetOrCreateCollectionAsync throws when it has another one than the definition asks for, + // where the server reports it. + [Test] + public async Task GetOrCreateCollectionWithAnotherSpace() + { + var client = new ChromaClient(BaseConfigurationOptions, HttpClient); + var name = $"collection{Random.Shared.Next()}"; + var created = await client.CreateCollectionAsync(new ChromaCollectionDefinition(name) { Configuration = new() { Space = ChromaSpace.L2 } }); + Assume.That(created.Space, Is.Not.Null, "The server does not report the space of the collection."); + await Assert.ThatAsync(() => client.GetOrCreateCollectionAsync(new ChromaCollectionDefinition(name) { Configuration = new() { Space = ChromaSpace.Cosine } }), + Throws.InstanceOf().With.Message.Contains("not cosine")); + } + + // Without a space Chroma uses l2; the servers before 1.0.6 do not report it reliably. + [Test] + public async Task SpaceOfACollectionWithoutOne() + { + var collection = await new ChromaClient(BaseConfigurationOptions, HttpClient).CreateCollectionAsync($"collection{Random.Shared.Next()}"); + // Chroma 0.x keeps the space only in the metadata, so the client reads l2, its default; 1.0.0 to 1.0.5 do not report it. + Assert.That(collection.Space, ConfigurationSpaceReported || !IsChroma1 ? Is.EqualTo(ChromaSpace.L2) : Is.Null); + } + + [Test] + public async Task GetCollectionClient() + { + var client = new ChromaClient(BaseConfigurationOptions, HttpClient); + var collection = await client.CreateCollectionAsync($"collection{Random.Shared.Next()}"); + await client.GetCollectionClient(collection).AddAsync(["a"], embeddings: [new([1f, 0f])]); + Assert.That(await client.GetCollectionClient(collection.Id, collection.Name).CountAsync(), Is.EqualTo(1)); + } + + [Test] + public async Task CollectionClientFromTheId() + { + var collection = await new ChromaClient(BaseConfigurationOptions, HttpClient).CreateCollectionAsync($"collection{Random.Shared.Next()}"); + var collectionClient = new ChromaCollectionClient(collection.Id, collection.Name, BaseConfigurationOptions, HttpClient); + await collectionClient.AddAsync(["a"], embeddings: [new([1f, 0f])]); + Assert.That(await collectionClient.CountAsync(), Is.EqualTo(1)); + Assert.That((await new ChromaCollectionClient(collection, BaseConfigurationOptions, HttpClient).GetAsync("a"))?.Id, Is.EqualTo("a")); + } + + [Test] + public async Task CollectionExists() + { + var name = $"collection{Random.Shared.Next()}"; + + var client = new ChromaClient(BaseConfigurationOptions, HttpClient); + Assert.That(await client.CollectionExistsAsync(name), Is.False); + await client.CreateCollectionAsync(name); + Assert.That(await client.CollectionExistsAsync(name), Is.True); + await client.DeleteCollectionAsync(name); + Assert.That(await client.CollectionExistsAsync(name), Is.False); + } + + [Test] + public async Task GetCollectionById() + { + Assume.That(CollectionByIdSupported, Is.True, "Only the v2 API of Chroma 1.5.7 and later gets a collection by its id."); + var name = $"collection{Random.Shared.Next()}"; + + var client = new ChromaClient(BaseConfigurationOptions, HttpClient); + var collection = await client.CreateCollectionAsync(name, metadata: new Dictionary { ["key"] = "value" }); + var result = await client.GetCollectionByIdAsync(collection.Id); + Assert.That(result.Id, Is.EqualTo(collection.Id)); + Assert.That(result.Name, Is.EqualTo(name)); + Assert.That(result.Metadata?["key"], Is.EqualTo("value")); + Assert.That(result.Tenant, Is.EqualTo(collection.Tenant)); + Assert.That(result.Database, Is.EqualTo(collection.Database)); + } + + [Test] + public async Task GetCollectionByIdNotExists() + { + Assume.That(CollectionByIdSupported, Is.True, "Only the v2 API of Chroma 1.5.7 and later gets a collection by its id."); + + var client = new ChromaClient(BaseConfigurationOptions, HttpClient); + await Assert.ThatAsync(() => client.GetCollectionByIdAsync(Guid.NewGuid()), Throws.InstanceOf().With.Message.Contains("does not exist")); + } + + // The id is looked up in the tenant and database of the request only. + [Test] + public async Task GetCollectionByIdInAnotherDatabase() + { + Assume.That(OtherTenantsAndDatabasesTested, Is.True, "A server already running may not let the tests create or look up other tenants and databases."); + Assume.That(CollectionByIdSupported, Is.True, "Only the v2 API of Chroma 1.5.7 and later gets a collection by its id."); + var database = $"database{Random.Shared.Next()}"; + + var client = new ChromaClient(BaseConfigurationOptions, HttpClient); + var collection = await client.CreateCollectionAsync($"collection{Random.Shared.Next()}"); + await client.CreateDatabaseAsync(database); + Assert.That((await client.GetCollectionByIdAsync(collection.Id)).Id, Is.EqualTo(collection.Id)); + await Assert.ThatAsync(() => client.GetCollectionByIdAsync(collection.Id, database: database), Throws.InstanceOf().With.Message.Contains("does not exist")); + } + + // The older servers have no such path: the client reports it with a ChromaException that names the request, + // "Not Found" from the Python servers, "NotFound" from Chroma 1.x, which answers with an empty body. + [Test] + public async Task GetCollectionByIdNotSupported() + { + Assume.That(CollectionByIdSupported, Is.False, "This server gets a collection by its id."); + + var client = new ChromaClient(BaseConfigurationOptions, HttpClient); + var collection = await client.CreateCollectionAsync($"collection{Random.Shared.Next()}"); + await Assert.ThatAsync(() => client.GetCollectionByIdAsync(collection.Id), Throws.InstanceOf().With.Message.Match($"^Not ?Found: GET /api/v[12]/.*collections/by-id/{collection.Id}$")); + } + [Test] - [Ignore("Failing because of bug on Chroma's side.", Until = "2025-04-21")] public async Task ListCollectionsSimple() { var names = new[] { $"collection{Random.Shared.Next()}", $"collection{Random.Shared.Next()}" }; var client = new ChromaClient(BaseConfigurationOptions, HttpClient); - await client.CreateCollection(names[0]); - await client.CreateCollection(names[1]); - var result = await client.ListCollections(); + await client.CreateCollectionAsync(names[0]); + await client.CreateCollectionAsync(names[1]); + var result = await client.ListCollectionsAsync(); Assert.That(result, Is.Not.Null); Assert.That(result, Has.Count.GreaterThanOrEqualTo(2)); Assert.That(result.Select(x => x.Name), Contains.Item(names[0])); Assert.That(result.Select(x => x.Name), Contains.Item(names[1])); } + [Test] + public async Task ListCollectionsPages() + { + Assume.That(OtherTenantsAndDatabasesTested, Is.True, "A server already running may not let the tests create or look up other tenants and databases."); + Assume.That(ListCollectionsPagingSupported, Is.True, "Chroma 0.4.15 and earlier ignore the limit and the offset of the list of the collections."); + var database = $"database{Random.Shared.Next()}"; + + var client = new ChromaClient(BaseConfigurationOptions, HttpClient); + await client.CreateDatabaseAsync(database); + var names = new[] { $"collection{Random.Shared.Next()}", $"collection{Random.Shared.Next()}", $"collection{Random.Shared.Next()}" }; + foreach (var name in names) + { + await client.CreateCollectionAsync(name, database: database); + } + var first = await client.ListCollectionsAsync(limit: 2, database: database); + var second = await client.ListCollectionsAsync(limit: 2, offset: 2, database: database); + Assert.That(first, Has.Count.EqualTo(2)); + Assert.That(second, Has.Count.EqualTo(1)); + Assert.That(first.Concat(second).Select(x => x.Name), Is.EquivalentTo(names)); + } + [Test] public async Task CreateCollectionWithoutMetadata() { var name = $"collection{Random.Shared.Next()}"; var client = new ChromaClient(BaseConfigurationOptions, HttpClient); - var result = await client.CreateCollection(name); + var result = await client.CreateCollectionAsync(name); Assert.That(result, Is.Not.Null); Assert.That(result.Name, Is.EqualTo(name)); } @@ -72,7 +328,7 @@ public async Task CreateCollectionWithMetadata() }; var client = new ChromaClient(BaseConfigurationOptions, HttpClient); - var result = await client.CreateCollection(name, + var result = await client.CreateCollectionAsync(name, metadata: metadata); Assert.That(result, Is.Not.Null); Assert.That(result.Name, Is.EqualTo(name)); @@ -87,19 +343,18 @@ public async Task CreateCollectionAlreadyExists() var name = $"collection{Random.Shared.Next()}"; var client = new ChromaClient(BaseConfigurationOptions, HttpClient); - await client.CreateCollection(name); - await Assert.ThatAsync(async () => await client.CreateCollection(name), Throws.InstanceOf().With.Message.Not.Null.And.With.Message.Not.Empty); + await client.CreateCollectionAsync(name); + await Assert.ThatAsync(async () => await client.CreateCollectionAsync(name), Throws.InstanceOf().With.Message.Matches($@"^Collection \[?{name}\]? already exists")); } [Test] - [Ignore("Failing because of bug on Chroma's side.", Until = "2025-04-21")] public async Task DeleteCollection() { var name = $"collection{Random.Shared.Next()}"; var client = new ChromaClient(BaseConfigurationOptions, HttpClient); - await client.CreateCollection(name); - await client.DeleteCollection(name); + await client.CreateCollectionAsync(name); + await client.DeleteCollectionAsync(name); } [Test] @@ -108,7 +363,38 @@ public async Task DeleteCollectionNotExists() var name = $"collection{Random.Shared.Next()}"; var client = new ChromaClient(BaseConfigurationOptions, HttpClient); - await Assert.ThatAsync(async () => await client.DeleteCollection(name), Throws.InstanceOf().With.Message.Not.Null.And.With.Message.Not.Empty); + await Assert.ThatAsync(async () => await client.DeleteCollectionAsync(name), Throws.InstanceOf().With.Message.Matches($@"^Collection \[?{name}\]? does not exist")); + } + + // A client made by name works on the collection of that name also after it was deleted and created again elsewhere. + [Test] + public async Task CollectionClientByName() + { + var name = $"collection{Random.Shared.Next()}"; + var client = new ChromaClient(BaseConfigurationOptions, HttpClient); + await client.CreateCollectionAsync(name); + var byName = client.GetCollectionClient(name); + await byName.AddAsync(["a", "b"], embeddings: [new([1f, 0f]), new([0f, 1f])]); + var first = (await byName.GetCollectionAsync()).Id; + await client.DeleteCollectionAsync(name); + await client.CreateCollectionAsync(name); + Assert.That(await byName.CountAsync(), Is.EqualTo(0)); + await byName.AddAsync(["c"], embeddings: [new([1f, 1f])]); + Assert.That((await byName.GetAsync()).Select(x => x.Id), Is.EqualTo(new[] { "c" })); + Assert.That(byName.Collection.Id, Is.Not.EqualTo(first)); + } + + // Every tested server tells a missing collection in its own way, which DeleteCollectionIfExistsAsync recognizes. + [Test] + public async Task DeleteCollectionIfExists() + { + var name = $"collection{Random.Shared.Next()}"; + + var client = new ChromaClient(BaseConfigurationOptions, HttpClient); + await client.CreateCollectionAsync(name); + Assert.That(await client.DeleteCollectionIfExistsAsync(name), Is.True); + Assert.That(await client.CollectionExistsAsync(name), Is.False); + Assert.That(await client.DeleteCollectionIfExistsAsync(name), Is.False); } [Test] @@ -117,7 +403,7 @@ public async Task GetOrCreateCollectionDoesNotExist() var name = $"collection{Random.Shared.Next()}"; var client = new ChromaClient(BaseConfigurationOptions, HttpClient); - var result = await client.GetOrCreateCollection(name); + var result = await client.GetOrCreateCollectionAsync(name); Assert.That(result, Is.Not.Null); Assert.That(result.Name, Is.EqualTo(name)); } @@ -128,8 +414,8 @@ public async Task GetOrCreateCollectionDoesExist() var name = $"collection{Random.Shared.Next()}"; var client = new ChromaClient(BaseConfigurationOptions, HttpClient); - var result1 = await client.GetOrCreateCollection(name); - var result2 = await client.GetOrCreateCollection(name); + var result1 = await client.GetOrCreateCollectionAsync(name); + var result2 = await client.GetOrCreateCollectionAsync(name); Assert.That(result1, Is.Not.Null); Assert.That(result1.Name, Is.EqualTo(name)); Assert.That(result2, Is.Not.Null); @@ -138,12 +424,12 @@ public async Task GetOrCreateCollectionDoesExist() } [Test] - [Ignore("Failing because of bug on Chroma's side.", Until = "2025-04-21")] public async Task CountCollections() { + Assume.That(CountCollectionsAndNotContainsSupported, Is.True, "Chroma 0.4.15 has no count_collections, no $not_contains filter and no tenant and database in the collections."); var client = new ChromaClient(BaseConfigurationOptions, HttpClient); - var list = await client.ListCollections(); - var result = await client.CountCollections(); + var list = await client.ListCollectionsAsync(); + var result = await client.CountCollectionsAsync(); Assert.That(result, Is.EqualTo(list.Count)); } } diff --git a/ChromaDB.Client.Tests/CollectionClientByNameTests.cs b/ChromaDB.Client.Tests/CollectionClientByNameTests.cs new file mode 100644 index 0000000..e10dc87 --- /dev/null +++ b/ChromaDB.Client.Tests/CollectionClientByNameTests.cs @@ -0,0 +1,413 @@ +using System.Net; +using System.Text.Json; +using ChromaDB.Client.Models; +using NUnit.Framework; + +namespace ChromaDB.Client.Tests; + +// A collection client made by name reads the collection before its first request, and again when a request fails on the id it read +// before: when the name has another id now, the operation runs again, once, on that collection. +[TestFixture] +public class CollectionClientByNameTests +{ + const string First = "11111111-1111-1111-1111-111111111111"; + const string Second = "22222222-2222-2222-2222-222222222222"; + const string Missing = """{"error":"NotFoundError","message":"Collection [11111111-1111-1111-1111-111111111111] does not exist."}"""; + + [Test] + public async Task ReadsTheCollectionOnce() + { + var server = new FakeServer(First); + var collection = Client(server).GetCollectionClient("c"); + Assert.That((collection.Collection.Name, collection.Collection.Id), Is.EqualTo(("c", Guid.Empty))); + Assert.That(server.Requests, Is.Empty); + Assert.That(await collection.CountAsync(), Is.EqualTo(3)); + Assert.That(await collection.CountAsync(), Is.EqualTo(3)); + Assert.That(server.Requests, Is.EqualTo(new[] { "GET collections/c", $"GET collections/{First}/count", $"GET collections/{First}/count" })); + Assert.That(collection.Collection.Id, Is.EqualTo(Guid.Parse(First))); + } + + // The collection was deleted and created again elsewhere: the client reads it again and counts on the new one. + [Test] + public async Task ReadsTheCollectionAgainWhenItsIdIsGone() + { + var server = new FakeServer(First); + var collection = Client(server).GetCollectionClient("c"); + await collection.CountAsync(); + server.Id = Second; + server.MissingId = First; + Assert.That(await collection.CountAsync(), Is.EqualTo(3)); + Assert.That(server.Requests.Skip(2), Is.EqualTo(new[] { $"GET collections/{First}/count", "GET collections/c", $"GET collections/{Second}/count" })); + Assert.That(collection.Collection.Id, Is.EqualTo(Guid.Parse(Second))); + } + + // A write that fails after some of its batches went is not run again on a collection created again under the name: those batches + // stay in the collection that was there, and the exception says how many records went. + [Test] + public async Task APartialWriteIsNotRunAgain() + { + var server = new FakeServer(First); + server.OnRequest = path => + { + if (path.EndsWith("/add")) + { + (server.Id, server.MissingId) = (Second, First); + } + }; + var client = new ChromaClient(new ChromaConfigurationOptions("http://localhost:8000").WithBatchSplitting(1), new HttpClient(server)); + var collection = client.GetCollectionClient("c"); + await collection.CountAsync(); + await Assert.ThatAsync(() => collection.AddAsync(["a", "b"], [new([1f]), new([2f])]), Throws.InstanceOf().With.Message.Contains("1 of the 2 records")); + Assert.That(server.Requests.Where(x => x.EndsWith("/add")), Is.EqualTo(new[] { $"POST collections/{First}/add", $"POST collections/{First}/add" })); + } + + // An id just read is not read again, and an id that stays the same keeps the failure. + [Test] + public async Task TheSameIdKeepsTheFailure() + { + var server = new FakeServer(First) { MissingId = First }; + var collection = Client(server).GetCollectionClient("c"); + await Assert.ThatAsync(() => collection.CountAsync(), Throws.InstanceOf().With.Message.Contains("does not exist")); + Assert.That(server.Requests, Is.EqualTo(new[] { "GET collections/c", $"GET collections/{First}/count" })); + await Assert.ThatAsync(() => collection.CountAsync(), Throws.InstanceOf().With.Message.Contains("does not exist")); + Assert.That(server.Requests.Skip(2), Is.EqualTo(new[] { $"GET collections/{First}/count", "GET collections/c" })); + } + + // Any failure counts, as the servers tell a collection gone in their own ways: Chroma 0.4 answers 500 with "coroutine raised StopIteration". + // Without attempts, as a read goes again after a 500 before (ReadRetriesTests). + [Test] + public async Task AnyFailureReadsTheCollectionAgain() + { + var server = new FakeServer(First); + var collection = Client(server, ChromaReadRetries.None).GetCollectionClient("c"); + await collection.CountAsync(); + server.MissingId = First; + server.MissingAnswer = (HttpStatusCode.InternalServerError, """{"error":"RuntimeError('coroutine raised StopIteration')"}"""); + await Assert.ThatAsync(() => collection.CountAsync(), Throws.InstanceOf().With.Message.EqualTo("coroutine raised StopIteration")); + server.Id = Second; + Assert.That(await collection.CountAsync(), Is.EqualTo(3)); + Assert.That(server.Requests.Skip(2), Is.EqualTo(new[] { $"GET collections/{First}/count", "GET collections/c", $"GET collections/{First}/count", "GET collections/c", $"GET collections/{Second}/count" })); + } + + // With ReadRetries.Default, a 500 of a collection gone goes again first, up to the attempts, and then the client reads the collection. + [Test] + public async Task AnyFailureReadsTheCollectionAgainAfterTheAttempts() + { + var server = new FakeServer(First); + var collection = Client(server).GetCollectionClient("c"); + await collection.CountAsync(); + server.MissingId = First; + server.MissingAnswer = (HttpStatusCode.InternalServerError, """{"error":"RuntimeError('coroutine raised StopIteration')"}"""); + server.Id = Second; + Assert.That(await collection.CountAsync(), Is.EqualTo(3)); + Assert.That(server.Requests.Skip(2), Is.EqualTo(Enumerable.Repeat($"GET collections/{First}/count", ChromaReadRetries.Default.MaxAttempts) + .Concat(["GET collections/c", $"GET collections/{Second}/count"]))); + } + + // When the name has no collection either, the failure of the operation stands. + [Test] + public async Task NoCollectionOfTheNameKeepsTheFailure() + { + var server = new FakeServer(First); + var collection = Client(server).GetCollectionClient("c"); + await collection.CountAsync(); + server.MissingId = First; + server.NameMissing = true; + await Assert.ThatAsync(() => collection.CountAsync(), Throws.InstanceOf().With.Message.Contains(First)); + Assert.That(server.Requests.Skip(2), Is.EqualTo(new[] { $"GET collections/{First}/count", "GET collections/c" })); + } + + // Also an operation without a result, like an upsert, runs again on the collection of the name. + [Test] + public async Task OperationsWithoutAResult() + { + var server = new FakeServer(First); + var collection = Client(server).GetCollectionClient("c"); + await collection.DeleteAsync(["a"]); + server.Id = Second; + server.MissingId = First; + await collection.DeleteAsync(["a"]); + Assert.That(server.Requests.Where(x => x.EndsWith("/delete")), Is.EqualTo(new[] { $"POST collections/{First}/delete", $"POST collections/{First}/delete", $"POST collections/{Second}/delete" })); + } + + [Test] + public async Task GetCollectionAsync() + { + var server = new FakeServer(First); + var client = Client(server); + var byName = client.GetCollectionClient("c"); + Assert.That((await byName.GetCollectionAsync()).Id, Is.EqualTo(Guid.Parse(First))); + Assert.That((await byName.GetCollectionAsync()).Id, Is.EqualTo(Guid.Parse(First))); + var byId = client.GetCollectionClient(Guid.Parse(Second), "c"); + Assert.That((await byId.GetCollectionAsync()).Id, Is.EqualTo(Guid.Parse(Second))); + Assert.That(server.Requests, Is.EqualTo(new[] { "GET collections/c" })); + } + + // The operation runs again from the records given: what the client computed for the collection that is gone, like the sparse + // vectors of its schema, does not go to the new one. + [Test] + public async Task RunsAgainFromTheRecordsGiven() + { + var server = new FakeServer(First) { Schema = Bm25Schema }; + var collection = Client(server).GetCollectionClient("c"); + await collection.CountAsync(); + server.Id = Second; + server.Schema = null; + server.MissingId = First; + await collection.UpsertAsync(new ChromaRecords(["a"]) { Embeddings = [new([1f, 0f])], Documents = ["apple pie"] }); + var upserts = server.Bodies.Where(x => x.Path.EndsWith("/upsert")).Select(x => x.Body).ToList(); + Assert.That(server.Bodies.Where(x => x.Path.EndsWith("/upsert")).Select(x => x.Path), Is.EqualTo(new[] { $"collections/{First}/upsert", $"collections/{Second}/upsert" })); + Assert.That(upserts[0].GetProperty("metadatas")[0].TryGetProperty("doc_bm25", out _), Is.True); + Assert.That(upserts[1].GetProperty("metadatas").ValueKind, Is.EqualTo(JsonValueKind.Null)); + } + + // GetCollectionClient(name, createIfMissing: true) sends nothing until the first operation, which gets or creates the collection; + // the next operations use it. + [Test] + public async Task CreatesTheCollectionAtTheFirstOperation() + { + var server = new FakeServer(First); + var collection = Client(server).GetCollectionClient("c", createIfMissing: true); + Assert.That(server.Requests, Is.Empty); + Assert.That(await collection.CountAsync(), Is.EqualTo(3)); + Assert.That(await collection.CountAsync(), Is.EqualTo(3)); + Assert.That(OnTheCollections(server), Is.EqualTo(new[] { "POST collections", $"GET collections/{First}/count", $"GET collections/{First}/count" })); + Assert.That(server.Bodies.Single(x => x.Path == "collections").Body.GetProperty("get_or_create").GetBoolean(), Is.True); + Assert.That(collection.Collection.Id, Is.EqualTo(Guid.Parse(First))); + } + + [Test] + public async Task WithoutCreateIfMissingItReadsTheCollection() + { + var server = new FakeServer(First); + await Client(server).GetCollectionClient("c", createIfMissing: false).CountAsync(); + await Client(server).GetCollectionClient(new ChromaCollectionDefinition("c"), createIfMissing: false).CountAsync(); + Assert.That(OnTheCollections(server), Is.EqualTo(new[] { "GET collections/c", $"GET collections/{First}/count", "GET collections/c", $"GET collections/{First}/count" })); + } + + // First operations that start together share one request of the collection. + [Test] + public async Task FirstOperationsShareTheRequest() + { + var server = new FakeServer(First) { CreateGate = new(TaskCreationOptions.RunContinuationsAsynchronously) }; + var collection = Client(server).GetCollectionClient("c", createIfMissing: true); + var counts = Enumerable.Range(0, 8).Select(_ => collection.CountAsync()).ToList(); + server.CreateGate.SetResult(true); + Assert.That(await Task.WhenAll(counts), Is.All.EqualTo(3)); + Assert.That(server.Requests.Count(x => x == "POST collections"), Is.EqualTo(1)); + } + + // A first operation cancelled while the collection is asked for stops at once; the others wait for the same request. + [Test] + public async Task ACancelledFirstOperationLeavesTheOthers() + { + var server = new FakeServer(First) { CreateGate = new(TaskCreationOptions.RunContinuationsAsynchronously) }; + var collection = Client(server).GetCollectionClient("c", createIfMissing: true); + using var cancellation = new CancellationTokenSource(); + var cancelled = collection.CountAsync(cancellation.Token); + var other = collection.CountAsync(); + cancellation.Cancel(); + await Assert.ThatAsync(() => cancelled.WaitAsync(TimeSpan.FromSeconds(5)), Throws.InstanceOf()); + server.CreateGate.SetResult(true); + Assert.That(await other, Is.EqualTo(3)); + Assert.That(server.Requests.Count(x => x == "POST collections"), Is.EqualTo(1)); + } + + // An operation cancelled before it starts sends no request, also not the creation, and GetOrCreateCollectionClientAsync neither. + [Test] + public async Task AnOperationCancelledBeforeItStartsCreatesNothing() + { + var server = new FakeServer(First); + var client = Client(server); + using var cancellation = new CancellationTokenSource(); + cancellation.Cancel(); + await Assert.ThatAsync(() => client.GetCollectionClient("c", createIfMissing: true).CountAsync(cancellation.Token), Throws.InstanceOf()); + await Assert.ThatAsync(() => client.GetOrCreateCollectionClientAsync("c", cancellation.Token), Throws.InstanceOf()); + Assert.That(server.Requests, Is.Empty); + } + + // Every operation waiting for the first read is cancelled, and the read then fails: the next operation sends a new request. + [Test] + public async Task AFailureAfterEveryWaiterIsCancelledIsNotKept() + { + var server = new FakeServer(First) { CreateGate = new(TaskCreationOptions.RunContinuationsAsynchronously), CreateFailures = 1 }; + var collection = Client(server).GetCollectionClient("c", createIfMissing: true); + using var cancellation = new CancellationTokenSource(); + var cancelled = collection.CountAsync(cancellation.Token); + var read = collection.FirstRead!; + cancellation.Cancel(); + await Assert.ThatAsync(() => cancelled.WaitAsync(TimeSpan.FromSeconds(5)), Throws.InstanceOf()); + server.CreateGate.SetResult(true); + await Assert.ThatAsync(() => read, Throws.InstanceOf()); + Assert.That(await collection.CountAsync(), Is.EqualTo(3)); + Assert.That(server.Requests.Count(x => x == "POST collections"), Is.EqualTo(2)); + } + + // The collection a client of WithMetadataValues gets or creates is read with its own way of reading metadata values, as when it is + // read by name. + [Test] + public async Task TheCreationReadsWithTheMetadataValuesOfTheClient() + { + var server = new FakeServer(First) { CollectionMetadata = """{"date":"2026-10-04"}""" }; + var inferred = new ChromaClient(new ChromaConfigurationOptions("http://localhost:8000").WithMetadataValues(ChromaMetadataValues.Inferred), new HttpClient(server)); + var exact = inferred.GetCollectionClient("c", createIfMissing: true).WithMetadataValues(ChromaMetadataValues.Exact); + Assert.That((await exact.GetCollectionAsync()).Metadata!["date"], Is.EqualTo("2026-10-04")); + var byName = inferred.GetCollectionClient("c").WithMetadataValues(ChromaMetadataValues.Exact); + Assert.That((await byName.GetCollectionAsync()).Metadata!["date"], Is.EqualTo("2026-10-04")); + } + + // A creation that fails throws in the operation, and the next operation asks again. + [Test] + public async Task AFailedCreationGoesAgainAtTheNextOperation() + { + var server = new FakeServer(First) { CreateFailures = 1 }; + var collection = Client(server).GetCollectionClient("c", createIfMissing: true); + await Assert.ThatAsync(() => collection.CountAsync(), Throws.InstanceOf()); + Assert.That(await collection.CountAsync(), Is.EqualTo(3)); + Assert.That(server.Requests.Count(x => x == "POST collections"), Is.EqualTo(2)); + } + + // A collection deleted elsewhere is created again, empty, with the same definition, and the operation runs again on it, once. + [Test] + public async Task ACollectionDeletedElsewhereIsCreatedAgain() + { + var server = new FakeServer(First); + var collection = Client(server).GetCollectionClient("c", createIfMissing: true); + await collection.CountAsync(); + (server.Id, server.MissingId) = (Second, First); + Assert.That(await collection.CountAsync(), Is.EqualTo(3)); + Assert.That(OnTheCollections(server).Skip(2), Is.EqualTo(new[] { $"GET collections/{First}/count", "POST collections", $"GET collections/{Second}/count" })); + Assert.That(collection.Collection.Id, Is.EqualTo(Guid.Parse(Second))); + } + + // A write that went in part is not run again on the collection created again. + [Test] + public async Task APartialWriteIsNotRunAgainOnACollectionCreatedAgain() + { + var server = new FakeServer(First); + server.OnRequest = path => + { + if (path.EndsWith("/add")) + { + (server.Id, server.MissingId) = (Second, First); + } + }; + var client = new ChromaClient(new ChromaConfigurationOptions("http://localhost:8000").WithBatchSplitting(1), new HttpClient(server)); + var collection = client.GetCollectionClient("c", createIfMissing: true); + await collection.CountAsync(); + await Assert.ThatAsync(() => collection.AddAsync(["a", "b"], [new([1f]), new([2f])]), Throws.InstanceOf().With.Message.Contains("1 of the 2 records")); + Assert.That(server.Requests.Count(x => x == "POST collections"), Is.EqualTo(1)); + } + + // The clients that the With methods return create the collection too. + [Test] + public async Task TheWithMethodsKeepTheCreation() + { + var server = new FakeServer(First); + var collection = Client(server).GetCollectionClient("c", createIfMissing: true).WithMetadataValues(ChromaMetadataValues.Exact).WithDocumentCopyKey("text"); + await collection.CountAsync(); + Assert.That(OnTheCollections(server), Is.EqualTo(new[] { "POST collections", $"GET collections/{First}/count" })); + } + + // The definition goes with the creation. + [Test] + public async Task TheDefinitionGoesWithTheCreation() + { + var server = new FakeServer(First); + var collection = Client(server).GetCollectionClient(new ChromaCollectionDefinition("c") { Metadata = new Dictionary { ["k"] = "v" } }, createIfMissing: true); + await collection.CountAsync(); + Assert.That(server.Bodies.Single(x => x.Path == "collections").Body.GetProperty("metadata").GetProperty("k").GetString(), Is.EqualTo("v")); + } + + // GetOrCreateCollectionClientAsync gets or creates the collection now, and its errors come from it; then the client works as the + // one of GetCollectionClient(name, createIfMissing: true). + [Test] + public async Task GetOrCreateCollectionClientAsyncCreatesNow() + { + var server = new FakeServer(First); + var client = Client(server); + var collection = await client.GetOrCreateCollectionClientAsync("c"); + Assert.That(OnTheCollections(server), Is.EqualTo(new[] { "POST collections" })); + Assert.That(collection.Collection.Id, Is.EqualTo(Guid.Parse(First))); + await collection.CountAsync(); + var defined = await client.GetOrCreateCollectionClientAsync(new ChromaCollectionDefinition("c") { Metadata = new Dictionary { ["k"] = "v" } }); + Assert.That(OnTheCollections(server), Is.EqualTo(new[] { "POST collections", $"GET collections/{First}/count", "POST collections" })); + Assert.That(server.Bodies.Last(x => x.Path == "collections").Body.GetProperty("metadata").GetProperty("k").GetString(), Is.EqualTo("v")); + (server.Id, server.MissingId) = (Second, First); + Assert.That(await defined.CountAsync(), Is.EqualTo(3)); + Assert.That(defined.Collection.Id, Is.EqualTo(Guid.Parse(Second))); + } + + [Test] + public async Task GetOrCreateCollectionClientAsyncThrowsAtOnce() + { + var server = new FakeServer(First) { CreateFailures = 1 }; + await Assert.ThatAsync(() => Client(server).GetOrCreateCollectionClientAsync("c"), Throws.InstanceOf()); + } + + // The requests on the collections, without the ones on the server, like its version. + static IEnumerable OnTheCollections(FakeServer server) => server.Requests.Where(x => !x.Contains("/api/")); + + const string Bm25Schema = """{"defaults":{},"keys":{"doc_bm25":{"sparse_vector":{"sparse_vector_index":{"enabled":true,"config":{"embedding_function":{"type":"known","name":"chroma_bm25","config":{}},"source_key":"#document"}}}}}}"""; + + static ChromaClient Client(HttpMessageHandler handler, ChromaReadRetries? retries = null) + => new(new ChromaConfigurationOptions("http://localhost:8000").WithBatchSplitting(false).WithReadRetries(retries ?? ChromaReadRetries.Default), new HttpClient(handler)); + + // Answers the collection c with Id and the space l2, as Chroma 1.5.9 reports it, or as missing with NameMissing, MissingAnswer to the requests on MissingId, and 3 for a count; + // records the requests, without the prefix of the database. + sealed class FakeServer(string id) : HttpMessageHandler + { + public string Id { get; set; } = id; + public string? MissingId { get; set; } + public (HttpStatusCode Status, string Body) MissingAnswer { get; set; } = (HttpStatusCode.NotFound, Missing); + public bool NameMissing { get; set; } + public string? Schema { get; set; } + public Action? OnRequest { get; set; } + // get_or_create: answered when the gate opens, with an error for the first CreateFailures. + public TaskCompletionSource? CreateGate { get; init; } + public int CreateFailures { get; set; } + public string? CollectionMetadata { get; init; } + public List Requests { get; } = []; + public List<(string Path, JsonElement Body)> Bodies { get; } = []; + + protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + var path = request.RequestUri!.AbsolutePath.Replace("/api/v2/tenants/default_tenant/databases/default_database/", ""); + // The first operations of a test can send their requests at the same time: List takes one writer at a time. + lock (Requests) + { + Requests.Add($"{request.Method} {path}"); + } + if (request.Content is not null) + { + var parsed = JsonDocument.Parse(await request.Content.ReadAsStringAsync(cancellationToken)).RootElement.Clone(); + lock (Bodies) + { + Bodies.Add((path, parsed)); + } + } + if (path == "collections" && request.Method == HttpMethod.Post) + { + if (CreateGate is { } gate) + { + await gate.Task; + } + if (CreateFailures > 0) + { + CreateFailures--; + return new HttpResponseMessage(HttpStatusCode.InternalServerError) { Content = new StringContent("""{"error":"InternalError","message":"Error creating the collection"}""") }; + } + path = "collections/c"; + } + var (status, body) = path == "collections/c" && NameMissing ? (HttpStatusCode.NotFound, """{"error":"NotFoundError","message":"Collection [c] does not exist"}""") + : path == "collections/c" ? (HttpStatusCode.OK, $$$"""{"id":"{{{Id}}}","name":"c","configuration_json":{"hnsw":{"space":"l2"}},"metadata":{{{CollectionMetadata ?? "null"}}},"schema":{{{Schema ?? "null"}}}}""") + : MissingId is { } missing && path.Contains(missing) ? MissingAnswer + : path.EndsWith("/count") ? (HttpStatusCode.OK, "3") + : path.EndsWith("/version") ? (HttpStatusCode.OK, "\"1.5.9\"") + : (HttpStatusCode.OK, "{}"); + // After the answer: a change of the server shows from the next request. + OnRequest?.Invoke(path); + return new HttpResponseMessage(status) { Content = new StringContent(body) }; + } + } +} diff --git a/ChromaDB.Client.Tests/CollectionClientCUDTests.cs b/ChromaDB.Client.Tests/CollectionClientCUDTests.cs index fa0525a..0afce30 100644 --- a/ChromaDB.Client.Tests/CollectionClientCUDTests.cs +++ b/ChromaDB.Client.Tests/CollectionClientCUDTests.cs @@ -1,3 +1,4 @@ +using ChromaDB.Client.Models; using NUnit.Framework; namespace ChromaDB.Client.Tests; @@ -9,14 +10,14 @@ public class CollectionClientCUDTests : ChromaTestsBase public async Task AddJustIds() { var client = await Init(); - await client.Add([$"{Guid.NewGuid()}"]); + await WithoutEmbeddings(() => client.AddAsync([$"{Guid.NewGuid()}"])); } [Test] public async Task AddWithEmbeddings() { var client = await Init(); - await client.Add([$"{Guid.NewGuid()}"], + await client.AddAsync([$"{Guid.NewGuid()}"], embeddings: [new([1f, 0.5f, 0f, -0.5f, -1f])]); } @@ -24,27 +25,27 @@ await client.Add([$"{Guid.NewGuid()}"], public async Task AddWithMetadatas() { var client = await Init(); - await client.Add([$"{Guid.NewGuid()}"], + await WithoutEmbeddings(() => client.AddAsync([$"{Guid.NewGuid()}"], metadatas: [new Dictionary { { "key", "value" }, { "key2", 10 }, - }]); + }])); } [Test] public async Task AddWithDocuments() { var client = await Init(); - await client.Add([$"{Guid.NewGuid()}"], - documents: ["test"]); + await WithoutEmbeddings(() => client.AddAsync([$"{Guid.NewGuid()}"], + documents: ["test"])); } [Test] public async Task AddWithAll() { var client = await Init(); - await client.Add([$"{Guid.NewGuid()}"], + await client.AddAsync([$"{Guid.NewGuid()}"], embeddings: [new([1f, 0.5f, 0f, -0.5f, -1f])], metadatas: [new Dictionary { @@ -60,8 +61,8 @@ public async Task UpdateNothing() var id = $"{Guid.NewGuid()}"; var client = await Init(); - await client.Add([id]); - await client.Update([id]); + await client.AddAsync([id], embeddings: Embeddings(1)); + await client.UpdateAsync([id]); } [Test] @@ -70,8 +71,8 @@ public async Task UpdateEmbeddings() var id = $"{Guid.NewGuid()}"; var client = await Init(); - await client.Add([id]); - await client.Update([id], + await client.AddAsync([id], embeddings: Embeddings(1)); + await client.UpdateAsync([id], embeddings: [new([1f, 0.5f, 0f, -0.5f, -1f])]); } @@ -81,8 +82,8 @@ public async Task UpdateMetadatas() var id = $"{Guid.NewGuid()}"; var client = await Init(); - await client.Add([id]); - await client.Update([id], + await client.AddAsync([id], embeddings: Embeddings(1)); + await client.UpdateAsync([id], metadatas: [new Dictionary { { "key", "value" }, @@ -96,8 +97,8 @@ public async Task UpdateDocuments() var id = $"{Guid.NewGuid()}"; var client = await Init(); - await client.Add([id]); - await client.Update([id], + await client.AddAsync([id], embeddings: Embeddings(1)); + await client.UpdateAsync([id], documents: ["test"]); } @@ -107,8 +108,8 @@ public async Task UpdateAll() var id = $"{Guid.NewGuid()}"; var client = await Init(); - await client.Add([id]); - await client.Update([id], + await client.AddAsync([id], embeddings: Embeddings(1)); + await client.UpdateAsync([id], embeddings: [new([1f, 0.5f, 0f, -0.5f, -1f])], metadatas: [new Dictionary { @@ -118,18 +119,33 @@ await client.Update([id], documents: ["test"]); } + // Without NullDocumentsDelete the client sends a null document as it is: Chroma 1.0.0 and later and Chroma Cloud keep the stored + // document, as in an update without documents, and every 0.x server deletes it. + [Test] + public async Task NullDocumentInAnUpdateAndAnUpsert() + { + var client = await Init(); + await client.AddAsync(["a", "b", "c"], embeddings: Embeddings(3), documents: ["doc a", "doc b", "doc c"]); + var metadata = new Dictionary { ["k"] = 2 }; + await client.UpdateAsync(new ChromaRecords(["a"]) { Metadatas = [metadata], Documents = [null] }); + await client.UpsertAsync(new ChromaRecords(["b"]) { Embeddings = Embeddings(1), Metadatas = [metadata], Documents = [null] }); + await client.UpdateAsync(["c"], metadatas: [metadata]); + var documents = (await client.GetAsync(include: ChromaGetInclude.Documents)).OrderBy(x => x.Id).Select(x => x.Document); + Assert.That(documents, Is.EqualTo(NullDocumentKept ? new[] { "doc a", "doc b", "doc c" } : new[] { null, null, "doc c" })); + } + [Test] public async Task UpsertJustIds() { var client = await Init(); - await client.Upsert([$"{Guid.NewGuid()}"]); + await WithoutEmbeddings(() => client.UpsertAsync([$"{Guid.NewGuid()}"])); } [Test] public async Task UpsertWithEmbeddings() { var client = await Init(); - await client.Upsert([$"{Guid.NewGuid()}"], + await client.UpsertAsync([$"{Guid.NewGuid()}"], embeddings: [new([1f, 0.5f, 0f, -0.5f, -1f])]); } @@ -137,27 +153,27 @@ await client.Upsert([$"{Guid.NewGuid()}"], public async Task UpsertWithMetadatas() { var client = await Init(); - await client.Upsert([$"{Guid.NewGuid()}"], + await WithoutEmbeddings(() => client.UpsertAsync([$"{Guid.NewGuid()}"], metadatas: [new Dictionary { { "key", "value" }, { "key2", 10 }, - }]); + }])); } [Test] public async Task UpsertWithDocuments() { var client = await Init(); - await client.Upsert([$"{Guid.NewGuid()}"], - documents: ["test"]); + await WithoutEmbeddings(() => client.UpsertAsync([$"{Guid.NewGuid()}"], + documents: ["test"])); } [Test] public async Task UpsertWithAll() { var client = await Init(); - await client.Upsert([$"{Guid.NewGuid()}"], + await client.UpsertAsync([$"{Guid.NewGuid()}"], embeddings: [new([1f, 0.5f, 0f, -0.5f, -1f])], metadatas: [new Dictionary { @@ -173,8 +189,8 @@ public async Task DeleteByIdExisting() var id = $"{Guid.NewGuid()}"; var client = await Init(); - await client.Add([id]); - await client.Delete([id]); + await client.AddAsync([id], embeddings: Embeddings(1)); + await client.DeleteAsync([id]); } [Test] @@ -183,7 +199,7 @@ public async Task DeleteByIdNonExisting() var id = $"{Guid.NewGuid()}"; var client = await Init(); - await client.Delete([id]); + await client.DeleteAsync([id]); } [Test] @@ -193,8 +209,8 @@ public async Task DeleteByMultipleIds() var id2 = $"{Guid.NewGuid()}"; var client = await Init(); - await client.Add([id1, id2]); - await client.Delete([id1, id2]); + await client.AddAsync([id1, id2], embeddings: Embeddings(2)); + await client.DeleteAsync([id1, id2]); } [Test] @@ -204,8 +220,8 @@ public async Task DeleteByMultipleIdsOneNonExisting() var id2 = $"{Guid.NewGuid()}"; var client = await Init(); - await client.Add([id1]); - await client.Delete([id1, id2]); + await client.AddAsync([id1], embeddings: Embeddings(1)); + await client.DeleteAsync([id1, id2]); } [Test] @@ -215,14 +231,16 @@ public async Task DeleteByMultipleIdsWithWhere() var id2 = $"{Guid.NewGuid()}"; var client = await Init(); - await client.Add([id1, id2], + await client.AddAsync([id1, id2], + embeddings: Embeddings(2), metadatas: [new Dictionary { + { "key", "other" }, }, new Dictionary { { "key", "value" }, }]); - await client.Delete([id1, id2], + await client.DeleteAsync([id1, id2], where: ChromaWhereOperator.Equal("key", "value")); } @@ -233,17 +251,52 @@ public async Task DeleteByMultipleIdsWithWhereDocument() var id2 = $"{Guid.NewGuid()}"; var client = await Init(); - await client.Add([id1, id2], + await client.AddAsync([id1, id2], + embeddings: Embeddings(2), documents: ["Doc1", "Doc2"]); - await client.Delete([id1, id2], - whereDocument: ChromaWhereDocumentOperator.Contains("2")); + // Three characters tell the documents apart: on Chroma Cloud a $contains of fewer characters throws (KD-57). + await client.DeleteAsync([id1, id2], + whereDocument: ChromaWhereDocumentOperator.Contains("oc2")); + } + + [Test] + public async Task AddWithInconsistentDimensions() + { + Assume.That(EmbeddingDimensionsChecked, Is.True, "Before Chroma 0.5.20 the server accepts embeddings of different dimensions."); + var client = await Init(); + var exception = Assert.ThrowsAsync(() => client.AddAsync(["a", "b"], embeddings: [new([1f, 2f]), new([1f, 2f, 3f])])); + Assert.That(exception!.Message, Does.Contain("dimension").IgnoreCase.And.Not.Contain("{")); + } + + [Test] + public async Task RecordsWithUris() + { + Assume.That(UrisSupported, Is.True, "Chroma 0.4.15 and earlier do not return the URIs of the records."); + var client = await Init(); + await client.AddAsync(new ChromaRecords(["a", "b"]) { Embeddings = Embeddings(2), Uris = ["file://a", null] }); + await client.UpdateAsync(new ChromaRecords(["b"]) { Uris = ["file://b2"] }); + await client.UpsertAsync(new ChromaRecords(["c"]) { Embeddings = Embeddings(1), Uris = ["file://c"] }); + var entries = await client.GetAsync(["a", "b", "c"], include: ChromaGetInclude.Uris); + Assert.That(entries.ToDictionary(x => x.Id, x => x.Uri), Is.EquivalentTo(new Dictionary { ["a"] = "file://a", ["b"] = "file://b2", ["c"] = "file://c" })); + var nearest = await client.QueryAsync(new ReadOnlyMemory([1f, 0.5f, 0f, -0.5f, -1f]), nResults: 3, include: ChromaQueryInclude.Uris); + Assert.That(nearest.Select(x => x.Uri), Is.EquivalentTo(new[] { "file://a", "file://b2", "file://c" })); + } + + // Chroma 0.4.10 to 0.4.15 reject "uris" in include with the validation errors of FastAPI: the message is theirs. + [Test] + public async Task UrisOnAServerWithoutThem() + { + Assume.That(UrisSupported, Is.False, "This server returns the URIs of the records."); + var client = await Init(); + await Assert.ThatAsync(() => client.GetAsync(include: ChromaGetInclude.Uris), + Throws.InstanceOf().With.Message.StartsWith("body.include.0").And.Message.Contains("documents")); } async Task Init() { var name = $"collection{Random.Shared.Next()}"; var client = new ChromaClient(BaseConfigurationOptions, HttpClient); - var collection = await client.GetOrCreateCollection(name); + var collection = await client.GetOrCreateCollectionAsync(name); return new ChromaCollectionClient(collection, BaseConfigurationOptions, HttpClient); } } diff --git a/ChromaDB.Client.Tests/CollectionClientGetTests.cs b/ChromaDB.Client.Tests/CollectionClientGetTests.cs index b2f60b0..c27f1f5 100644 --- a/ChromaDB.Client.Tests/CollectionClientGetTests.cs +++ b/ChromaDB.Client.Tests/CollectionClientGetTests.cs @@ -10,11 +10,11 @@ public class CollectionClientGetTests : ChromaTestsBase public async Task GetSingleIdIncludeNothing() { var client = await Init(); - var result = await client.Get(Id1, + var result = await client.GetAsync(Id1, include: ChromaGetInclude.None); Assert.That(result, Is.Not.Null); Assert.That(result.Id, Is.EqualTo(Id1)); - Assert.That(result.Embeddings, Is.Null); + Assert.That(result.Embedding, Is.Null); Assert.That(result.Metadata, Is.Null); Assert.That(result.Document, Is.Null); } @@ -23,11 +23,11 @@ public async Task GetSingleIdIncludeNothing() public async Task GetSingleIdIncludeEmbeddings() { var client = await Init(); - var result = await client.Get(Id1, + var result = await client.GetAsync(Id1, include: ChromaGetInclude.Embeddings); Assert.That(result, Is.Not.Null); Assert.That(result.Id, Is.EqualTo(Id1)); - Assert.That(result.Embeddings, Is.EqualTo(Embeddings1).Using(EmbeddingsComparer.Instance)); + Assert.That(result.Embedding, Is.EqualTo(Embeddings1).Using(EmbeddingsComparer.Instance)); Assert.That(result.Metadata, Is.Null); Assert.That(result.Document, Is.Null); } @@ -36,11 +36,11 @@ public async Task GetSingleIdIncludeEmbeddings() public async Task GetSingleIdIncludeMetadatas() { var client = await Init(); - var result = await client.Get(Id1, + var result = await client.GetAsync(Id1, include: ChromaGetInclude.Metadatas); Assert.That(result, Is.Not.Null); Assert.That(result.Id, Is.EqualTo(Id1)); - Assert.That(result.Embeddings, Is.Null); + Assert.That(result.Embedding, Is.Null); Assert.That(result.Metadata, Is.EqualTo(Metadata1)); Assert.That(result.Document, Is.Null); } @@ -49,11 +49,11 @@ public async Task GetSingleIdIncludeMetadatas() public async Task GetSingleIdIncludeDocuments() { var client = await Init(); - var result = await client.Get(Id1, + var result = await client.GetAsync(Id1, include: ChromaGetInclude.Documents); Assert.That(result, Is.Not.Null); Assert.That(result.Id, Is.EqualTo(Id1)); - Assert.That(result.Embeddings, Is.Null); + Assert.That(result.Embedding, Is.Null); Assert.That(result.Metadata, Is.Null); Assert.That(result.Document, Is.EqualTo(Doc1)); } @@ -62,11 +62,11 @@ public async Task GetSingleIdIncludeDocuments() public async Task GetSingleIdIncludeAll() { var client = await Init(); - var result = await client.Get(Id1, + var result = await client.GetAsync(Id1, include: ChromaGetInclude.Embeddings | ChromaGetInclude.Metadatas | ChromaGetInclude.Documents); Assert.That(result, Is.Not.Null); Assert.That(result.Id, Is.EqualTo(Id1)); - Assert.That(result.Embeddings, Is.EqualTo(Embeddings1).Using(EmbeddingsComparer.Instance)); + Assert.That(result.Embedding, Is.EqualTo(Embeddings1).Using(EmbeddingsComparer.Instance)); Assert.That(result.Metadata, Is.EqualTo(Metadata1)); Assert.That(result.Document, Is.EqualTo(Doc1)); } @@ -75,16 +75,16 @@ public async Task GetSingleIdIncludeAll() public async Task GetMultipleIdsIncludeNothing() { var client = await Init(); - var result = await client.Get( + var result = await client.GetAsync( ids: [Id1, Id2], include: ChromaGetInclude.None); Assert.That(result, Has.Count.EqualTo(2)); Assert.That(result[0].Id, Is.EqualTo(Id1)); - Assert.That(result[0].Embeddings, Is.Null); + Assert.That(result[0].Embedding, Is.Null); Assert.That(result[0].Metadata, Is.Null); Assert.That(result[0].Document, Is.Null); Assert.That(result[1].Id, Is.EqualTo(Id2)); - Assert.That(result[1].Embeddings, Is.Null); + Assert.That(result[1].Embedding, Is.Null); Assert.That(result[1].Metadata, Is.Null); Assert.That(result[1].Document, Is.Null); } @@ -93,16 +93,16 @@ public async Task GetMultipleIdsIncludeNothing() public async Task GetMultipleIdsIncludeEmbeddings() { var client = await Init(); - var result = await client.Get( + var result = await client.GetAsync( ids: [Id1, Id2], include: ChromaGetInclude.Embeddings); Assert.That(result, Has.Count.EqualTo(2)); Assert.That(result[0].Id, Is.EqualTo(Id1)); - Assert.That(result[0].Embeddings, Is.EqualTo(Embeddings1).Using(EmbeddingsComparer.Instance)); + Assert.That(result[0].Embedding, Is.EqualTo(Embeddings1).Using(EmbeddingsComparer.Instance)); Assert.That(result[0].Metadata, Is.Null); Assert.That(result[0].Document, Is.Null); Assert.That(result[1].Id, Is.EqualTo(Id2)); - Assert.That(result[1].Embeddings, Is.EqualTo(Embeddings2).Using(EmbeddingsComparer.Instance)); + Assert.That(result[1].Embedding, Is.EqualTo(Embeddings2).Using(EmbeddingsComparer.Instance)); Assert.That(result[1].Metadata, Is.Null); Assert.That(result[1].Document, Is.Null); } @@ -111,16 +111,16 @@ public async Task GetMultipleIdsIncludeEmbeddings() public async Task GetMultipleIdsIncludeMetadatas() { var client = await Init(); - var result = await client.Get( + var result = await client.GetAsync( ids: [Id1, Id2], include: ChromaGetInclude.Metadatas); Assert.That(result, Has.Count.EqualTo(2)); Assert.That(result[0].Id, Is.EqualTo(Id1)); - Assert.That(result[0].Embeddings, Is.Null); + Assert.That(result[0].Embedding, Is.Null); Assert.That(result[0].Metadata, Is.EqualTo(Metadata1)); Assert.That(result[0].Document, Is.Null); Assert.That(result[1].Id, Is.EqualTo(Id2)); - Assert.That(result[1].Embeddings, Is.Null); + Assert.That(result[1].Embedding, Is.Null); Assert.That(result[1].Metadata, Is.EqualTo(Metadata2)); Assert.That(result[1].Document, Is.Null); } @@ -129,16 +129,16 @@ public async Task GetMultipleIdsIncludeMetadatas() public async Task GetMultipleIdsIncludeDocuments() { var client = await Init(); - var result = await client.Get( + var result = await client.GetAsync( ids: [Id1, Id2], include: ChromaGetInclude.Documents); Assert.That(result, Has.Count.EqualTo(2)); Assert.That(result[0].Id, Is.EqualTo(Id1)); - Assert.That(result[0].Embeddings, Is.Null); + Assert.That(result[0].Embedding, Is.Null); Assert.That(result[0].Metadata, Is.Null); Assert.That(result[0].Document, Is.EqualTo(Doc1)); Assert.That(result[1].Id, Is.EqualTo(Id2)); - Assert.That(result[1].Embeddings, Is.Null); + Assert.That(result[1].Embedding, Is.Null); Assert.That(result[1].Metadata, Is.Null); Assert.That(result[1].Document, Is.EqualTo(Doc2)); } @@ -147,16 +147,16 @@ public async Task GetMultipleIdsIncludeDocuments() public async Task GetMultipleIdsIncludeAll() { var client = await Init(); - var result = await client.Get( + var result = await client.GetAsync( ids: [Id1, Id2], include: ChromaGetInclude.Embeddings | ChromaGetInclude.Metadatas | ChromaGetInclude.Documents); Assert.That(result, Has.Count.EqualTo(2)); Assert.That(result[0].Id, Is.EqualTo(Id1)); - Assert.That(result[0].Embeddings, Is.EqualTo(Embeddings1).Using(EmbeddingsComparer.Instance)); + Assert.That(result[0].Embedding, Is.EqualTo(Embeddings1).Using(EmbeddingsComparer.Instance)); Assert.That(result[0].Metadata, Is.EqualTo(Metadata1)); Assert.That(result[0].Document, Is.EqualTo(Doc1)); Assert.That(result[1].Id, Is.EqualTo(Id2)); - Assert.That(result[1].Embeddings, Is.EqualTo(Embeddings2).Using(EmbeddingsComparer.Instance)); + Assert.That(result[1].Embedding, Is.EqualTo(Embeddings2).Using(EmbeddingsComparer.Instance)); Assert.That(result[1].Metadata, Is.EqualTo(Metadata2)); Assert.That(result[1].Document, Is.EqualTo(Doc2)); } @@ -165,13 +165,13 @@ public async Task GetMultipleIdsIncludeAll() public async Task GetLimitIncludeAll() { var client = await Init(); - var result = await client.Get( + var result = await client.GetAsync( ids: [Id1, Id2], include: ChromaGetInclude.Embeddings | ChromaGetInclude.Metadatas | ChromaGetInclude.Documents, limit: 1); Assert.That(result, Has.Count.EqualTo(1)); Assert.That(result[0].Id, Is.EqualTo(Id1)); - Assert.That(result[0].Embeddings, Is.EqualTo(Embeddings1).Using(EmbeddingsComparer.Instance)); + Assert.That(result[0].Embedding, Is.EqualTo(Embeddings1).Using(EmbeddingsComparer.Instance)); Assert.That(result[0].Metadata, Is.EqualTo(Metadata1)); Assert.That(result[0].Document, Is.EqualTo(Doc1)); } @@ -180,14 +180,14 @@ public async Task GetLimitIncludeAll() public async Task GetLimitOffsetIncludeAll() { var client = await Init(); - var result = await client.Get( + var result = await client.GetAsync( ids: [Id1, Id2], include: ChromaGetInclude.Embeddings | ChromaGetInclude.Metadatas | ChromaGetInclude.Documents, limit: 1, offset: 1); Assert.That(result, Has.Count.EqualTo(1)); Assert.That(result[0].Id, Is.EqualTo(Id2)); - Assert.That(result[0].Embeddings, Is.EqualTo(Embeddings2).Using(EmbeddingsComparer.Instance)); + Assert.That(result[0].Embedding, Is.EqualTo(Embeddings2).Using(EmbeddingsComparer.Instance)); Assert.That(result[0].Metadata, Is.EqualTo(Metadata2)); Assert.That(result[0].Document, Is.EqualTo(Doc2)); } @@ -196,12 +196,12 @@ public async Task GetLimitOffsetIncludeAll() public async Task GetWhereEqualIncludeAll() { var client = await Init(); - var result = await client.Get( + var result = await client.GetAsync( where: ChromaWhereOperator.Equal(MetadataKey2, Metadata2[MetadataKey2]), include: ChromaGetInclude.Embeddings | ChromaGetInclude.Metadatas | ChromaGetInclude.Documents); Assert.That(result, Has.Count.EqualTo(1)); Assert.That(result[0].Id, Is.EqualTo(Id2)); - Assert.That(result[0].Embeddings, Is.EqualTo(Embeddings2).Using(EmbeddingsComparer.Instance)); + Assert.That(result[0].Embedding, Is.EqualTo(Embeddings2).Using(EmbeddingsComparer.Instance)); Assert.That(result[0].Metadata, Is.EqualTo(Metadata2)); Assert.That(result[0].Document, Is.EqualTo(Doc2)); } @@ -210,12 +210,12 @@ public async Task GetWhereEqualIncludeAll() public async Task GetWhereNotEqualIncludeAll() { var client = await Init(); - var result = await client.Get( + var result = await client.GetAsync( where: ChromaWhereOperator.NotEqual(MetadataKey2, Metadata1[MetadataKey2]), include: ChromaGetInclude.Embeddings | ChromaGetInclude.Metadatas | ChromaGetInclude.Documents); Assert.That(result, Has.Count.EqualTo(1)); Assert.That(result[0].Id, Is.EqualTo(Id2)); - Assert.That(result[0].Embeddings, Is.EqualTo(Embeddings2).Using(EmbeddingsComparer.Instance)); + Assert.That(result[0].Embedding, Is.EqualTo(Embeddings2).Using(EmbeddingsComparer.Instance)); Assert.That(result[0].Metadata, Is.EqualTo(Metadata2)); Assert.That(result[0].Document, Is.EqualTo(Doc2)); } @@ -224,12 +224,12 @@ public async Task GetWhereNotEqualIncludeAll() public async Task GetWhereInIncludeAll() { var client = await Init(); - var result = await client.Get( + var result = await client.GetAsync( where: ChromaWhereOperator.In(MetadataKey2, Metadata2[MetadataKey2]), include: ChromaGetInclude.Embeddings | ChromaGetInclude.Metadatas | ChromaGetInclude.Documents); Assert.That(result, Has.Count.EqualTo(1)); Assert.That(result[0].Id, Is.EqualTo(Id2)); - Assert.That(result[0].Embeddings, Is.EqualTo(Embeddings2).Using(EmbeddingsComparer.Instance)); + Assert.That(result[0].Embedding, Is.EqualTo(Embeddings2).Using(EmbeddingsComparer.Instance)); Assert.That(result[0].Metadata, Is.EqualTo(Metadata2)); Assert.That(result[0].Document, Is.EqualTo(Doc2)); } @@ -238,12 +238,12 @@ public async Task GetWhereInIncludeAll() public async Task GetWhereNotInIncludeAll() { var client = await Init(); - var result = await client.Get( + var result = await client.GetAsync( where: ChromaWhereOperator.NotIn(MetadataKey2, Metadata1[MetadataKey2]), include: ChromaGetInclude.Embeddings | ChromaGetInclude.Metadatas | ChromaGetInclude.Documents); Assert.That(result, Has.Count.EqualTo(1)); Assert.That(result[0].Id, Is.EqualTo(Id2)); - Assert.That(result[0].Embeddings, Is.EqualTo(Embeddings2).Using(EmbeddingsComparer.Instance)); + Assert.That(result[0].Embedding, Is.EqualTo(Embeddings2).Using(EmbeddingsComparer.Instance)); Assert.That(result[0].Metadata, Is.EqualTo(Metadata2)); Assert.That(result[0].Document, Is.EqualTo(Doc2)); } @@ -252,12 +252,12 @@ public async Task GetWhereNotInIncludeAll() public async Task GetWhereGreaterThanIncludeAll() { var client = await Init(); - var result = await client.Get( + var result = await client.GetAsync( where: ChromaWhereOperator.GreaterThan(MetadataKey2, Metadata1[MetadataKey2]), include: ChromaGetInclude.Embeddings | ChromaGetInclude.Metadatas | ChromaGetInclude.Documents); Assert.That(result, Has.Count.EqualTo(1)); Assert.That(result[0].Id, Is.EqualTo(Id2)); - Assert.That(result[0].Embeddings, Is.EqualTo(Embeddings2).Using(EmbeddingsComparer.Instance)); + Assert.That(result[0].Embedding, Is.EqualTo(Embeddings2).Using(EmbeddingsComparer.Instance)); Assert.That(result[0].Metadata, Is.EqualTo(Metadata2)); Assert.That(result[0].Document, Is.EqualTo(Doc2)); } @@ -266,12 +266,12 @@ public async Task GetWhereGreaterThanIncludeAll() public async Task GetWhereLessThanIncludeAll() { var client = await Init(); - var result = await client.Get( + var result = await client.GetAsync( where: ChromaWhereOperator.LessThan(MetadataKey2, Metadata2[MetadataKey2]), include: ChromaGetInclude.Embeddings | ChromaGetInclude.Metadatas | ChromaGetInclude.Documents); Assert.That(result, Has.Count.EqualTo(1)); Assert.That(result[0].Id, Is.EqualTo(Id1)); - Assert.That(result[0].Embeddings, Is.EqualTo(Embeddings1).Using(EmbeddingsComparer.Instance)); + Assert.That(result[0].Embedding, Is.EqualTo(Embeddings1).Using(EmbeddingsComparer.Instance)); Assert.That(result[0].Metadata, Is.EqualTo(Metadata1)); Assert.That(result[0].Document, Is.EqualTo(Doc1)); } @@ -280,12 +280,12 @@ public async Task GetWhereLessThanIncludeAll() public async Task GetWhereGreaterThanOrEqualIncludeAll() { var client = await Init(); - var result = await client.Get( + var result = await client.GetAsync( where: ChromaWhereOperator.GreaterThanOrEqual(MetadataKey2, Metadata2[MetadataKey2]), include: ChromaGetInclude.Embeddings | ChromaGetInclude.Metadatas | ChromaGetInclude.Documents); Assert.That(result, Has.Count.EqualTo(1)); Assert.That(result[0].Id, Is.EqualTo(Id2)); - Assert.That(result[0].Embeddings, Is.EqualTo(Embeddings2).Using(EmbeddingsComparer.Instance)); + Assert.That(result[0].Embedding, Is.EqualTo(Embeddings2).Using(EmbeddingsComparer.Instance)); Assert.That(result[0].Metadata, Is.EqualTo(Metadata2)); Assert.That(result[0].Document, Is.EqualTo(Doc2)); } @@ -294,12 +294,12 @@ public async Task GetWhereGreaterThanOrEqualIncludeAll() public async Task GetWhereLessThanOrEqualIncludeAll() { var client = await Init(); - var result = await client.Get( + var result = await client.GetAsync( where: ChromaWhereOperator.LessThanOrEqual(MetadataKey2, Metadata1[MetadataKey2]), include: ChromaGetInclude.Embeddings | ChromaGetInclude.Metadatas | ChromaGetInclude.Documents); Assert.That(result, Has.Count.EqualTo(1)); Assert.That(result[0].Id, Is.EqualTo(Id1)); - Assert.That(result[0].Embeddings, Is.EqualTo(Embeddings1).Using(EmbeddingsComparer.Instance)); + Assert.That(result[0].Embedding, Is.EqualTo(Embeddings1).Using(EmbeddingsComparer.Instance)); Assert.That(result[0].Metadata, Is.EqualTo(Metadata1)); Assert.That(result[0].Document, Is.EqualTo(Doc1)); } @@ -308,12 +308,12 @@ public async Task GetWhereLessThanOrEqualIncludeAll() public async Task GetWhereAndIncludeAll() { var client = await Init(); - var result = await client.Get( + var result = await client.GetAsync( where: ChromaWhereOperator.Equal(MetadataKey2, Metadata2[MetadataKey2]) && ChromaWhereOperator.NotEqual(MetadataKey2, Metadata1[MetadataKey2]), include: ChromaGetInclude.Embeddings | ChromaGetInclude.Metadatas | ChromaGetInclude.Documents); Assert.That(result, Has.Count.EqualTo(1)); Assert.That(result[0].Id, Is.EqualTo(Id2)); - Assert.That(result[0].Embeddings, Is.EqualTo(Embeddings2).Using(EmbeddingsComparer.Instance)); + Assert.That(result[0].Embedding, Is.EqualTo(Embeddings2).Using(EmbeddingsComparer.Instance)); Assert.That(result[0].Metadata, Is.EqualTo(Metadata2)); Assert.That(result[0].Document, Is.EqualTo(Doc2)); } @@ -322,12 +322,12 @@ public async Task GetWhereAndIncludeAll() public async Task GetWhereOrIncludeAll() { var client = await Init(); - var result = await client.Get( + var result = await client.GetAsync( where: ChromaWhereOperator.Equal(MetadataKey2, Metadata2[MetadataKey2]) || ChromaWhereOperator.NotEqual(MetadataKey2, Metadata1[MetadataKey2]), include: ChromaGetInclude.Embeddings | ChromaGetInclude.Metadatas | ChromaGetInclude.Documents); Assert.That(result, Has.Count.EqualTo(1)); Assert.That(result[0].Id, Is.EqualTo(Id2)); - Assert.That(result[0].Embeddings, Is.EqualTo(Embeddings2).Using(EmbeddingsComparer.Instance)); + Assert.That(result[0].Embedding, Is.EqualTo(Embeddings2).Using(EmbeddingsComparer.Instance)); Assert.That(result[0].Metadata, Is.EqualTo(Metadata2)); Assert.That(result[0].Document, Is.EqualTo(Doc2)); } @@ -336,12 +336,12 @@ public async Task GetWhereOrIncludeAll() public async Task GetWhereDocumentContainsIncludeDocuments() { var client = await Init(); - var result = await client.Get( - whereDocument: ChromaWhereDocumentOperator.Contains(Doc1[^1]), + var result = await client.GetAsync( + whereDocument: ChromaWhereDocumentOperator.Contains(Doc1[^3..]), include: ChromaGetInclude.Documents); Assert.That(result, Has.Count.EqualTo(1)); Assert.That(result[0].Id, Is.EqualTo(Id1)); - Assert.That(result[0].Embeddings, Is.Null); + Assert.That(result[0].Embedding, Is.Null); Assert.That(result[0].Metadata, Is.Null); Assert.That(result[0].Document, Is.EqualTo(Doc1)); } @@ -349,13 +349,14 @@ public async Task GetWhereDocumentContainsIncludeDocuments() [Test] public async Task GetWhereDocumentNotContainsIncludeDocuments() { + Assume.That(CountCollectionsAndNotContainsSupported, Is.True, "Chroma 0.4.15 has no count_collections, no $not_contains filter and no tenant and database in the collections."); var client = await Init(); - var result = await client.Get( - whereDocument: ChromaWhereDocumentOperator.NotContains(Doc2[^1]), + var result = await client.GetAsync( + whereDocument: ChromaWhereDocumentOperator.NotContains(Doc2[^3..]), include: ChromaGetInclude.Documents); Assert.That(result, Has.Count.EqualTo(1)); Assert.That(result[0].Id, Is.EqualTo(Id1)); - Assert.That(result[0].Embeddings, Is.Null); + Assert.That(result[0].Embedding, Is.Null); Assert.That(result[0].Metadata, Is.Null); Assert.That(result[0].Document, Is.EqualTo(Doc1)); } @@ -363,13 +364,14 @@ public async Task GetWhereDocumentNotContainsIncludeDocuments() [Test] public async Task GetWhereDocumentAndOrIncludeDocuments() { + Assume.That(CountCollectionsAndNotContainsSupported, Is.True, "Chroma 0.4.15 has no count_collections, no $not_contains filter and no tenant and database in the collections."); var client = await Init(); - var result = await client.Get( + var result = await client.GetAsync( whereDocument: ChromaWhereDocumentOperator.Contains(Doc1) && ChromaWhereDocumentOperator.NotContains(Doc1) || ChromaWhereDocumentOperator.NotContains(Doc2), include: ChromaGetInclude.Documents); Assert.That(result, Has.Count.EqualTo(1)); Assert.That(result[0].Id, Is.EqualTo(Id1)); - Assert.That(result[0].Embeddings, Is.Null); + Assert.That(result[0].Embedding, Is.Null); Assert.That(result[0].Metadata, Is.Null); Assert.That(result[0].Document, Is.EqualTo(Doc1)); } @@ -390,16 +392,118 @@ public async Task GetWhereDocumentAndOrIncludeDocuments() { MetadataKey1, "2" }, { MetadataKey2, 2 }, }; + // Their last three characters tell them apart: on Chroma Cloud a $contains of fewer characters throws (KD-57). static readonly string Doc1 = "Doc1"; static readonly string Doc2 = "Doc2"; + // Not pushes the negation into the operators: $ne and $nin match the records without the key, from Chroma 0.5.15, and $lte does + // not. All matches every record, and None none. + [Test] + public async Task NotAllAndNone() + { + var client = new ChromaClient(BaseConfigurationOptions, HttpClient); + var collection = new ChromaCollectionClient(await client.CreateCollectionAsync($"collection{Random.Shared.Next()}"), BaseConfigurationOptions, HttpClient); + await collection.AddAsync(["a", "b", "c", "d"], + embeddings: [new([1f, 0f]), new([0f, 1f]), new([1f, 1f]), new([0.5f, 0.5f])], + metadatas: [new Dictionary { ["k"] = 1 }, new Dictionary { ["k"] = 5 }, new Dictionary { ["other"] = 1 }, new Dictionary { ["k"] = 3 }]); + async Task> Ids(ChromaWhereOperator where) => (await collection.GetAsync(where: where, include: ChromaGetInclude.None)).Select(x => x.Id).Order(); + var withoutTheKey = NotEqualMatchesRecordsWithoutTheKey ? new[] { "c" } : []; + Assert.That(await Ids(ChromaWhereOperator.Not(ChromaWhereOperator.Equal("k", 1))), Is.EqualTo(withoutTheKey.Concat(["b", "d"]).Order())); + Assert.That(await Ids(ChromaWhereOperator.Not(ChromaWhereOperator.In("k", 1, 5))), Is.EqualTo(withoutTheKey.Concat(["d"]).Order())); + Assert.That(await Ids(ChromaWhereOperator.Not(ChromaWhereOperator.GreaterThan("k", 2))), Is.EqualTo(new[] { "a" })); + Assert.That(await Ids(ChromaWhereOperator.Not(ChromaWhereOperator.Equal("k", 1) | ChromaWhereOperator.Equal("k", 5))), Is.EqualTo(withoutTheKey.Concat(["d"]).Order())); + Assert.That(await Ids(ChromaWhereOperator.All), Is.EqualTo(new[] { "a", "b", "c", "d" })); + Assert.That(await Ids(ChromaWhereOperator.None), Is.Empty); + Assert.That(await Ids(ChromaWhereOperator.In("k")), Is.Empty); + Assert.That(await Ids(ChromaWhereOperator.NotIn("k") & ChromaWhereOperator.Equal("k", 3)), Is.EqualTo(new[] { "d" })); + } + + // A chain of 50 filters goes as one $or or $and list, which Chroma takes: nested, 32 were too deep for System.Text.Json. + [Test] + public async Task ChainsOfManyFilters() + { + Assume.That(ChromaCloud, Is.False, "Chroma Cloud takes at most 8 predicates in a filter, its default quota."); + var client = new ChromaClient(BaseConfigurationOptions, HttpClient); + var collection = new ChromaCollectionClient(await client.CreateCollectionAsync($"collection{Random.Shared.Next()}"), BaseConfigurationOptions, HttpClient); + var ids = Enumerable.Range(0, 100).Select(i => $"r{i}").ToList(); + await collection.AddAsync(ids, + embeddings: ids.Select(_ => new ReadOnlyMemory([1f, 0f])).ToList(), + metadatas: Enumerable.Range(0, 100).Select(i => (IReadOnlyDictionary)new Dictionary { ["i"] = i }).ToList()); + var or = ChromaWhereOperator.Equal("i", 0); + var and = ChromaWhereOperator.NotEqual("i", 0); + for (var i = 1; i < 50; i++) + { + or |= ChromaWhereOperator.Equal("i", i); + and &= ChromaWhereOperator.NotEqual("i", i); + } + Assert.That((await collection.GetAsync(where: or)).Select(x => x.Id).Order(), Is.EqualTo(ids.Take(50).Order())); + Assert.That((await collection.GetAsync(where: and)).Select(x => x.Id).Order(), Is.EqualTo(ids.Skip(50).Order())); + + // The client splits a long list: in one list, Chroma 1.x answers 500 from 988 filters, and Chroma 1.5.9 crashes on a stack + // overflow from about 4,400. Split, 4,000 filters work, and 4,500 leave the server up: Chroma 1.0.0 answers "too many SQL + // variables" from about 4,090. Chroma 0.6.3 rejects more than 488 filters however they go. + if (IsChroma1) + { + ChromaWhereOperator Chain(int count) + { + var chain = ChromaWhereOperator.NotEqual("i", 0); + for (var i = 1; i < count; i++) + { + chain &= ChromaWhereOperator.NotEqual("i", i < 50 ? i : 1000 + i); + } + return chain; + } + Assert.That((await collection.GetAsync(where: Chain(4_000))).Select(x => x.Id).Order(), Is.EqualTo(ids.Skip(50).Order())); + try + { + await collection.GetAsync(where: Chain(4_500)); + } + catch (ChromaException ex) when (ex.Message.Contains("too many SQL variables")) + { + } + await client.HeartbeatAsync(); + } + } + + // A limit beyond the ids goes as the number of the ids: a limit of 1000 is over the quota of 300 of Chroma Cloud, which rejects it. + [Test] + public async Task GetIdsWithLimitBeyondTheQuota() + { + var client = await Init(); + var result = await client.GetAsync([Id1, Id2], limit: 1000); + Assert.That(result.Select(x => x.Id), Is.EquivalentTo(new[] { Id1, Id2 })); + } + + // A date in a filter finds the record that stores the same date: the client sends both as System.Text.Json writes them. + [Test] + public async Task GetWhereDate() + { + var client = await Init(); + var date = new DateTime(2026, 10, 7, 10, 0, 0); + var offset = new DateTimeOffset(2026, 10, 7, 12, 0, 0, TimeSpan.FromHours(2)); + await client.UpdateAsync([Id1], metadatas: [new Dictionary { ["date"] = date, ["offset"] = offset }]); + Assert.That((await client.GetAsync(where: ChromaWhereOperator.Equal("date", date))).Select(x => x.Id), Is.EqualTo(new[] { Id1 })); + Assert.That((await client.GetAsync(where: ChromaWhereOperator.In("offset", offset))).Select(x => x.Id), Is.EqualTo(new[] { Id1 })); + Assert.That((await client.GetAsync(where: ChromaWhereOperator.NotEqual("date", date))).Select(x => x.Id), Is.EqualTo(new[] { Id2 }).Or.Empty); + } + + // A client made by name reads the collection before its first request: a get with a limit of 0, which sends no get, says that the + // collection does not exist as the other reads do. + [Test] + public void GetWithALimitOf0ByNameOnACollectionThatDoesNotExist() + { + var collection = new ChromaClient(BaseConfigurationOptions, HttpClient).GetCollectionClient($"missing{Random.Shared.Next()}"); + var error = Assert.ThrowsAsync(() => collection.GetAsync(limit: 0)); + Assert.That(error?.Message, Is.EqualTo(Assert.ThrowsAsync(() => collection.GetAsync())!.Message)); + } + async Task Init() { var name = $"collection{Random.Shared.Next()}"; var client = new ChromaClient(BaseConfigurationOptions, HttpClient); - var collection = await client.CreateCollection(name); + var collection = await client.CreateCollectionAsync(name); var collectionClient = new ChromaCollectionClient(collection, BaseConfigurationOptions, HttpClient); - await collectionClient.Add([Id1, Id2], + await collectionClient.AddAsync([Id1, Id2], embeddings: [Embeddings1, Embeddings2], metadatas: [Metadata1, Metadata2], documents: [Doc1, Doc2]); diff --git a/ChromaDB.Client.Tests/CollectionClientQueryTests.cs b/ChromaDB.Client.Tests/CollectionClientQueryTests.cs index 7886e89..d126690 100644 --- a/ChromaDB.Client.Tests/CollectionClientQueryTests.cs +++ b/ChromaDB.Client.Tests/CollectionClientQueryTests.cs @@ -1,4 +1,5 @@ -using ChromaDB.Client.Tests.Common; +using ChromaDB.Client.Models; +using ChromaDB.Client.Tests.Common; using NUnit.Framework; namespace ChromaDB.Client.Tests; @@ -12,15 +13,15 @@ public class CollectionClientQueryTests : ChromaTestsBase public async Task SimpleQuerySingle() { var client = await Init(); - var result = await client.Query(Embeddings1, + var result = await client.QueryAsync(Embeddings1, include: ChromaQueryInclude.Distances | ChromaQueryInclude.Embeddings); Assert.That(result, Is.Not.Null); Assert.That(result, Has.Count.EqualTo(2)); Assert.That(result.Select(x => x.Distance), Has.Some.Not.EqualTo(0).Within(DistanceTolerance)); - Assert.That(result[0].Embeddings, Is.Not.Null.And.Length.GreaterThan(0)); + Assert.That(result[0].Embedding, Is.Not.Null.And.Length.GreaterThan(0)); Assert.That(result[0].Metadata, Is.Null); Assert.That(result[0].Document, Is.Null); - Assert.That(result[1].Embeddings, Is.Not.Null.And.Length.GreaterThan(0)); + Assert.That(result[1].Embedding, Is.Not.Null.And.Length.GreaterThan(0)); Assert.That(result[1].Metadata, Is.Null); Assert.That(result[1].Document, Is.Null); } @@ -29,40 +30,50 @@ public async Task SimpleQuerySingle() public async Task SimpleQuerySingleIncludeAll() { var client = await Init(); - var result = await client.Query(Embeddings1, + var result = await client.QueryAsync(Embeddings1, include: ChromaQueryInclude.Distances | ChromaQueryInclude.Embeddings | ChromaQueryInclude.Metadatas | ChromaQueryInclude.Documents); Assert.That(result, Is.Not.Null); Assert.That(result, Has.Count.EqualTo(2)); Assert.That(result.Select(x => x.Distance), Has.Some.Not.EqualTo(0).Within(DistanceTolerance)); - Assert.That(result[0].Embeddings, Is.Not.Null.And.Length.GreaterThan(0)); + Assert.That(result[0].Embedding, Is.Not.Null.And.Length.GreaterThan(0)); Assert.That(result[0].Metadata, Is.Not.Null.And.Not.Empty); Assert.That(result[0].Document, Is.Not.Null.And.Not.Empty); - Assert.That(result[1].Embeddings, Is.Not.Null.And.Length.GreaterThan(0)); + Assert.That(result[1].Embedding, Is.Not.Null.And.Length.GreaterThan(0)); Assert.That(result[1].Metadata, Is.Not.Null.And.Not.Empty); Assert.That(result[1].Document, Is.Not.Null.And.Not.Empty); } + [Test] + public async Task SimpleQuerySingleWithoutDistances() + { + var client = await Init(); + var result = await client.QueryAsync(Embeddings1, + include: ChromaQueryInclude.Embeddings); + Assert.That(result, Has.Count.EqualTo(2)); + Assert.That(result.Select(x => x.Distance), Has.All.Null); + } + [Test] public async Task SimpleQueryMultiple() { var client = await Init(); - var result = await client.Query([Embeddings1, Embeddings2], + var result = await client.QueryAsync([Embeddings1, Embeddings2], include: ChromaQueryInclude.Distances | ChromaQueryInclude.Embeddings); Assert.That(result, Has.Count.EqualTo(2)); Assert.That(result[0], Has.Count.EqualTo(2)); Assert.That(result[0].Select(x => x.Distance), Has.Some.Not.EqualTo(0).Within(DistanceTolerance)); - Assert.That(result[0][0].Embeddings, Is.Not.Null.And.Length.GreaterThan(0)); + Assert.That(result[0][0].Embedding, Is.Not.Null.And.Length.GreaterThan(0)); Assert.That(result[0][0].Metadata, Is.Null); Assert.That(result[0][0].Document, Is.Null); - Assert.That(result[0][1].Embeddings, Is.Not.Null.And.Length.GreaterThan(0)); + Assert.That(result[0][1].Embedding, Is.Not.Null.And.Length.GreaterThan(0)); Assert.That(result[0][1].Metadata, Is.Null); Assert.That(result[0][1].Document, Is.Null); Assert.That(result[1], Has.Count.EqualTo(2)); Assert.That(result[1].Select(x => x.Distance), Has.Some.Not.EqualTo(0).Within(DistanceTolerance)); - Assert.That(result[1][0].Embeddings, Is.Not.Null.And.Length.GreaterThan(0)); + Assert.That(result[1][0].Embedding, Is.Not.Null.And.Length.GreaterThan(0)); Assert.That(result[1][0].Metadata, Is.Null); Assert.That(result[1][0].Document, Is.Null); - Assert.That(result[1][1].Embeddings, Is.Not.Null.And.Length.GreaterThan(0)); + Assert.That(result[1][1].Embedding, Is.Not.Null.And.Length.GreaterThan(0)); Assert.That(result[1][1].Metadata, Is.Null); Assert.That(result[1][1].Document, Is.Null); } @@ -71,23 +82,23 @@ public async Task SimpleQueryMultiple() public async Task SimpleQueryMultipleIncludeAll() { var client = await Init(); - var result = await client.Query([Embeddings1, Embeddings2], + var result = await client.QueryAsync([Embeddings1, Embeddings2], include: ChromaQueryInclude.Distances | ChromaQueryInclude.Embeddings | ChromaQueryInclude.Metadatas | ChromaQueryInclude.Documents); Assert.That(result, Has.Count.EqualTo(2)); Assert.That(result[0], Has.Count.EqualTo(2)); Assert.That(result[0].Select(x => x.Distance), Has.Some.Not.EqualTo(0).Within(DistanceTolerance)); - Assert.That(result[0][0].Embeddings, Is.Not.Null.And.Length.GreaterThan(0)); + Assert.That(result[0][0].Embedding, Is.Not.Null.And.Length.GreaterThan(0)); Assert.That(result[0][0].Metadata, Is.Not.Null.And.Not.Empty); Assert.That(result[0][0].Document, Is.Not.Null.And.Not.Empty); - Assert.That(result[0][1].Embeddings, Is.Not.Null.And.Length.GreaterThan(0)); + Assert.That(result[0][1].Embedding, Is.Not.Null.And.Length.GreaterThan(0)); Assert.That(result[0][1].Metadata, Is.Not.Null.And.Not.Empty); Assert.That(result[0][1].Document, Is.Not.Null.And.Not.Empty); Assert.That(result[1], Has.Count.EqualTo(2)); Assert.That(result[1].Select(x => x.Distance), Has.Some.Not.EqualTo(0).Within(DistanceTolerance)); - Assert.That(result[1][0].Embeddings, Is.Not.Null.And.Length.GreaterThan(0)); + Assert.That(result[1][0].Embedding, Is.Not.Null.And.Length.GreaterThan(0)); Assert.That(result[1][0].Metadata, Is.Not.Null.And.Not.Empty); Assert.That(result[1][0].Document, Is.Not.Null.And.Not.Empty); - Assert.That(result[1][1].Embeddings, Is.Not.Null.And.Length.GreaterThan(0)); + Assert.That(result[1][1].Embedding, Is.Not.Null.And.Length.GreaterThan(0)); Assert.That(result[1][1].Metadata, Is.Not.Null.And.Not.Empty); Assert.That(result[1][1].Document, Is.Not.Null.And.Not.Empty); } @@ -96,13 +107,13 @@ public async Task SimpleQueryMultipleIncludeAll() public async Task QuerySingleNResults1() { var client = await Init(); - var result = await client.Query(Embeddings1, + var result = await client.QueryAsync(Embeddings1, include: ChromaQueryInclude.Distances | ChromaQueryInclude.Embeddings, nResults: 1); Assert.That(result, Is.Not.Null); Assert.That(result, Has.Count.EqualTo(1)); Assert.That(result[0].Distance, Is.EqualTo(0).Within(DistanceTolerance)); - Assert.That(result[0].Embeddings, Is.EqualTo(Embeddings1).Using(EmbeddingsComparer.Instance)); + Assert.That(result[0].Embedding, Is.EqualTo(Embeddings1).Using(EmbeddingsComparer.Instance)); Assert.That(result[0].Metadata, Is.Null); Assert.That(result[0].Document, Is.Null); } @@ -111,20 +122,20 @@ public async Task QuerySingleNResults1() public async Task QueryWithWhereEqual() { var client = await Init(); - var result = await client.Query([Embeddings1, Embeddings2], + var result = await client.QueryAsync([Embeddings1, Embeddings2], where: ChromaWhereOperator.Equal(MetadataKey2, Metadata1[MetadataKey2]), include: ChromaQueryInclude.Distances); Assert.That(result, Has.Count.EqualTo(2)); Assert.That(result[0], Has.Count.EqualTo(1)); Assert.That(result[0][0].Distance, Is.EqualTo(0).Within(DistanceTolerance)); Assert.That(result[0][0].Id, Is.EqualTo(Id1)); - Assert.That(result[0][0].Embeddings, Is.Null); + Assert.That(result[0][0].Embedding, Is.Null); Assert.That(result[0][0].Metadata, Is.Null); Assert.That(result[0][0].Document, Is.Null); Assert.That(result[1], Has.Count.EqualTo(1)); Assert.That(result[1][0].Distance, Is.GreaterThan(0)); Assert.That(result[1][0].Id, Is.EqualTo(Id1)); - Assert.That(result[1][0].Embeddings, Is.Null); + Assert.That(result[1][0].Embedding, Is.Null); Assert.That(result[1][0].Metadata, Is.Null); Assert.That(result[1][0].Document, Is.Null); } @@ -133,20 +144,20 @@ public async Task QueryWithWhereEqual() public async Task QueryWithWhereNotEqual() { var client = await Init(); - var result = await client.Query([Embeddings1, Embeddings2], + var result = await client.QueryAsync([Embeddings1, Embeddings2], where: ChromaWhereOperator.NotEqual(MetadataKey2, Metadata2[MetadataKey2]), include: ChromaQueryInclude.Distances); Assert.That(result, Has.Count.EqualTo(2)); Assert.That(result[0], Has.Count.EqualTo(1)); Assert.That(result[0][0].Distance, Is.EqualTo(0).Within(DistanceTolerance)); Assert.That(result[0][0].Id, Is.EqualTo(Id1)); - Assert.That(result[0][0].Embeddings, Is.Null); + Assert.That(result[0][0].Embedding, Is.Null); Assert.That(result[0][0].Metadata, Is.Null); Assert.That(result[0][0].Document, Is.Null); Assert.That(result[1], Has.Count.EqualTo(1)); Assert.That(result[1][0].Distance, Is.GreaterThan(0)); Assert.That(result[1][0].Id, Is.EqualTo(Id1)); - Assert.That(result[1][0].Embeddings, Is.Null); + Assert.That(result[1][0].Embedding, Is.Null); Assert.That(result[1][0].Metadata, Is.Null); Assert.That(result[1][0].Document, Is.Null); } @@ -155,20 +166,20 @@ public async Task QueryWithWhereNotEqual() public async Task QueryWithWhereIn() { var client = await Init(); - var result = await client.Query([Embeddings1, Embeddings2], + var result = await client.QueryAsync([Embeddings1, Embeddings2], where: ChromaWhereOperator.In(MetadataKey2, Metadata1[MetadataKey2]), include: ChromaQueryInclude.Distances); Assert.That(result, Has.Count.EqualTo(2)); Assert.That(result[0], Has.Count.EqualTo(1)); Assert.That(result[0][0].Distance, Is.EqualTo(0).Within(DistanceTolerance)); Assert.That(result[0][0].Id, Is.EqualTo(Id1)); - Assert.That(result[0][0].Embeddings, Is.Null); + Assert.That(result[0][0].Embedding, Is.Null); Assert.That(result[0][0].Metadata, Is.Null); Assert.That(result[0][0].Document, Is.Null); Assert.That(result[1], Has.Count.EqualTo(1)); Assert.That(result[1][0].Distance, Is.GreaterThan(0)); Assert.That(result[1][0].Id, Is.EqualTo(Id1)); - Assert.That(result[1][0].Embeddings, Is.Null); + Assert.That(result[1][0].Embedding, Is.Null); Assert.That(result[1][0].Metadata, Is.Null); Assert.That(result[1][0].Document, Is.Null); } @@ -177,20 +188,20 @@ public async Task QueryWithWhereIn() public async Task QueryWithWhereNotIn() { var client = await Init(); - var result = await client.Query([Embeddings1, Embeddings2], + var result = await client.QueryAsync([Embeddings1, Embeddings2], where: ChromaWhereOperator.NotIn(MetadataKey2, Metadata2[MetadataKey2]), include: ChromaQueryInclude.Distances); Assert.That(result, Has.Count.EqualTo(2)); Assert.That(result[0], Has.Count.EqualTo(1)); Assert.That(result[0][0].Distance, Is.EqualTo(0).Within(DistanceTolerance)); Assert.That(result[0][0].Id, Is.EqualTo(Id1)); - Assert.That(result[0][0].Embeddings, Is.Null); + Assert.That(result[0][0].Embedding, Is.Null); Assert.That(result[0][0].Metadata, Is.Null); Assert.That(result[0][0].Document, Is.Null); Assert.That(result[1], Has.Count.EqualTo(1)); Assert.That(result[1][0].Distance, Is.GreaterThan(0)); Assert.That(result[1][0].Id, Is.EqualTo(Id1)); - Assert.That(result[1][0].Embeddings, Is.Null); + Assert.That(result[1][0].Embedding, Is.Null); Assert.That(result[1][0].Metadata, Is.Null); Assert.That(result[1][0].Document, Is.Null); } @@ -199,20 +210,20 @@ public async Task QueryWithWhereNotIn() public async Task QueryWithWhereGreaterThan() { var client = await Init(); - var result = await client.Query([Embeddings1, Embeddings2], + var result = await client.QueryAsync([Embeddings1, Embeddings2], where: ChromaWhereOperator.GreaterThan(MetadataKey2, Metadata1[MetadataKey2]), include: ChromaQueryInclude.Distances); Assert.That(result, Has.Count.EqualTo(2)); Assert.That(result[0], Has.Count.EqualTo(1)); Assert.That(result[0][0].Distance, Is.GreaterThan(0)); Assert.That(result[0][0].Id, Is.EqualTo(Id2)); - Assert.That(result[0][0].Embeddings, Is.Null); + Assert.That(result[0][0].Embedding, Is.Null); Assert.That(result[0][0].Metadata, Is.Null); Assert.That(result[0][0].Document, Is.Null); Assert.That(result[1], Has.Count.EqualTo(1)); Assert.That(result[1][0].Distance, Is.EqualTo(0).Within(DistanceTolerance)); Assert.That(result[1][0].Id, Is.EqualTo(Id2)); - Assert.That(result[1][0].Embeddings, Is.Null); + Assert.That(result[1][0].Embedding, Is.Null); Assert.That(result[1][0].Metadata, Is.Null); Assert.That(result[1][0].Document, Is.Null); } @@ -221,20 +232,20 @@ public async Task QueryWithWhereGreaterThan() public async Task QueryWithWhereLessThan() { var client = await Init(); - var result = await client.Query([Embeddings1, Embeddings2], + var result = await client.QueryAsync([Embeddings1, Embeddings2], where: ChromaWhereOperator.LessThan(MetadataKey2, Metadata2[MetadataKey2]), include: ChromaQueryInclude.Distances); Assert.That(result, Has.Count.EqualTo(2)); Assert.That(result[0], Has.Count.EqualTo(1)); Assert.That(result[0][0].Distance, Is.EqualTo(0).Within(DistanceTolerance)); Assert.That(result[0][0].Id, Is.EqualTo(Id1)); - Assert.That(result[0][0].Embeddings, Is.Null); + Assert.That(result[0][0].Embedding, Is.Null); Assert.That(result[0][0].Metadata, Is.Null); Assert.That(result[0][0].Document, Is.Null); Assert.That(result[1], Has.Count.EqualTo(1)); Assert.That(result[1][0].Distance, Is.GreaterThan(0)); Assert.That(result[1][0].Id, Is.EqualTo(Id1)); - Assert.That(result[1][0].Embeddings, Is.Null); + Assert.That(result[1][0].Embedding, Is.Null); Assert.That(result[1][0].Metadata, Is.Null); Assert.That(result[1][0].Document, Is.Null); } @@ -243,20 +254,20 @@ public async Task QueryWithWhereLessThan() public async Task QueryWithWhereGreaterThanOrEqual() { var client = await Init(); - var result = await client.Query([Embeddings1, Embeddings2], + var result = await client.QueryAsync([Embeddings1, Embeddings2], where: ChromaWhereOperator.GreaterThanOrEqual(MetadataKey2, Metadata2[MetadataKey2]), include: ChromaQueryInclude.Distances); Assert.That(result, Has.Count.EqualTo(2)); Assert.That(result[0], Has.Count.EqualTo(1)); Assert.That(result[0][0].Distance, Is.GreaterThan(0)); Assert.That(result[0][0].Id, Is.EqualTo(Id2)); - Assert.That(result[0][0].Embeddings, Is.Null); + Assert.That(result[0][0].Embedding, Is.Null); Assert.That(result[0][0].Metadata, Is.Null); Assert.That(result[0][0].Document, Is.Null); Assert.That(result[1], Has.Count.EqualTo(1)); Assert.That(result[1][0].Distance, Is.EqualTo(0).Within(DistanceTolerance)); Assert.That(result[1][0].Id, Is.EqualTo(Id2)); - Assert.That(result[1][0].Embeddings, Is.Null); + Assert.That(result[1][0].Embedding, Is.Null); Assert.That(result[1][0].Metadata, Is.Null); Assert.That(result[1][0].Document, Is.Null); } @@ -265,20 +276,20 @@ public async Task QueryWithWhereGreaterThanOrEqual() public async Task QueryWithWhereLessThanOrEqual() { var client = await Init(); - var result = await client.Query([Embeddings1, Embeddings2], + var result = await client.QueryAsync([Embeddings1, Embeddings2], where: ChromaWhereOperator.LessThanOrEqual(MetadataKey2, Metadata1[MetadataKey2]), include: ChromaQueryInclude.Distances); Assert.That(result, Has.Count.EqualTo(2)); Assert.That(result[0], Has.Count.EqualTo(1)); Assert.That(result[0][0].Distance, Is.EqualTo(0).Within(DistanceTolerance)); Assert.That(result[0][0].Id, Is.EqualTo(Id1)); - Assert.That(result[0][0].Embeddings, Is.Null); + Assert.That(result[0][0].Embedding, Is.Null); Assert.That(result[0][0].Metadata, Is.Null); Assert.That(result[0][0].Document, Is.Null); Assert.That(result[1], Has.Count.EqualTo(1)); Assert.That(result[1][0].Distance, Is.GreaterThan(0)); Assert.That(result[1][0].Id, Is.EqualTo(Id1)); - Assert.That(result[1][0].Embeddings, Is.Null); + Assert.That(result[1][0].Embedding, Is.Null); Assert.That(result[1][0].Metadata, Is.Null); Assert.That(result[1][0].Document, Is.Null); } @@ -287,20 +298,20 @@ public async Task QueryWithWhereLessThanOrEqual() public async Task QueryWithWhereAndOr() { var client = await Init(); - var result = await client.Query([Embeddings1, Embeddings2], + var result = await client.QueryAsync([Embeddings1, Embeddings2], where: ChromaWhereOperator.Equal(MetadataKey2, Metadata1[MetadataKey2]) && ChromaWhereOperator.NotEqual(MetadataKey2, Metadata1[MetadataKey2]) || ChromaWhereOperator.NotEqual(MetadataKey2, Metadata2[MetadataKey2]), include: ChromaQueryInclude.Distances); Assert.That(result, Has.Count.EqualTo(2)); Assert.That(result[0], Has.Count.EqualTo(1)); Assert.That(result[0][0].Distance, Is.EqualTo(0).Within(DistanceTolerance)); Assert.That(result[0][0].Id, Is.EqualTo(Id1)); - Assert.That(result[0][0].Embeddings, Is.Null); + Assert.That(result[0][0].Embedding, Is.Null); Assert.That(result[0][0].Metadata, Is.Null); Assert.That(result[0][0].Document, Is.Null); Assert.That(result[1], Has.Count.EqualTo(1)); Assert.That(result[1][0].Distance, Is.GreaterThan(0)); Assert.That(result[1][0].Id, Is.EqualTo(Id1)); - Assert.That(result[1][0].Embeddings, Is.Null); + Assert.That(result[1][0].Embedding, Is.Null); Assert.That(result[1][0].Metadata, Is.Null); Assert.That(result[1][0].Document, Is.Null); } @@ -309,20 +320,20 @@ public async Task QueryWithWhereAndOr() public async Task QueryWithWhereDocumentContains() { var client = await Init(); - var result = await client.Query([Embeddings1, Embeddings2], - whereDocument: ChromaWhereDocumentOperator.Contains(Doc1[^1]), + var result = await client.QueryAsync([Embeddings1, Embeddings2], + whereDocument: ChromaWhereDocumentOperator.Contains(Doc1[^3..]), include: ChromaQueryInclude.Distances); Assert.That(result, Has.Count.EqualTo(2)); Assert.That(result[0], Has.Count.EqualTo(1)); Assert.That(result[0][0].Distance, Is.EqualTo(0).Within(DistanceTolerance)); Assert.That(result[0][0].Id, Is.EqualTo(Id1)); - Assert.That(result[0][0].Embeddings, Is.Null); + Assert.That(result[0][0].Embedding, Is.Null); Assert.That(result[0][0].Metadata, Is.Null); Assert.That(result[0][0].Document, Is.Null); Assert.That(result[1], Has.Count.EqualTo(1)); Assert.That(result[1][0].Distance, Is.GreaterThan(0)); Assert.That(result[1][0].Id, Is.EqualTo(Id1)); - Assert.That(result[1][0].Embeddings, Is.Null); + Assert.That(result[1][0].Embedding, Is.Null); Assert.That(result[1][0].Metadata, Is.Null); Assert.That(result[1][0].Document, Is.Null); } @@ -330,21 +341,22 @@ public async Task QueryWithWhereDocumentContains() [Test] public async Task QueryWithWhereDocumentNotContains() { + Assume.That(CountCollectionsAndNotContainsSupported, Is.True, "Chroma 0.4.15 has no count_collections, no $not_contains filter and no tenant and database in the collections."); var client = await Init(); - var result = await client.Query([Embeddings1, Embeddings2], - whereDocument: ChromaWhereDocumentOperator.NotContains(Doc2[^1]), + var result = await client.QueryAsync([Embeddings1, Embeddings2], + whereDocument: ChromaWhereDocumentOperator.NotContains(Doc2[^3..]), include: ChromaQueryInclude.Distances); Assert.That(result, Has.Count.EqualTo(2)); Assert.That(result[0], Has.Count.EqualTo(1)); Assert.That(result[0][0].Distance, Is.EqualTo(0).Within(DistanceTolerance)); Assert.That(result[0][0].Id, Is.EqualTo(Id1)); - Assert.That(result[0][0].Embeddings, Is.Null); + Assert.That(result[0][0].Embedding, Is.Null); Assert.That(result[0][0].Metadata, Is.Null); Assert.That(result[0][0].Document, Is.Null); Assert.That(result[1], Has.Count.EqualTo(1)); Assert.That(result[1][0].Distance, Is.GreaterThan(0)); Assert.That(result[1][0].Id, Is.EqualTo(Id1)); - Assert.That(result[1][0].Embeddings, Is.Null); + Assert.That(result[1][0].Embedding, Is.Null); Assert.That(result[1][0].Metadata, Is.Null); Assert.That(result[1][0].Document, Is.Null); } @@ -352,29 +364,148 @@ public async Task QueryWithWhereDocumentNotContains() [Test] public async Task QueryWithWhereDocumentAndOr() { + Assume.That(CountCollectionsAndNotContainsSupported, Is.True, "Chroma 0.4.15 has no count_collections, no $not_contains filter and no tenant and database in the collections."); var client = await Init(); - var result = await client.Query([Embeddings1, Embeddings2], + var result = await client.QueryAsync([Embeddings1, Embeddings2], whereDocument: ChromaWhereDocumentOperator.Contains(Doc1) && ChromaWhereDocumentOperator.NotContains(Doc1) || ChromaWhereDocumentOperator.NotContains(Doc2), include: ChromaQueryInclude.Distances); Assert.That(result, Has.Count.EqualTo(2)); Assert.That(result[0], Has.Count.EqualTo(1)); Assert.That(result[0][0].Distance, Is.EqualTo(0).Within(DistanceTolerance)); Assert.That(result[0][0].Id, Is.EqualTo(Id1)); - Assert.That(result[0][0].Embeddings, Is.Null); + Assert.That(result[0][0].Embedding, Is.Null); Assert.That(result[0][0].Metadata, Is.Null); Assert.That(result[0][0].Document, Is.Null); Assert.That(result[1], Has.Count.EqualTo(1)); Assert.That(result[1][0].Distance, Is.GreaterThan(0)); Assert.That(result[1][0].Id, Is.EqualTo(Id1)); - Assert.That(result[1][0].Embeddings, Is.Null); + Assert.That(result[1][0].Embedding, Is.Null); Assert.That(result[1][0].Metadata, Is.Null); Assert.That(result[1][0].Document, Is.Null); } + [Test] + public async Task QueryWithIds() + { + var client = await Init(withThird: true); + var query = new ChromaQuery([Embeddings1, Embeddings2]) { Ids = [Id2, Id3], Include = ChromaQueryInclude.Distances }; + if (!IdsInQuerySupported) + { + // The server searches all the records, and Id1 is the nearest to Embeddings1. + await Assert.ThatAsync(() => client.QueryAsync(query), Throws.InstanceOf().With.Message.Contains("outside the ids")); + return; + } + var result = await client.QueryAsync(query); + Assert.That(result, Has.Count.EqualTo(2)); + Assert.That(result[0].Select(x => x.Id), Is.EquivalentTo(new[] { Id2, Id3 })); + Assert.That(result[1].Select(x => x.Id), Is.EquivalentTo(new[] { Id2, Id3 })); + Assert.That(result[1][0].Id, Is.EqualTo(Id2)); + Assert.That(result[1][0].Distance, Is.EqualTo(0).Within(DistanceTolerance)); + } + + [Test] + public async Task QueryWithOffset() + { + var client = await Init(withThird: true); + var all = await client.QueryAsync(new ChromaQuery([Embeddings1, Embeddings2]) { NResults = 3, Include = ChromaQueryInclude.Distances }); + var skipped = await client.QueryAsync(new ChromaQuery([Embeddings1, Embeddings2]) { NResults = 2, Offset = 1, Include = ChromaQueryInclude.Distances }); + Assert.That(skipped.Select(entries => entries.Select(x => x.Id)), Is.EqualTo(all.Select(entries => entries.Skip(1).Select(x => x.Id)))); + } + + [Test] + public async Task QueryWithIdsNResults1() + { + Assume.That(IdsInQuerySupported, Is.True, "Chroma 0.6.3 and earlier ignore the ids of a query."); + var client = await Init(withThird: true); + var result = await client.QueryAsync(new ChromaQuery([Embeddings1]) { Ids = [Id2, Id3], NResults = 1 }); + Assert.That(result.Single().Select(x => x.Id), Is.EqualTo(new[] { Id3 })); + } + + [Test] + public async Task QueryWithIdsAndWhere() + { + Assume.That(IdsInQuerySupported, Is.True, "Chroma 0.6.3 and earlier ignore the ids of a query."); + var client = await Init(withThird: true); + var result = await client.QueryAsync(new ChromaQuery([Embeddings1]) { Ids = [Id1, Id3], Where = ChromaWhereOperator.Equal(MetadataKey2, Metadata2[MetadataKey2]) }); + Assert.That(result.Single().Select(x => x.Id), Is.EqualTo(new[] { Id3 })); + } + + [Test] + public async Task QueryWithEmptyIds() + { + var client = await Init(withThird: true); + var query = new ChromaQuery([Embeddings1]) { Ids = [] }; + if (!IdsInQuerySupported) + { + await Assert.ThatAsync(() => client.QueryAsync(query), Throws.InstanceOf().With.Message.Contains("outside the ids")); + return; + } + Assert.That((await client.QueryAsync(query)).Single(), Is.Empty); + } + + // Chroma 1.x answers 500 "Error finding id" when an id of the query does not exist, and Chroma Cloud leaves it out: the client + // leaves it out on both. Chroma 0.x ignores the ids. + [Test] + public async Task QueryWithMissingIdLeavesItOut() + { + var client = await Init(withThird: true); + var query = new ChromaQuery([Embeddings1, Embeddings2]) { Ids = [Id1, "missing", Id3], Include = ChromaQueryInclude.Distances }; + if (!IdsInQuerySupported) + { + await Assert.ThatAsync(() => client.QueryAsync(query), Throws.InstanceOf().With.Message.Contains("outside the ids")); + return; + } + var result = await client.QueryAsync(query); + Assert.That(result, Has.Count.EqualTo(2)); + Assert.That(result[0].Select(x => x.Id), Is.EqualTo(new[] { Id1, Id3 })); + Assert.That(result[1].Select(x => x.Id), Is.EquivalentTo(new[] { Id1, Id3 })); + } + + [Test] + public async Task QueryWithDeletedIdLeavesItOut() + { + Assume.That(IdsInQuerySupported, Is.True, "Chroma 0.6.3 and earlier ignore the ids of a query."); + var client = await Init(withThird: true); + await client.DeleteAsync([Id1]); + var result = await client.QueryAsync(new ChromaQuery([Embeddings1]) { Ids = [Id1, Id2] }); + Assert.That(result.Single().Select(x => x.Id), Is.EqualTo(new[] { Id2 })); + } + + [Test] + public async Task QueryWithOnlyMissingIdsHasNoResults() + { + Assume.That(IdsInQuerySupported, Is.True, "Chroma 0.6.3 and earlier ignore the ids of a query."); + var client = await Init(withThird: true); + var result = await client.QueryAsync(new ChromaQuery([Embeddings1, Embeddings2]) { Ids = ["missing", "other"] }); + Assert.That(result, Has.Count.EqualTo(2)); + Assert.That(result, Has.All.Empty); + } + + // Chroma 1.x sends null for an embedding beyond the range of a float in a cosine collection, and for the distance to it in an l2 + // collection: the client reads them, and the other records come back as they are. + [Test] + public async Task FloatsBeyondTheRange() + { + var chroma = new ChromaClient(BaseConfigurationOptions, HttpClient); + foreach (var space in new[] { ChromaSpace.Cosine, ChromaSpace.L2 }) + { + var collection = chroma.GetCollectionClient(await chroma.CreateCollectionAsync(new ChromaCollectionDefinition($"collection{Random.Shared.Next()}") { Configuration = new() { Space = space } })); + await collection.AddAsync(["ok", "big"], embeddings: [new([3f, 4f]), new([float.MaxValue, float.MaxValue])]); + var records = (await collection.GetAsync(include: ChromaGetInclude.Embeddings)).ToDictionary(x => x.Id, x => x.Embedding!.Value.ToArray()); + Assert.That(records["ok"], Is.EqualTo(new[] { 3f, 4f }), space.ToString()); + var found = (await collection.QueryAsync([new([3f, 4f])], include: ChromaQueryInclude.Distances | ChromaQueryInclude.Embeddings)).Single(); + Assert.That((found[0].Id, found[0].Distance), Is.EqualTo(("ok", 0f)), space.ToString()); + Assert.That(found.Select(x => x.Id), Is.EquivalentTo(new[] { "ok", "big" }), space.ToString()); + } + } + static readonly string Id1 = "id1"; static readonly string Id2 = "id2"; + static readonly string Id3 = "id3"; static readonly ReadOnlyMemory Embeddings1 = new([1, 2, 3]); static readonly ReadOnlyMemory Embeddings2 = new([1.4f, 1.5f, 99.33f]); + // Nearer to Embeddings1 than Embeddings2 is, farther than Embeddings1 itself. + static readonly ReadOnlyMemory Embeddings3 = new([1, 2, 10]); static readonly string MetadataKey1 = "key1"; static readonly string MetadataKey2 = "key2"; static readonly Dictionary Metadata1 = new() @@ -387,19 +518,24 @@ public async Task QueryWithWhereDocumentAndOr() { MetadataKey1, "2" }, { MetadataKey2, 2 }, }; + // Their last three characters tell them apart: on Chroma Cloud a $contains of fewer characters throws (KD-57). static readonly string Doc1 = "Doc1"; static readonly string Doc2 = "Doc2"; - async Task Init() + async Task Init(bool withThird = false) { var name = $"collection{Random.Shared.Next()}"; var client = new ChromaClient(BaseConfigurationOptions, HttpClient); - var collection = await client.CreateCollection(name); + var collection = await client.CreateCollectionAsync(name); var collectionClient = new ChromaCollectionClient(collection, BaseConfigurationOptions, HttpClient); - await collectionClient.Add([Id1, Id2], + await collectionClient.AddAsync([Id1, Id2], embeddings: [Embeddings1, Embeddings2], metadatas: [Metadata1, Metadata2], documents: [Doc1, Doc2]); + if (withThird) + { + await collectionClient.AddAsync([Id3], embeddings: [Embeddings3], metadatas: [Metadata2], documents: [Doc2]); + } return collectionClient; } } diff --git a/ChromaDB.Client.Tests/CollectionClientTenantTests.cs b/ChromaDB.Client.Tests/CollectionClientTenantTests.cs new file mode 100644 index 0000000..3530d1b --- /dev/null +++ b/ChromaDB.Client.Tests/CollectionClientTenantTests.cs @@ -0,0 +1,37 @@ +using System.Net; +using ChromaDB.Client.Models; +using NUnit.Framework; + +namespace ChromaDB.Client.Tests; + +[TestFixture] +public class CollectionClientTenantTests +{ + static readonly Guid Id = Guid.Parse("11111111-2222-3333-4444-555555555555"); + + [TestCase(null, null, null, null, "default_tenant", "default_database")] + [TestCase(null, null, "", "", "default_tenant", "default_database")] + [TestCase(null, null, "options_tenant", "options_database", "options_tenant", "options_database")] + [TestCase("", "", "options_tenant", "options_database", "options_tenant", "options_database")] + [TestCase("collection_tenant", "collection_database", "options_tenant", "options_database", "collection_tenant", "collection_database")] + public async Task TenantAndDatabaseOfTheRequests(string? collectionTenant, string? collectionDatabase, string? optionsTenant, string? optionsDatabase, string expectedTenant, string expectedDatabase) + { + var handler = new RecordingHandler(); + using var httpClient = new HttpClient(handler); + var collection = new ChromaCollection("collection") { Id = Id, Tenant = collectionTenant, Database = collectionDatabase }; + var options = new ChromaConfigurationOptions("http://localhost:8000/api/v2/", tenant: optionsTenant, database: optionsDatabase); + await new ChromaCollectionClient(collection, options, httpClient).CountAsync(); + Assert.That(handler.RequestUri?.AbsolutePath, Is.EqualTo($"/api/v2/tenants/{expectedTenant}/databases/{expectedDatabase}/collections/{Id}/count")); + } + + sealed class RecordingHandler : HttpMessageHandler + { + public Uri? RequestUri { get; private set; } + + protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + RequestUri = request.RequestUri; + return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent("0") }); + } + } +} diff --git a/ChromaDB.Client.Tests/CollectionClientTests.cs b/ChromaDB.Client.Tests/CollectionClientTests.cs index 1054a99..7d6361f 100644 --- a/ChromaDB.Client.Tests/CollectionClientTests.cs +++ b/ChromaDB.Client.Tests/CollectionClientTests.cs @@ -1,4 +1,5 @@ -using NUnit.Framework; +using ChromaDB.Client.Models; +using NUnit.Framework; namespace ChromaDB.Client.Tests; @@ -9,7 +10,7 @@ public class CollectionClientTests : ChromaTestsBase public async Task CountEmptyCollection() { var client = await Init(); - var result = await client.Count(); + var result = await client.CountAsync(); Assert.That(result, Is.EqualTo(0)); } @@ -17,8 +18,8 @@ public async Task CountEmptyCollection() public async Task CountNonEmptyCollection() { var client = await Init(); - await client.Add([$"{Guid.NewGuid()}", $"{Guid.NewGuid()}", $"{Guid.NewGuid()}", $"{Guid.NewGuid()}", $"{Guid.NewGuid()}", $"{Guid.NewGuid()}"]); - var result = await client.Count(); + await client.AddAsync([$"{Guid.NewGuid()}", $"{Guid.NewGuid()}", $"{Guid.NewGuid()}", $"{Guid.NewGuid()}", $"{Guid.NewGuid()}", $"{Guid.NewGuid()}"], embeddings: Embeddings(6)); + var result = await client.CountAsync(); Assert.That(result, Is.EqualTo(6)); } @@ -26,8 +27,8 @@ public async Task CountNonEmptyCollection() public async Task PeekDefault() { var client = await Init(); - await client.Add([$"{Guid.NewGuid()}", $"{Guid.NewGuid()}", $"{Guid.NewGuid()}", $"{Guid.NewGuid()}", $"{Guid.NewGuid()}", $"{Guid.NewGuid()}", $"{Guid.NewGuid()}", $"{Guid.NewGuid()}", $"{Guid.NewGuid()}", $"{Guid.NewGuid()}", $"{Guid.NewGuid()}"]); - var result = await client.Peek(); + await client.AddAsync([$"{Guid.NewGuid()}", $"{Guid.NewGuid()}", $"{Guid.NewGuid()}", $"{Guid.NewGuid()}", $"{Guid.NewGuid()}", $"{Guid.NewGuid()}", $"{Guid.NewGuid()}", $"{Guid.NewGuid()}", $"{Guid.NewGuid()}", $"{Guid.NewGuid()}", $"{Guid.NewGuid()}"], embeddings: Embeddings(11)); + var result = await client.PeekAsync(); Assert.That(result, Is.Not.Empty); } @@ -35,8 +36,8 @@ public async Task PeekDefault() public async Task PeekExplicitLimit() { var client = await Init(); - await client.Add([$"{Guid.NewGuid()}", $"{Guid.NewGuid()}", $"{Guid.NewGuid()}", $"{Guid.NewGuid()}", $"{Guid.NewGuid()}", $"{Guid.NewGuid()}", $"{Guid.NewGuid()}", $"{Guid.NewGuid()}", $"{Guid.NewGuid()}", $"{Guid.NewGuid()}", $"{Guid.NewGuid()}"]); - var result = await client.Peek( + await client.AddAsync([$"{Guid.NewGuid()}", $"{Guid.NewGuid()}", $"{Guid.NewGuid()}", $"{Guid.NewGuid()}", $"{Guid.NewGuid()}", $"{Guid.NewGuid()}", $"{Guid.NewGuid()}", $"{Guid.NewGuid()}", $"{Guid.NewGuid()}", $"{Guid.NewGuid()}", $"{Guid.NewGuid()}"], embeddings: Embeddings(11)); + var result = await client.PeekAsync( limit: 2); Assert.That(result, Has.Count.EqualTo(2)); } @@ -45,8 +46,10 @@ public async Task PeekExplicitLimit() public async Task ModifyCollectionName() { var client = await Init(); - await client.Modify( + await client.ModifyAsync( name: $"{client.Collection.Name}_modified"); + var result = await GetCollection($"{client.Collection.Name}_modified"); + Assert.That(result.Id, Is.EqualTo(client.Collection.Id)); } [Test] @@ -59,8 +62,12 @@ public async Task ModifyCollectionMetadata() }; var client = await Init(); - await client.Modify( + await client.ModifyAsync( metadata: metadata); + var result = await GetCollection(client.Collection.Name); + Assert.That(result.Metadata, Is.Not.Null); + Assert.That(result.Metadata["test"], Is.EqualTo(metadata["test"])); + Assert.That(result.Metadata["test2"], Is.EqualTo(metadata["test2"])); } [Test] @@ -73,16 +80,24 @@ public async Task ModifyCollectionAll() }; var client = await Init(); - await client.Modify( + await client.ModifyAsync( name: $"{client.Collection.Name}_modified", metadata: metadata); + var result = await GetCollection($"{client.Collection.Name}_modified"); + Assert.That(result.Id, Is.EqualTo(client.Collection.Id)); + Assert.That(result.Metadata, Is.Not.Null); + Assert.That(result.Metadata["test"], Is.EqualTo(metadata["test"])); + Assert.That(result.Metadata["test3"], Is.EqualTo(metadata["test3"])); } async Task Init() { var name = $"collection{Random.Shared.Next()}"; var client = new ChromaClient(BaseConfigurationOptions, HttpClient); - var collection = await client.CreateCollection(name); + var collection = await client.CreateCollectionAsync(name); return new ChromaCollectionClient(collection, BaseConfigurationOptions, HttpClient); } + + Task GetCollection(string name) + => new ChromaClient(BaseConfigurationOptions, HttpClient).GetCollectionAsync(name); } diff --git a/ChromaDB.Client.Tests/CollectionConfigurationTests.cs b/ChromaDB.Client.Tests/CollectionConfigurationTests.cs new file mode 100644 index 0000000..5109390 --- /dev/null +++ b/ChromaDB.Client.Tests/CollectionConfigurationTests.cs @@ -0,0 +1,164 @@ +using System.Net; +using System.Text.Json; +using ChromaDB.Client.Models; +using NUnit.Framework; + +namespace ChromaDB.Client.Tests; + +[TestFixture] +public class CollectionConfigurationTests +{ + const string Created = """{"id":"11111111-2222-3333-4444-555555555555","name":"c"}"""; + + [TestCase(ChromaSpace.L2, "l2")] + [TestCase(ChromaSpace.Cosine, "cosine")] + [TestCase(ChromaSpace.InnerProduct, "ip")] + public async Task CreateSendsTheSpaceInTheMetadata(ChromaSpace space, string expected) + { + var handler = new RecordingHandler(Created); + await Client(handler).CreateCollectionAsync(new ChromaCollectionDefinition("c") { Metadata = new Dictionary { ["key"] = "value" }, Configuration = new() { Space = space } }); + Assert.That(handler.Body.GetProperty("metadata").GetRawText(), Is.EqualTo($$"""{"key":"value","hnsw:space":"{{expected}}"}""")); + } + + // get_or_create is serialized by the generated code too. + [Test] + public async Task CreateAndGetOrCreateSendGetOrCreate() + { + var handler = new RecordingHandler(Created); + await Client(handler).CreateCollectionAsync("c"); + Assert.That(handler.Body.GetProperty("get_or_create").GetBoolean(), Is.False); + await Client(handler).GetOrCreateCollectionAsync("c"); + Assert.That(handler.Body.GetProperty("get_or_create").GetBoolean(), Is.True); + } + + [Test] + public async Task GetOrCreateSendsTheSpaceInTheMetadata() + { + var handler = new RecordingHandler(Created); + await Client(handler).GetOrCreateCollectionAsync(new ChromaCollectionDefinition("c") { Configuration = new() { Space = ChromaSpace.Cosine } }); + Assert.That(handler.Body.GetProperty("metadata").GetRawText(), Is.EqualTo("""{"hnsw:space":"cosine"}""")); + Assert.That(handler.Body.GetProperty("get_or_create").GetBoolean(), Is.True); + } + + // The metadata of the caller is not changed. + [Test] + public async Task CreateKeepsTheMetadataOfTheCaller() + { + var metadata = new Dictionary { ["key"] = "value" }; + await Client(new RecordingHandler(Created)).CreateCollectionAsync(new ChromaCollectionDefinition("c") { Metadata = metadata, Configuration = new() { Space = ChromaSpace.Cosine } }); + Assert.That(metadata.Keys, Is.EqualTo(new[] { "key" })); + } + + [Test] + public async Task CreateWithoutConfigurationSendsTheMetadataAsItIs() + { + var handler = new RecordingHandler(Created); + await Client(handler).CreateCollectionAsync(new ChromaCollectionDefinition("c") { Metadata = new Dictionary { ["hnsw:space"] = "ip" } }); + Assert.That(handler.Body.GetProperty("metadata").GetRawText(), Is.EqualTo("""{"hnsw:space":"ip"}""")); + } + + [Test] + public void CreateWithTwoDifferentSpacesThrows() + => Assert.ThrowsAsync(() => Client(new RecordingHandler(Created)).CreateCollectionAsync(new ChromaCollectionDefinition("c") { Metadata = new Dictionary { ["hnsw:space"] = "ip" }, Configuration = new() { Space = ChromaSpace.Cosine } })); + + [Test] + public async Task CreateWithTheSameSpaceTwiceIsAccepted() + { + var handler = new RecordingHandler(Created); + await Client(handler).CreateCollectionAsync(new ChromaCollectionDefinition("c") { Metadata = new Dictionary { ["hnsw:space"] = "cosine" }, Configuration = new() { Space = ChromaSpace.Cosine } }); + Assert.That(handler.Body.GetProperty("metadata").GetRawText(), Is.EqualTo("""{"hnsw:space":"cosine"}""")); + } + + [TestCase("""{"id":"11111111-2222-3333-4444-555555555555","name":"c","metadata":{"hnsw:space":"cosine"},"configuration_json":{"hnsw":{"space":"l2"}}}""", ChromaSpace.Cosine)] + [TestCase("""{"id":"11111111-2222-3333-4444-555555555555","name":"c","metadata":null,"configuration_json":{"hnsw":{"space":"ip"},"spann":null}}""", ChromaSpace.InnerProduct)] + // Chroma Cloud. + [TestCase("""{"id":"11111111-2222-3333-4444-555555555555","name":"c","metadata":null,"configuration_json":{"hnsw":null,"spann":{"space":"cosine"}}}""", ChromaSpace.Cosine)] + [TestCase("""{"id":"11111111-2222-3333-4444-555555555555","name":"c","metadata":null,"configuration_json":{"_type":"CollectionConfigurationInternal","hnsw_configuration":{"space":"l2"}}}""", null)] + [TestCase("""{"id":"11111111-2222-3333-4444-555555555555","name":"c","metadata":null}""", null)] + public async Task SpaceOfTheCollection(string response, ChromaSpace? expected) + { + using var httpClient = new HttpClient(new RecordingHandler(response)); + var collection = await new ChromaClient(new ChromaConfigurationOptions("http://localhost:8000"), httpClient).GetCollectionAsync("c"); + Assert.That(collection.Space, Is.EqualTo(expected)); + } + + [Test] + public async Task ConfigurationJsonAsTheServerSendsIt() + { + using var httpClient = new HttpClient(new RecordingHandler("""{"id":"11111111-2222-3333-4444-555555555555","name":"c","configuration_json":{"hnsw":{"space":"l2","ef_search":100}}}""")); + var collection = await new ChromaClient(new ChromaConfigurationOptions("http://localhost:8000"), httpClient).GetCollectionAsync("c"); + Assert.That(collection.ConfigurationJson!.Value.GetProperty("hnsw").GetProperty("ef_search").GetInt32(), Is.EqualTo(100)); + } + + [Test] + public async Task CollectionClientFromTheId() + { + var handler = new RecordingHandler("3"); + var id = Guid.NewGuid(); + var options = new ChromaConfigurationOptions("http://localhost:8000", tenant: "t", database: "d"); + var client = new ChromaCollectionClient(id, "c", options, new HttpClient(handler)); + Assert.That(await client.CountAsync(), Is.EqualTo(3)); + Assert.That(handler.Path, Is.EqualTo($"/api/v2/tenants/t/databases/d/collections/{id}/count")); + Assert.That(client.Collection.Id, Is.EqualTo(id)); + Assert.That(client.Collection.Name, Is.EqualTo("c")); + } + + [Test] + public async Task GetCollectionClientUsesTheOptionsOfTheClient() + { + var handler = new RecordingHandler("3"); + var id = Guid.NewGuid(); + var client = new ChromaClient(new ChromaConfigurationOptions("http://localhost:8000", tenant: "t", database: "d"), new HttpClient(handler)); + Assert.That(await client.GetCollectionClient(id, "c").CountAsync(), Is.EqualTo(3)); + Assert.That(handler.Path, Is.EqualTo($"/api/v2/tenants/t/databases/d/collections/{id}/count")); + Assert.That(await client.GetCollectionClient(new ChromaCollection("c") { Id = id, Tenant = "t2", Database = "d2" }).CountAsync(), Is.EqualTo(3)); + Assert.That(handler.Path, Is.EqualTo($"/api/v2/tenants/t2/databases/d2/collections/{id}/count")); + } + + // The collection clients of one ChromaClient share what it keeps: the version of the server is asked once. + [Test] + public async Task CollectionClientsOfAClientShareTheVersion() + { + var handler = new CountingHandler(); + var client = new ChromaClient(new ChromaConfigurationOptions("http://localhost:8000"), new HttpClient(handler)); + foreach (var name in new[] { "a", "b", "c" }) + { + await client.GetCollectionClient(Guid.NewGuid(), name).AddAsync(new ChromaRecords(["x"]) { Embeddings = [new([1f, 0f])], Metadatas = [new Dictionary { ["tags"] = new[] { "y" } }] }); + } + Assert.That(handler.VersionRequests, Is.EqualTo(1)); + Assert.That(handler.AddRequests, Is.EqualTo(3)); + } + + static ChromaClient Client(HttpMessageHandler handler) + => new(new ChromaConfigurationOptions("http://localhost:8000"), new HttpClient(handler)); + + sealed class CountingHandler : HttpMessageHandler + { + public int VersionRequests { get; private set; } + public int AddRequests { get; private set; } + + protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + var isVersion = request.RequestUri!.AbsolutePath.EndsWith("/version"); + if (isVersion) VersionRequests++; else if (request.RequestUri.AbsolutePath.EndsWith("/add")) AddRequests++; + return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent(isVersion ? "\"1.0.0\"" : "true") }); + } + } + + // Answers the version 1.0.0, and every other request with the response; records the last body. + sealed class RecordingHandler(string response) : HttpMessageHandler + { + public JsonElement Body { get; private set; } + public string? Path { get; private set; } + + protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + Path = request.RequestUri!.AbsolutePath; + if (request.Content is not null) + { + Body = JsonDocument.Parse(await request.Content.ReadAsStringAsync(cancellationToken)).RootElement.Clone(); + } + return new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent(Path.EndsWith("/version") ? "\"1.0.0\"" : response) }; + } + } +} diff --git a/ChromaDB.Client.Tests/CollectionSettingsServerTests.cs b/ChromaDB.Client.Tests/CollectionSettingsServerTests.cs new file mode 100644 index 0000000..02ce432 --- /dev/null +++ b/ChromaDB.Client.Tests/CollectionSettingsServerTests.cs @@ -0,0 +1,222 @@ +using System.Text.Json; +using ChromaDB.Client.Models; +using NUnit.Framework; + +namespace ChromaDB.Client.Tests; + +// The settings of a new collection on the server: applied where the server applies them, an error where it would ignore them. +[TestFixture] +public class CollectionSettingsServerTests : ChromaTestsBase +{ + static readonly ChromaEmbeddingFunctionReference OpenAi = ChromaEmbeddingFunctionReference.Known("openai", new Dictionary { ["model_name"] = "text-embedding-3-small", ["api_key_env_var"] = "OPENAI_API_KEY" }); + + // Chroma 1.0.6 and later report the configuration; the earlier versions take the "hnsw:" metadata without telling. + [Test] + public async Task HnswSettings() + { + var client = new ChromaClient(BaseConfigurationOptions, HttpClient); + var name = $"collection{Random.Shared.Next()}"; + var definition = new ChromaCollectionDefinition(name) + { + Configuration = new() { Space = ChromaSpace.Cosine, Hnsw = new() { EfConstruction = 150, EfSearch = 120, MaxNeighbors = 20, ResizeFactor = 1.5, SyncThreshold = 2000 } }, + }; + if (ChromaCloud) + { + await Assert.ThatAsync(() => client.CreateCollectionAsync(definition), Throws.InstanceOf().With.Message.Contains("SPANN")); + Assert.That(await client.CollectionExistsAsync(name), Is.False); + return; + } + var collection = await client.CreateCollectionAsync(definition); + Assert.That(collection.Space, Is.EqualTo(ChromaSpace.Cosine)); + if (ConfigurationSpaceReported) + { + var hnsw = collection.ConfigurationJson!.Value.GetProperty("hnsw"); + Assert.That((hnsw.GetProperty("ef_construction").GetInt32(), hnsw.GetProperty("ef_search").GetInt32(), hnsw.GetProperty("max_neighbors").GetInt32(), + hnsw.GetProperty("resize_factor").GetDouble(), hnsw.GetProperty("sync_threshold").GetInt32()), Is.EqualTo((150, 120, 20, 1.5, 2000))); + } + } + + // Chroma rejects 100.0 for the HNSW settings that are integers: a whole double goes as an integer. + [Test] + public async Task WholeDoublesInTheHnswMetadata() + { + if (ChromaCloud) + { + Assert.Ignore("Chroma Cloud has no HNSW index."); + } + var client = new ChromaClient(BaseConfigurationOptions, HttpClient); + var collection = await client.CreateCollectionAsync(new ChromaCollectionDefinition($"collection{Random.Shared.Next()}") + { + Metadata = new Dictionary { ["hnsw:construction_ef"] = 100.0, ["hnsw:M"] = 16.0, ["hnsw:resize_factor"] = 2.0 }, + }); + Assert.That(Convert.ToInt64(collection.Metadata!["hnsw:construction_ef"]), Is.EqualTo(100)); + if (ConfigurationSpaceReported) + { + var hnsw = collection.ConfigurationJson!.Value.GetProperty("hnsw"); + Assert.That((hnsw.GetProperty("ef_construction").GetInt32(), hnsw.GetProperty("max_neighbors").GetInt32(), hnsw.GetProperty("resize_factor").GetDouble()), Is.EqualTo((100, 16, 2.0))); + } + } + + [Test] + public async Task SpannSettings() + { + var client = new ChromaClient(BaseConfigurationOptions, HttpClient); + var name = $"collection{Random.Shared.Next()}"; + var definition = new ChromaCollectionDefinition(name) { Configuration = new() { Space = ChromaSpace.Cosine, Spann = new() { SearchNprobe = 32, WriteNprobe = 16 } } }; + if (ChromaCloud) + { + var spann = (await client.CreateCollectionAsync(definition)).ConfigurationJson!.Value.GetProperty("spann"); + Assert.That((spann.GetProperty("space").GetString(), spann.GetProperty("search_nprobe").GetInt32(), spann.GetProperty("write_nprobe").GetInt32()), Is.EqualTo(("cosine", 32, 16))); + return; + } + Assume.That(IsChroma0 || NewConfigurationApplied, "Chroma 1.0.0 to 1.0.5 report no configuration: the client cannot tell that they ignore the SPANN settings."); + await Assert.ThatAsync(() => client.CreateCollectionAsync(definition), Throws.InstanceOf().With.Message.Contains(IsChroma0 ? "1.0.0" : "HNSW")); + Assert.That(await client.CollectionExistsAsync(name), Is.False); + } + + [Test] + public async Task EmbeddingFunction() + { + var client = new ChromaClient(BaseConfigurationOptions, HttpClient); + var definition = new ChromaCollectionDefinition($"collection{Random.Shared.Next()}") { Configuration = new() { Space = ChromaSpace.InnerProduct, EmbeddingFunction = OpenAi } }; + if (IsChroma0) + { + await Assert.ThatAsync(() => client.CreateCollectionAsync(definition), Throws.InstanceOf().With.Message.Contains("1.0.0")); + return; + } + var collection = await client.CreateCollectionAsync(definition); + Assert.That(collection.Space, Is.EqualTo(ChromaSpace.InnerProduct)); + if (ChromaCloud || ConfigurationSpaceReported) + { + Assert.That(collection.ConfigurationJson!.Value.GetProperty("embedding_function").GetProperty("name").GetString(), Is.EqualTo("openai")); + } + } + + [Test] + public async Task ModifyTheEmbeddingFunction() + { + Assume.That(ChromaCloud || NewConfigurationApplied, "Chroma 1.0.6 and later apply a new configuration."); + var client = new ChromaClient(BaseConfigurationOptions, HttpClient); + var collection = await client.CreateCollectionAsync($"collection{Random.Shared.Next()}"); + await client.GetCollectionClient(collection).ModifyConfigurationAsync(new ChromaCollectionConfigurationUpdate { EmbeddingFunction = OpenAi }); + var configuration = (await client.GetCollectionAsync(collection.Name)).ConfigurationJson!.Value; + Assert.That(configuration.GetProperty("embedding_function").GetProperty("name").GetString(), Is.EqualTo("openai")); + } + + // A BM25 index added with ifSupported: Chroma Cloud creates the collection with it, every other server without it, as it rejects + // sparse vector indexes. + [Test] + public async Task Bm25IndexIfSupported() + { + var client = new ChromaClient(BaseConfigurationOptions, HttpClient); + var created = await client.CreateCollectionAsync(new ChromaCollectionDefinition($"collection{Random.Shared.Next()}") + { + Configuration = new() { Space = ChromaSpace.Cosine }, + Schema = new ChromaCollectionSchema().WithBm25Index(ChromaSearchKeys.Document, ifSupported: true), + }); + Assert.That(created.FindBm25Index(ChromaSearchKeys.Document), ChromaCloud ? Is.Not.Null : Is.Null); + var collection = client.GetCollectionClient(created); + await collection.AddAsync(new ChromaRecords(["a"]) { Embeddings = [new([1f, 0f])], Documents = ["apple pie"] }); + Assert.That((await collection.GetAsync(include: ChromaGetInclude.Documents)).Single().Document, Is.EqualTo("apple pie")); + } + + // Chroma 1.3.0 and later apply the indexes of a schema. + [Test] + public async Task IndexesOfTheSchema() + { + Assume.That(SchemaApplied, "Chroma 1.3.0 and later apply a schema."); + var client = new ChromaClient(BaseConfigurationOptions, HttpClient); + var schema = new ChromaCollectionSchema() + .WithoutIndex(ChromaSchemaIndex.StringInverted, "title") + .WithoutIndex(ChromaSchemaIndex.IntInverted) + .WithoutIndex(ChromaSchemaIndex.FullTextSearch); + var read = (await client.CreateCollectionAsync(new ChromaCollectionDefinition($"collection{Random.Shared.Next()}") { Schema = schema })).SchemaJson!.Value; + Assert.That(Enabled(read.GetProperty("keys").GetProperty("title").GetProperty("string").GetProperty("string_inverted_index")), Is.False); + Assert.That(Enabled(read.GetProperty("defaults").GetProperty("int").GetProperty("int_inverted_index")), Is.False); + Assert.That(Enabled(read.GetProperty("keys").GetProperty("#document").GetProperty("string").GetProperty("fts_index")), Is.False); + Assert.That(Enabled(read.GetProperty("defaults").GetProperty("float").GetProperty("float_inverted_index")), Is.True); + } + + // A filter on a key without its index is rejected; the full-text search index turned off is kept by Chroma 1.3.0 to 1.5.0. + [Test] + public async Task FiltersWithoutTheirIndex() + { + Assume.That(SchemaApplied, "Chroma 1.3.0 and later apply a schema."); + var client = new ChromaClient(BaseConfigurationOptions, HttpClient); + var schema = new ChromaCollectionSchema().WithoutIndex(ChromaSchemaIndex.StringInverted, "title").WithoutIndex(ChromaSchemaIndex.IntInverted).WithoutIndex(ChromaSchemaIndex.FullTextSearch); + var collection = client.GetCollectionClient(await client.CreateCollectionAsync(new ChromaCollectionDefinition($"collection{Random.Shared.Next()}") { Schema = schema })); + await collection.AddAsync(new ChromaRecords(["a", "b"]) + { + Embeddings = [new([1f, 0f]), new([0f, 1f])], + Documents = ["apple pie", "banana split"], + Metadatas = [new Dictionary { ["title"] = "x", ["n"] = 1, ["other"] = "x" }, new Dictionary { ["title"] = "y", ["n"] = 2, ["other"] = "y" }], + }); + Assert.That((await collection.GetAsync(where: ChromaWhereOperator.Equal("other", "x"))).Select(x => x.Id), Is.EqualTo(new[] { "a" })); + await Assert.ThatAsync(() => collection.GetAsync(where: ChromaWhereOperator.Equal("title", "x")), Throws.InstanceOf().With.Message.Contains("indexing is disabled")); + await Assert.ThatAsync(() => collection.GetAsync(where: ChromaWhereOperator.Equal("n", 1)), Throws.InstanceOf().With.Message.Contains("indexing is disabled")); + if (FullTextSearchOffApplied) + { + await Assert.ThatAsync(() => collection.GetAsync(whereDocument: ChromaWhereDocumentOperator.Contains("apple")), Throws.InstanceOf().With.Message.Contains("FTS indexing is disabled")); + } + else + { + Assert.That((await collection.GetAsync(whereDocument: ChromaWhereDocumentOperator.Contains("apple"))).Select(x => x.Id), Is.EqualTo(new[] { "a" })); + } + } + + // Only Chroma Cloud has sparse vector indexes, SPANN and customer-managed keys. A key of Google Cloud KMS that does not exist is kept. + [Test] + public async Task SettingsOfChromaCloud() + { + Assume.That(ChromaCloud, Is.True, "Only Chroma Cloud has them."); + var client = new ChromaClient(BaseConfigurationOptions, HttpClient); + const string key = "projects/p/locations/us/keyRings/r/cryptoKeys/k"; + var collection = await client.CreateCollectionAsync(new ChromaCollectionDefinition($"collection{Random.Shared.Next()}") + { + Configuration = new() { Space = ChromaSpace.Cosine, Spann = new() { SearchNprobe = 32, NreplicaCount = 4, SearchRngEpsilon = 8, NumSamplesKmeans = 500 } }, + Schema = new ChromaCollectionSchema().WithSparseVectorIndex("doc_bm25", bm25: true).WithGcpCmek(key), + }); + Assert.That(collection.SparseVectorIndexes.Single().Algorithm, Is.EqualTo(ChromaSparseIndexAlgorithm.Wand)); + var schema = collection.SchemaJson!.Value; + Assert.That(schema.GetProperty("cmek").GetProperty("gcp").GetString(), Is.EqualTo(key)); + var spann = schema.GetProperty("keys").GetProperty("#embedding").GetProperty("float_list").GetProperty("vector_index").GetProperty("config").GetProperty("spann"); + Assert.That((spann.GetProperty("search_nprobe").GetInt32(), spann.GetProperty("nreplica_count").GetInt32(), spann.GetProperty("search_rng_epsilon").GetDouble(), spann.GetProperty("num_samples_kmeans").GetInt32()), + Is.EqualTo((32, 4, 8.0, 500))); + Assert.That(collection.Space, Is.EqualTo(ChromaSpace.Cosine)); + } + + // MaxScore is only for the tenants of Chroma Cloud that have it: on the others the test is inconclusive. + [Test] + public async Task MaxScoreSparseIndex() + { + Assume.That(ChromaCloud, Is.True, "Only Chroma Cloud has sparse vector indexes."); + var client = new ChromaClient(BaseConfigurationOptions, HttpClient); + var definition = new ChromaCollectionDefinition($"collection{Random.Shared.Next()}") { Schema = new ChromaCollectionSchema().WithSparseVectorIndex("doc_bm25", ChromaSparseIndexAlgorithm.MaxScore, bm25: true) }; + ChromaCollection collection; + try + { + collection = await client.CreateCollectionAsync(definition); + } + catch (ChromaException ex) when (ex.StatusCode is System.Net.HttpStatusCode.BadRequest or System.Net.HttpStatusCode.Forbidden) + { + Assert.Inconclusive($"The tenant does not have MaxScore: {ex.Message}"); + return; + } + Assert.That(collection.SparseVectorIndexes.Single().Algorithm, Is.EqualTo(ChromaSparseIndexAlgorithm.MaxScore)); + } + + // Chroma 0.5.1 and later send the dimension, set by the first write. + [Test] + public async Task Dimension() + { + var client = new ChromaClient(BaseConfigurationOptions, HttpClient); + var collection = await client.CreateCollectionAsync($"collection{Random.Shared.Next()}"); + Assert.That(collection.Dimension, Is.Null); + await client.GetCollectionClient(collection).AddAsync(["a"], embeddings: [new([1f, 0f, 0.5f])]); + var read = await client.GetCollectionAsync(collection.Name); + Assert.That(read.Dimension, CollectionDimensionReported ? Is.EqualTo(3) : Is.Null); + Assert.That(read.Version, CollectionDimensionReported ? Is.Not.Null : Is.Null); + } + + static bool Enabled(JsonElement index) => index.GetProperty("enabled").GetBoolean(); +} diff --git a/ChromaDB.Client.Tests/CollectionSettingsTests.cs b/ChromaDB.Client.Tests/CollectionSettingsTests.cs new file mode 100644 index 0000000..fe1f136 --- /dev/null +++ b/ChromaDB.Client.Tests/CollectionSettingsTests.cs @@ -0,0 +1,402 @@ +using System.Net; +using System.Text.Json; +using ChromaDB.Client.Models; +using NUnit.Framework; + +namespace ChromaDB.Client.Tests; + +// The settings of a new collection, where they go in the request, and what the client does when the server ignores them, against a +// fake server. The schemas are written as create_index and delete_index of the Python client of Chroma 1.5.9 write them. +[TestFixture] +public class CollectionSettingsTests +{ + const string Created = """{"id":"11111111-2222-3333-4444-555555555555","name":"c"}"""; + const string CreatedWithSchema = """{"id":"11111111-2222-3333-4444-555555555555","name":"c","schema":{"defaults":{},"keys":{}}}"""; + static readonly ChromaEmbeddingFunctionReference OpenAi = ChromaEmbeddingFunctionReference.Known("openai", new Dictionary { ["model_name"] = "text-embedding-3-small" }); + const string OpenAiJson = """{"type":"known","name":"openai","config":{"model_name":"text-embedding-3-small"}}"""; + + // The space and the HNSW settings go in the "hnsw:" metadata, which every tested Chroma applies. + [Test] + public async Task HnswSettingsInTheMetadata() + { + var server = Server(); + await Client(server).CreateCollectionAsync(new ChromaCollectionDefinition("c") + { + Metadata = new Dictionary { ["topic"] = "x" }, + Configuration = new() + { + Space = ChromaSpace.Cosine, + Hnsw = new() { EfConstruction = 150, EfSearch = 120, MaxNeighbors = 20, ResizeFactor = 1.5, SyncThreshold = 2000, BatchSize = 200, NumThreads = 2 }, + }, + }); + var body = Create(server); + Assert.That(body.GetProperty("metadata").GetRawText(), Is.EqualTo(""" + {"topic":"x","hnsw:space":"cosine","hnsw:construction_ef":150,"hnsw:search_ef":120,"hnsw:M":20,"hnsw:resize_factor":1.5, + "hnsw:sync_threshold":2000,"hnsw:batch_size":200,"hnsw:num_threads":2} + """.Replace("\r", "").Replace("\n", "").Replace("\t", ""))); + Assert.That(body.TryGetProperty("configuration", out _), Is.False); + Assert.That(body.TryGetProperty("schema", out _), Is.False); + } + + // Chroma 0.6.3 and 1.5.9 take these settings only as integers, and reject 100.0: a whole double goes as an integer. + [Test] + public async Task IntegerSettingsInTheMetadata() + { + var server = Server(); + await Client(server).CreateCollectionAsync(new ChromaCollectionDefinition("c") + { + Metadata = new Dictionary + { + ["hnsw:construction_ef"] = 100.0, ["hnsw:search_ef"] = 50f, ["hnsw:M"] = 16m, ["hnsw:batch_size"] = JsonDocument.Parse("200").RootElement, + ["hnsw:sync_threshold"] = 1000, ["hnsw:resize_factor"] = 2.0, ["topic"] = 3.0, + }, + }); + Assert.That(Create(server).GetProperty("metadata").GetRawText(), Is.EqualTo(""" + {"hnsw:construction_ef":100,"hnsw:search_ef":50,"hnsw:M":16,"hnsw:batch_size":200,"hnsw:sync_threshold":1000,"hnsw:resize_factor":2.0,"topic":3.0} + """)); + } + + // A whole number in the range of a long goes as that long, as the long itself would, and one beyond it throws. + [Test] + public async Task IntegerSettingsUpToTheBoundsOfALong() + { + foreach (var (value, sent) in new (object, string)[] + { + (9000000000000000000m, "9000000000000000000"), + ((decimal)long.MaxValue, "9223372036854775807"), + ((decimal)long.MinValue, "-9223372036854775808"), + (-9223372036854775808.0, "-9223372036854775808"), + (9223372036854774784.0, "9223372036854774784"), + (-9223372036854775808f, "-9223372036854775808"), + (9223371487098961920f, "9223371487098961920"), + }) + { + var server = Server(); + await Client(server).CreateCollectionAsync(new ChromaCollectionDefinition("c") { Metadata = new Dictionary { ["hnsw:sync_threshold"] = value } }); + Assert.That(Create(server).GetProperty("metadata").GetProperty("hnsw:sync_threshold").GetRawText(), Is.EqualTo(sent), $"{value}"); + } + foreach (var value in new object[] { (decimal)long.MaxValue + 1, 9223372036854775808.0, 9223372036854775808f }) + { + Assert.That(() => Client(Server()).CreateCollectionAsync(new ChromaCollectionDefinition("c") { Metadata = new Dictionary { ["hnsw:sync_threshold"] = value } }), + Throws.ArgumentException.With.Message.Contains("only as an integer"), $"{value}"); + } + } + + [Test] + public void SettingsThatChromaTakesOnlyAsIntegers() + { + var server = Server(); + foreach (var (key, value) in new (string, object)[] + { + ("hnsw:construction_ef", 100.5), + ("hnsw:search_ef", JsonDocument.Parse("100.0").RootElement), + ("hnsw:M", JsonDocument.Parse("1.999999999999999999999999999999999").RootElement), + ("hnsw:M", 1.9999999999999999999999999m), + ("hnsw:num_threads", double.NaN), + }) + { + Assert.That(() => Client(server).CreateCollectionAsync(new ChromaCollectionDefinition("c") { Metadata = new Dictionary { [key] = value } }), + Throws.ArgumentException.With.Message.Contains("only as an integer"), $"{key} = {value}"); + } + Assert.That(server.Requests, Is.Empty); + } + + // The SPANN settings go in the configuration, with the space: Chroma ignores the hnsw:space metadata of a request with them. + [Test] + public async Task SpannSettingsInTheConfiguration() + { + var server = Server(); + await Client(server).CreateCollectionAsync(new ChromaCollectionDefinition("c") + { + Configuration = new() + { + Space = ChromaSpace.Cosine, + Spann = new() { SearchNprobe = 32, WriteNprobe = 16, EfConstruction = 150, EfSearch = 120, MaxNeighbors = 20, SplitThreshold = 100, MergeThreshold = 30, ReassignNeighborCount = 32 }, + }, + }); + var body = Create(server); + Assert.That(body.GetProperty("configuration").GetRawText(), Is.EqualTo(""" + {"spann":{"search_nprobe":32,"write_nprobe":16,"ef_construction":150,"ef_search":120,"max_neighbors":20,"split_threshold":100, + "merge_threshold":30,"reassign_neighbor_count":32,"space":"cosine"}} + """.Replace("\r", "").Replace("\n", "").Replace("\t", ""))); + Assert.That(body.GetProperty("metadata").ValueKind, Is.EqualTo(JsonValueKind.Null)); + } + + // The embedding function goes in the configuration, the space still in the metadata, which Chroma applies with it. + [Test] + public async Task EmbeddingFunctionInTheConfiguration() + { + var server = Server(); + await Client(server).CreateCollectionAsync(new ChromaCollectionDefinition("c") { Configuration = new() { Space = ChromaSpace.InnerProduct, EmbeddingFunction = OpenAi } }); + var body = Create(server); + Assert.That(body.GetProperty("configuration").GetRawText(), Is.EqualTo($$"""{"embedding_function":{{OpenAiJson}}}""")); + Assert.That(body.GetProperty("metadata").GetRawText(), Is.EqualTo("""{"hnsw:space":"ip"}""")); + } + + // With a schema every setting goes in it, on the vector index of the defaults and of #embedding: Chroma rejects a configuration + // together with a schema. + [Test] + public async Task SettingsInTheSchema() + { + var server = Server(answer: CreatedWithSchema); + await Client(server).CreateCollectionAsync(new ChromaCollectionDefinition("c") + { + Configuration = new() { Space = ChromaSpace.InnerProduct, Hnsw = new() { EfConstruction = 150 }, EmbeddingFunction = OpenAi }, + Schema = new ChromaCollectionSchema().WithoutIndex(ChromaSchemaIndex.StringInverted, "title"), + }); + var body = Create(server); + var vector = """{"space":"ip","hnsw":{"ef_construction":150},"embedding_function":""" + OpenAiJson + "}"; + Assert.That(body.GetProperty("schema").GetRawText(), Is.EqualTo( + """{"defaults":{"float_list":{"vector_index":{"enabled":false,"config":""" + vector + "}}}," + + "\"keys\":{\"title\":{\"string\":{\"string_inverted_index\":{\"enabled\":false,\"config\":{}}}}," + + "\"#embedding\":{\"float_list\":{\"vector_index\":{\"enabled\":true,\"config\":" + vector + "}}}}}")); + Assert.That(body.GetProperty("metadata").ValueKind, Is.EqualTo(JsonValueKind.Null)); + Assert.That(body.TryGetProperty("configuration", out _), Is.False); + } + + // The SPANN settings that only a schema takes put all the settings in a schema, also without one in the definition. + [Test] + public async Task SpannSettingsOfTheSchemaOnly() + { + var server = Server(answer: CreatedWithSchema); + await Client(server).CreateCollectionAsync(new ChromaCollectionDefinition("c") + { + Configuration = new() { Space = ChromaSpace.Cosine, Spann = new() { SearchNprobe = 32, NreplicaCount = 4, SearchRngEpsilon = 8, WriteRngEpsilon = 6, NumSamplesKmeans = 500, NumCentersToMergeTo = 6, CenterDriftThreshold = 0.25 } }, + }); + var body = Create(server); + Assert.That(body.TryGetProperty("configuration", out _), Is.False); + var config = body.GetProperty("schema").GetProperty("keys").GetProperty("#embedding").GetProperty("float_list").GetProperty("vector_index").GetProperty("config"); + Assert.That(config.GetRawText(), Is.EqualTo(""" + {"space":"cosine","spann":{"search_nprobe":32,"search_rng_epsilon":8.0,"write_rng_epsilon":6.0,"nreplica_count":4,"num_samples_kmeans":500, + "num_centers_to_merge_to":6,"center_drift_threshold":0.25}} + """.Replace("\r", "").Replace("\n", "").Replace("\t", ""))); + } + + // Chroma rejects the two indexes together ("Multiple vector index configurations provided"): nothing is sent. + [Test] + public void HnswAndSpannTogether() + { + var server = Server(); + var definition = new ChromaCollectionDefinition("c") { Configuration = new() { Hnsw = new() { EfSearch = 1 }, Spann = new() { SearchNprobe = 1 } } }; + Assert.That(() => Client(server).CreateCollectionAsync(definition), Throws.ArgumentException); + Assert.That(() => Client(server).GetOrCreateCollectionAsync(definition), Throws.ArgumentException); + Assert.That(server.Requests, Is.Empty); + } + + // Chroma 0.5.4 to 0.6.3 fail on the configuration, 0.4.10 to 0.5.3 ignore it: the client throws before sending it. + [TestCase("spann")] + [TestCase("embedding function")] + public void ConfigurationOnChroma0(string setting) + { + var server = Server("0.6.3"); + var configuration = setting == "spann" ? new ChromaCollectionConfiguration { Spann = new() { SearchNprobe = 32 } } : new ChromaCollectionConfiguration { EmbeddingFunction = OpenAi }; + Assert.That(() => Client(server).CreateCollectionAsync(new ChromaCollectionDefinition("c") { Configuration = configuration }), + Throws.InstanceOf().With.Message.Contains("1.0.0")); + Assert.That(server.Requests.Select(x => x.Path), Has.None.EndsWith("/collections")); + } + + // The HNSW settings stay in the metadata on Chroma 0.x, which applies them from there. + [Test] + public async Task HnswSettingsOnChroma0() + { + var server = Server("0.6.3"); + await Client(server).CreateCollectionAsync(new ChromaCollectionDefinition("c") { Configuration = new() { Hnsw = new() { MaxNeighbors = 20 } } }); + Assert.That(Create(server).GetProperty("metadata").GetRawText(), Is.EqualTo("""{"hnsw:M":20}""")); + } + + // A collection whose index ignores the settings: CreateCollectionAsync deletes it and throws, GetOrCreateCollectionAsync throws and + // keeps it, since it may have existed before. + [TestCase("hnsw", """{"hnsw":null,"spann":{"search_nprobe":64}}""", "SPANN")] + [TestCase("spann", """{"hnsw":{"ef_search":100},"spann":null}""", "HNSW")] + public async Task SettingsOfTheOtherIndex(string setting, string configuration, string index) + { + var settings = setting == "hnsw" ? new ChromaCollectionConfiguration { Hnsw = new() { EfSearch = 120 } } : new ChromaCollectionConfiguration { Spann = new() { SearchNprobe = 32 } }; + var answer = $$"""{"id":"11111111-2222-3333-4444-555555555555","name":"c","configuration_json":{{configuration}}}"""; + var server = Server(answer: answer); + await Assert.ThatAsync(() => Client(server).CreateCollectionAsync(new ChromaCollectionDefinition("c") { Configuration = settings }), + Throws.InstanceOf().With.Message.Contains($"has an {index}").Or.Message.Contains($"has a {index}")); + Assert.That(server.Requests.Last().Method, Is.EqualTo("DELETE")); + server = Server(answer: answer); + await Assert.ThatAsync(() => Client(server).GetOrCreateCollectionAsync(new ChromaCollectionDefinition("c") { Configuration = settings }), Throws.InstanceOf()); + Assert.That(server.Requests.Select(x => x.Method), Has.None.EqualTo("DELETE")); + } + + // The settings that the server applies, as it reports them: no error. + [Test] + public async Task SettingsOfTheSameIndex() + { + var server = Server(answer: """{"id":"11111111-2222-3333-4444-555555555555","name":"c","configuration_json":{"hnsw":{"ef_search":120},"spann":null}}"""); + await Client(server).CreateCollectionAsync(new ChromaCollectionDefinition("c") { Configuration = new() { Hnsw = new() { EfSearch = 120 } } }); + Assert.That(server.Requests.Select(x => x.Method), Has.None.EqualTo("DELETE")); + } + + // Chroma ignores the hnsw:space metadata next to SPANN settings, while ChromaCollection.Space would read it back: the client rejects it. + [Test] + public void SpaceMetadataNextToSpannSettings() + { + var server = Server(); + var definition = new ChromaCollectionDefinition("c") + { + Metadata = new Dictionary { ["hnsw:space"] = "l2" }, + Configuration = new() { Space = ChromaSpace.Cosine, Spann = new() { SearchNprobe = 32 } }, + }; + Assert.That(() => Client(server).CreateCollectionAsync(definition), Throws.ArgumentException.With.Message.Contains("Configuration.Space")); + Assert.That(() => Client(server).GetOrCreateCollectionAsync(definition), Throws.ArgumentException); + Assert.That(server.Requests, Is.Empty); + } + + // Chroma 1.5.9 crashes on the first write with 0 neighbors and misses the nearest records with 1: nothing is sent. + [TestCase(0)] + [TestCase(1)] + [TestCase(-3)] + public async Task FewerThanTwoNeighbors(int maxNeighbors) + { + var server = Server(); + Assert.That(() => Client(server).CreateCollectionAsync(new ChromaCollectionDefinition("c") { Configuration = new() { Hnsw = new() { MaxNeighbors = maxNeighbors } } }), + Throws.ArgumentException.With.Message.Contains("at least 2")); + Assert.That(() => Client(server).GetOrCreateCollectionAsync(new ChromaCollectionDefinition("c") { Metadata = new Dictionary { ["hnsw:M"] = (long)maxNeighbors } }), + Throws.ArgumentException); + Assert.That(() => Client(server).CreateCollectionAsync(new ChromaCollectionDefinition("c") { Metadata = new Dictionary { ["hnsw:M"] = JsonDocument.Parse(maxNeighbors.ToString(System.Globalization.CultureInfo.InvariantCulture)).RootElement } }), + Throws.ArgumentException.With.Message.Contains("at least 2")); + Assert.That(() => Client(server).CreateCollectionAsync(new ChromaCollectionDefinition("c") + { + Configuration = new() { Hnsw = new() { MaxNeighbors = maxNeighbors } }, + Schema = new ChromaCollectionSchema(), + }), Throws.ArgumentException); + var collection = new ChromaCollectionClient(Guid.Parse("11111111-2222-3333-4444-555555555555"), "c", new ChromaConfigurationOptions("http://localhost:8000"), new HttpClient(server)); + await Assert.ThatAsync(() => collection.ModifyConfigurationAsync(new ChromaCollectionConfigurationUpdate { Hnsw = new() { MaxNeighbors = maxNeighbors } }), Throws.ArgumentException); + Assert.That(server.Requests, Is.Empty); + Assert.That(() => Client(server).CreateCollectionAsync(new ChromaCollectionDefinition("c") { Configuration = new() { Hnsw = new() { MaxNeighbors = 2 } } }), Throws.Nothing); + } + + [Test] + public void MetadataThatDisagreesWithTheSettings() + { + var server = Server(); + Assert.That(() => Client(server).CreateCollectionAsync(new ChromaCollectionDefinition("c") + { + Metadata = new Dictionary { ["hnsw:M"] = 10 }, + Configuration = new() { Hnsw = new() { MaxNeighbors = 20 } }, + }), Throws.ArgumentException); + Assert.That(() => Client(server).CreateCollectionAsync(new ChromaCollectionDefinition("c") + { + Metadata = new Dictionary { ["hnsw:M"] = 20L, ["hnsw:resize_factor"] = 1.5f }, + Configuration = new() { Hnsw = new() { MaxNeighbors = 20, ResizeFactor = 1.5 } }, + }), Throws.Nothing); + Assert.That(() => Client(server).CreateCollectionAsync(new ChromaCollectionDefinition("c") + { + Metadata = new Dictionary { ["hnsw:M"] = JsonDocument.Parse("20").RootElement, ["hnsw:space"] = JsonDocument.Parse("\"cosine\"").RootElement }, + Configuration = new() { Space = ChromaSpace.Cosine, Hnsw = new() { MaxNeighbors = 20 } }, + }), Throws.Nothing); + Assert.That(() => Client(server).CreateCollectionAsync(new ChromaCollectionDefinition("c") + { + Metadata = new Dictionary { ["hnsw:space"] = JsonDocument.Parse("\"l2\"").RootElement }, + Configuration = new() { Space = ChromaSpace.Cosine }, + }), Throws.ArgumentException); + Assert.That(() => Client(server).CreateCollectionAsync(new ChromaCollectionDefinition("c") + { + Metadata = new Dictionary { ["hnsw:M"] = JsonDocument.Parse("10").RootElement }, + Configuration = new() { Hnsw = new() { MaxNeighbors = 20 } }, + }), Throws.ArgumentException); + } + + [Test] + public void IndexesOfTheSchema() + { + var schema = new ChromaCollectionSchema() + .WithIndex(ChromaSchemaIndex.FullTextSearch) + .WithoutIndex(ChromaSchemaIndex.IntInverted) + .WithoutIndex(ChromaSchemaIndex.FloatInverted) + .WithoutIndex(ChromaSchemaIndex.BoolInverted) + .WithoutIndex(ChromaSchemaIndex.StringInverted, "title") + .WithSparseVectorIndex("title", ChromaSparseIndexAlgorithm.MaxScore, bm25: true); + Assert.That(schema.ToString(), Is.EqualTo(""" + {"defaults":{"int":{"int_inverted_index":{"enabled":false,"config":{}}},"float":{"float_inverted_index":{"enabled":false,"config":{}}}, + "bool":{"bool_inverted_index":{"enabled":false,"config":{}}}}, + "keys":{"#document":{"string":{"fts_index":{"enabled":true,"config":{}}}}, + "title":{"string":{"string_inverted_index":{"enabled":false,"config":{}}},"sparse_vector":{"sparse_vector_index":{"enabled":true,"config":{"bm25":true,"algorithm":"max_score"}}}}}} + """.Replace("\r", "").Replace("\n", "").Replace("\t", ""))); + } + + // As the Python client: the full-text search index is on #document only. + [Test] + public void FullTextSearchOnTheDocumentsOnly() + { + Assert.That(() => new ChromaCollectionSchema().WithIndex(ChromaSchemaIndex.FullTextSearch, "title"), Throws.ArgumentException); + Assert.That(new ChromaCollectionSchema().WithoutIndex(ChromaSchemaIndex.FullTextSearch, ChromaSearchKeys.Document).ToString(), + Is.EqualTo("""{"defaults":{},"keys":{"#document":{"string":{"fts_index":{"enabled":false,"config":{}}}}}}""")); + } + + // The default algorithm is left out, as the Python client writes it; the schema read back gives it. + [Test] + public async Task SparseIndexAlgorithm() + { + Assert.That(new ChromaCollectionSchema().WithSparseVectorIndex("v", ChromaSparseIndexAlgorithm.Wand).ToString(), Does.Not.Contain("algorithm")); + var server = Server(answer: """ + {"id":"11111111-2222-3333-4444-555555555555","name":"c","schema":{"defaults":{},"keys":{ + "a":{"sparse_vector":{"sparse_vector_index":{"enabled":true,"config":{"bm25":true,"algorithm":"max_score"}}}}, + "b":{"sparse_vector":{"sparse_vector_index":{"enabled":true,"config":{"bm25":true}}}}}}} + """); + var collection = await Client(server).CreateCollectionAsync(new ChromaCollectionDefinition("c") { Schema = new ChromaCollectionSchema().WithSparseVectorIndex("a", ChromaSparseIndexAlgorithm.MaxScore, bm25: true) }); + Assert.That(collection.SparseVectorIndexes.Select(x => (x.Key, x.Algorithm)), Is.EqualTo(new[] { ("a", ChromaSparseIndexAlgorithm.MaxScore), ("b", ChromaSparseIndexAlgorithm.Wand) })); + } + + [Test] + public void CustomerManagedKey() + { + const string key = "projects/p/locations/us/keyRings/r/cryptoKeys/k"; + Assert.That(new ChromaCollectionSchema().WithGcpCmek(key).ToString(), Is.EqualTo("{\"defaults\":{},\"keys\":{},\"cmek\":{\"gcp\":\"" + key + "\"}}")); + Assert.That(() => new ChromaCollectionSchema().WithGcpCmek("my-key"), Throws.ArgumentException); + Assert.That(() => new ChromaCollectionSchema().WithGcpCmek(key + "/cryptoKeyVersions/1"), Throws.ArgumentException); + Assert.That(() => new ChromaCollectionSchema().WithGcpCmek(key + "\n"), Throws.ArgumentException); + Assert.That(() => new ChromaCollectionSchema().WithGcpCmek("projects/p q/locations/us/keyRings/r/cryptoKeys/k"), Throws.ArgumentException); + Assert.That(() => new ChromaCollectionSchema().WithGcpCmek(" " + key), Throws.ArgumentException); + } + + // new_configuration with the embedding function, as Chroma writes it. + [Test] + public async Task ModifyTheEmbeddingFunction() + { + var server = new FakeServer(r => r.Method == "GET" + ? (HttpStatusCode.OK, """{"id":"11111111-2222-3333-4444-555555555555","name":"c","configuration_json":{"hnsw":{"ef_search":100},"spann":null}}""") + : (HttpStatusCode.OK, "{}")); + var collection = new ChromaCollectionClient(Guid.Parse("11111111-2222-3333-4444-555555555555"), "c", new ChromaConfigurationOptions("http://localhost:8000"), new HttpClient(server)); + await collection.ModifyConfigurationAsync(new ChromaCollectionConfigurationUpdate { EmbeddingFunction = OpenAi, Hnsw = new() { EfSearch = 150 } }); + Assert.That(server.Requests.Single(x => x.Method == "PUT").Body.GetRawText(), + Is.EqualTo("""{"new_name":null,"new_metadata":null,"new_configuration":{"hnsw":{"ef_search":150},"embedding_function":""" + OpenAiJson + "}}")); + } + + [TestCase("""{"id":"11111111-2222-3333-4444-555555555555","name":"c","dimension":3,"version":2,"log_position":7}""", 3, 2, 7L)] + [TestCase("""{"id":"11111111-2222-3333-4444-555555555555","name":"c","dimension":null,"version":0}""", null, 0, null)] + [TestCase("""{"id":"11111111-2222-3333-4444-555555555555","name":"c"}""", null, null, null)] + public async Task DimensionVersionAndLogPosition(string answer, int? dimension, int? version, long? logPosition) + { + var collection = await Client(Server(answer: answer)).GetCollectionAsync("c"); + Assert.That((collection.Dimension, collection.Version, collection.LogPosition), Is.EqualTo((dimension, version, logPosition))); + } + + static JsonElement Create(FakeServer server) + => server.Requests.Single(x => x.Method == "POST" && x.Path.EndsWith("/collections")).Body; + + static FakeServer Server(string version = "1.5.9", string answer = Created) + => new(r => r.Path.EndsWith("/version") ? (HttpStatusCode.OK, $"\"{version}\"") : (HttpStatusCode.OK, answer)); + + static ChromaClient Client(HttpMessageHandler handler) + => new(new ChromaConfigurationOptions("http://localhost:8000"), new HttpClient(handler)); + + sealed record Request(string Method, string Path, JsonElement Body); + + sealed class FakeServer(Func answer) : HttpMessageHandler + { + public List Requests { get; } = []; + + protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + var text = request.Content is null ? null : await request.Content.ReadAsStringAsync(cancellationToken); + var recorded = new Request(request.Method.Method, request.RequestUri!.AbsolutePath, text is { Length: > 0 } ? JsonDocument.Parse(text).RootElement.Clone() : default); + Requests.Add(recorded); + var (status, response) = answer(recorded); + return new HttpResponseMessage(status) { Content = new StringContent(response) }; + } + } +} diff --git a/ChromaDB.Client.Tests/Common/PropertyChecks.cs b/ChromaDB.Client.Tests/Common/PropertyChecks.cs new file mode 100644 index 0000000..0c13cd1 --- /dev/null +++ b/ChromaDB.Client.Tests/Common/PropertyChecks.cs @@ -0,0 +1,34 @@ +using System.Globalization; +using FsCheck; +using FsCheck.Fluent; +using Microsoft.FSharp.Core; + +namespace ChromaDB.Client.Tests.Common; + +// Checks a property with FsCheck on random cases, new ones on each run. A failure prints the case and its seed, like +// "(10774104525617377468,14267461796616028217)", and the seed of the failing step, with its size: the environment variable +// FSCHECK_REPLAY with either runs the same cases again. The CI sets one, so that its runs check the same cases. +internal static class PropertyChecks +{ + public static void ForAll(Gen cases, Func property, int count) + => Check.One(Config.QuickThrowOnFailure.WithMaxTest(count).WithReplay(Replay()), Prop.ForAll(cases.ToArbitrary(), property)); + + public static void ForAll(Gen cases, Func property, int count) + => ForAll(cases, x => property(x).ToProperty(), count); + + static FSharpOption? Replay() + { + if (Environment.GetEnvironmentVariable("FSCHECK_REPLAY") is not { Length: > 0 } replay) + { + return FSharpOption.None; + } + var numbers = replay.Trim('(', ')', ' ').Split(',') + .Select(x => ulong.TryParse(x.Trim(), NumberStyles.None, CultureInfo.InvariantCulture, out var number) ? number : (ulong?)null).ToList(); + if (numbers.Count is not (2 or 3) || numbers.Contains(null) || numbers.Count == 3 && numbers[2] > int.MaxValue) + { + throw new ArgumentException($"FSCHECK_REPLAY is \"{replay}\": it takes a seed as FsCheck prints it, like \"(10774104525617377468,14267461796616028217)\", or with the size, like \"(10972319524849194178,79312946374890781,2)\"."); + } + var size = numbers.Count == 3 ? FSharpOption.Some((int)numbers[2]!.Value) : FSharpOption.None; + return FSharpOption.Some(new Replay(new Rnd(numbers[0]!.Value, numbers[1]!.Value), size)); + } +} diff --git a/ChromaDB.Client.Tests/ConcurrentWritesTests.cs b/ChromaDB.Client.Tests/ConcurrentWritesTests.cs new file mode 100644 index 0000000..5cb7060 --- /dev/null +++ b/ChromaDB.Client.Tests/ConcurrentWritesTests.cs @@ -0,0 +1,123 @@ +using ChromaDB.Client.Models; +using NUnit.Framework; + +namespace ChromaDB.Client.Tests; + +// Two writes of the same record at the same time leave the record of one of them: a write that deletes a key does not depend on a read +// that another writer can change before the write goes. +[TestFixture] +public class ConcurrentWritesTests : ChromaTestsBase +{ + static readonly ReadOnlyMemory Embedding = new([1f, 0f]); + + // The write of B lands after A started and before A writes: A writes last, so the record is the one A wrote, without the key that A + // deletes and that B added. + [TestCase(true)] + [TestCase(false)] + public async Task AWriteThatDeletesAKeyAfterAnotherThatAddsIt(bool upsert) + { + var options = BaseConfigurationOptions.WithMetadataValues(ChromaMetadataValues.Exact); + var b = new ChromaClient(options, HttpClient).GetCollectionClient(await new ChromaClient(options, HttpClient).CreateCollectionAsync($"collection{Random.Shared.Next()}")); + await b.AddAsync(new ChromaRecords(["r"]) { Embeddings = [Embedding], Metadatas = [new Dictionary { ["s"] = "0" }] }); + var hook = new BeforeTheWrite(() => b.UpsertAsync(new ChromaRecords(["r"]) { Embeddings = [Embedding], Metadatas = [new Dictionary { ["s"] = "B", ["x"] = 2L }] })); + using var httpClient = new HttpClient(hook); + var a = new ChromaCollectionClient(b.Collection, options, httpClient); + var deletion = new Dictionary { ["s"] = "A", ["x"] = null! }; + await (upsert + ? a.UpsertAsync(new ChromaRecords(["r"]) { Embeddings = [Embedding], Metadatas = [deletion] }) + : a.UpdateAsync(new ChromaRecords(["r"]) { Metadatas = [deletion] })); + Assert.That(hook.Ran, Is.True); + Assert.That((await b.GetAsync("r", include: ChromaGetInclude.Metadatas))!.Metadata, Is.EquivalentTo(new Dictionary { ["s"] = "A" })); + } + + // With a document copy key, a document deletion key or empty document deletions, the write of B, which gives the record a document, + // lands after A started and before A writes: A writes last, so the record is the one A wrote, without its document: null, or empty with + // empty document deletions. + [TestCase(true, "copy")] + [TestCase(false, "copy")] + [TestCase(true, "mark")] + [TestCase(false, "mark")] + [TestCase(true, "empty")] + [TestCase(false, "empty")] + public async Task AWriteThatDeletesTheDocumentAfterAnotherThatWritesOne(bool upsert, string way) + { + var options = BaseConfigurationOptions.WithMetadataValues(ChromaMetadataValues.Exact); + var collection = await new ChromaClient(options, HttpClient).CreateCollectionAsync($"collection{Random.Shared.Next()}"); + var b = Deleting(new ChromaCollectionClient(collection, options, HttpClient), way); + await b.AddAsync(new ChromaRecords(["r"]) { Embeddings = [Embedding], Metadatas = [new Dictionary { ["s"] = "0" }] }); + var hook = new BeforeTheWrite(() => b.UpsertAsync(new ChromaRecords(["r"]) { Embeddings = [Embedding], Metadatas = [new Dictionary { ["s"] = "B" }], Documents = ["B"] })); + using var httpClient = new HttpClient(hook); + var a = Deleting(new ChromaCollectionClient(collection, options, httpClient), way); + var deletion = new ChromaRecords(["r"]) + { + Embeddings = upsert ? [Embedding] : null, + Metadatas = [new Dictionary { ["s"] = "A" }], + Documents = [null!], + NullDocumentsDelete = true, + }; + await (upsert ? a.UpsertAsync(deletion) : a.UpdateAsync(deletion)); + Assert.That(hook.Ran, Is.True); + var record = (await b.GetAsync("r", include: ChromaGetInclude.Metadatas | ChromaGetInclude.Documents))!; + var metadata = new Dictionary { ["s"] = "A" }; + if (way == "mark") + { + metadata["deleted"] = true; + } + Assert.That(record.Metadata, Is.EquivalentTo(metadata)); + Assert.That(record.Document, Is.EqualTo(way == "empty" ? "" : null)); + } + + // A client with a way to delete documents: a copy of each document, a mark, or empty documents. + static ChromaCollectionClient Deleting(ChromaCollectionClient client, string way) + => way switch + { + "copy" => client.WithDocumentCopyKey("text"), + "mark" => client.WithDocumentDeletionKey("deleted"), + _ => client.WithEmptyDocumentDeletions(), + }; + + // Writes of the same record at the same time, half of them deleting the key that the others add: the record left is one of the two. + // It does not catch a client that reads before it writes every time: the read and the write of a task fall around another write only + // in some rounds. On Chroma 1.5.9, with such a client, 20 rounds failed in 3 runs out of 10, and 100 rounds in 10 out of 10, at 35 + // seconds a run. The tests with BeforeTheWrite above put the other write between the two every time. + [Test] + public async Task ConcurrentWritesLeaveOneOfThem() + { + var options = BaseConfigurationOptions.WithMetadataValues(ChromaMetadataValues.Exact); + var client = new ChromaClient(options, HttpClient); + var collection = client.GetCollectionClient(await client.CreateCollectionAsync($"collection{Random.Shared.Next()}")); + var mixed = new List(); + for (var round = 0; round < 20; round++) + { + await collection.UpsertAsync(new ChromaRecords(["r"]) { Embeddings = [Embedding], Metadatas = [new Dictionary { ["s"] = "0" }] }); + await Task.WhenAll(Enumerable.Range(0, 8).Select(i => Task.Run(() => collection.UpsertAsync(new ChromaRecords(["r"]) + { + Embeddings = [Embedding], + Metadatas = [i % 2 == 0 ? new Dictionary { ["s"] = "A", ["x"] = null! } : new Dictionary { ["s"] = "B", ["x"] = 2L }], + })))); + var metadata = (await collection.GetAsync("r", include: ChromaGetInclude.Metadatas))!.Metadata!; + if (!(metadata["s"] is "A" && !metadata.ContainsKey("x") || metadata["s"] is "B" && metadata.TryGetValue("x", out var x) && x is 2L)) + { + mixed.Add($"round {round}: {string.Join(", ", metadata.Select(pair => $"{pair.Key}={pair.Value}"))}"); + } + } + Assert.That(mixed, Is.Empty); + } + + // Runs the other write once, just before the first write of the client goes: after the read that the client may do first. + sealed class BeforeTheWrite(Func otherWrite) : DelegatingHandler(new HttpClientHandler()) + { + public bool Ran { get; private set; } + + protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + var path = request.RequestUri!.AbsolutePath; + if (!Ran && (path.EndsWith("/upsert", StringComparison.Ordinal) || path.EndsWith("/update", StringComparison.Ordinal))) + { + Ran = true; + await otherWrite(); + } + return await base.SendAsync(request, cancellationToken); + } + } +} diff --git a/ChromaDB.Client.Tests/ConfigurationUriTests.cs b/ChromaDB.Client.Tests/ConfigurationUriTests.cs new file mode 100644 index 0000000..a895e2d --- /dev/null +++ b/ChromaDB.Client.Tests/ConfigurationUriTests.cs @@ -0,0 +1,42 @@ +using System.Net; +using NUnit.Framework; + +namespace ChromaDB.Client.Tests; + +[TestFixture] +public class ConfigurationUriTests +{ + [TestCase("http://localhost:8000/api/v2/", "http://localhost:8000/api/v2/heartbeat")] + [TestCase("http://localhost:8000/api/v2", "http://localhost:8000/api/v2/heartbeat")] + [TestCase("http://localhost:8000", "http://localhost:8000/api/v2/heartbeat")] + [TestCase("http://localhost:8000/", "http://localhost:8000/api/v2/heartbeat")] + [TestCase("http://localhost:8000/chroma/api/v2", "http://localhost:8000/chroma/api/v2/heartbeat")] + [TestCase("http://localhost:8000/my%20chroma/api/v2", "http://localhost:8000/my%20chroma/api/v2/heartbeat")] + [TestCase("https://chroma.example.com", "https://chroma.example.com/api/v2/heartbeat")] + public async Task RequestUri(string uri, string expected) + { + var handler = new RecordingHandler(); + using var httpClient = new HttpClient(handler); + var client = new ChromaClient(new ChromaConfigurationOptions(uri: uri), httpClient); + await client.HeartbeatAsync(); + Assert.That(handler.RequestUri?.AbsoluteUri, Is.EqualTo(expected)); + } + + [TestCase("https://api.trychroma.com", true)] + [TestCase("https://API.TRYCHROMA.COM/api/v2", true)] + [TestCase("http://localhost:8000", false)] + [TestCase("https://trychroma.com.example.com", false)] + public void IsChromaCloud(string uri, bool chromaCloud) + => Assert.That(new ChromaConfigurationOptions(uri).IsChromaCloud, Is.EqualTo(chromaCloud)); + + sealed class RecordingHandler : HttpMessageHandler + { + public Uri? RequestUri { get; private set; } + + protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + RequestUri = request.RequestUri; + return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent("""{"nanosecond heartbeat":1}""") }); + } + } +} diff --git a/ChromaDB.Client.Tests/ConnectionStringTests.cs b/ChromaDB.Client.Tests/ConnectionStringTests.cs new file mode 100644 index 0000000..a339f42 --- /dev/null +++ b/ChromaDB.Client.Tests/ConnectionStringTests.cs @@ -0,0 +1,63 @@ +using NUnit.Framework; + +namespace ChromaDB.Client.Tests; + +// ChromaConfigurationOptions.FromConnectionString, for the connection strings of the settings of an application. +[TestFixture] +public class ConnectionStringTests +{ + [Test] + public void ChromaCloud() + { + var options = ChromaConfigurationOptions.FromConnectionString("Endpoint=https://api.trychroma.com;Token=ck-123;Tenant=t1;Database=d1"); + Assert.That((options.Uri, options.ChromaToken, options.ChromaTokenTransportHeader, options.Tenant, options.Database), + Is.EqualTo((new Uri("https://api.trychroma.com"), "ck-123", ChromaTokenTransportHeader.XChromaToken, "t1", "d1"))); + } + + // Just a URI, as a connection string of a container; or the endpoint alone. + [TestCase("http://localhost:8000")] + [TestCase("Endpoint=http://localhost:8000")] + [TestCase("endpoint=http://localhost:8000;token=;TENANT=")] + public void EndpointAlone(string connectionString) + { + var options = ChromaConfigurationOptions.FromConnectionString(connectionString); + Assert.That((options.Uri, options.ChromaToken, options.Tenant, options.Database), Is.EqualTo((new Uri("http://localhost:8000"), (string?)null, (string?)null, (string?)null))); + } + + // The other options keep their defaults. + [Test] + public void DefaultsOfTheOtherOptions() + { + var options = ChromaConfigurationOptions.FromConnectionString("Endpoint=http://localhost:8000"); + var defaults = new ChromaConfigurationOptions(); + Assert.That((options.MetadataValues, options.BatchSplitting, options.ApiVersion), Is.EqualTo((defaults.MetadataValues, defaults.BatchSplitting, defaults.ApiVersion))); + } + + [Test] + public void ValueInQuotes() + { + Assert.That(ChromaConfigurationOptions.FromConnectionString("Endpoint=http://localhost:8000;Token=\"a;b\"").ChromaToken, Is.EqualTo("a;b")); + } + + [TestCase("Token=ck-123")] + [TestCase("Endpoint=;Token=ck-123")] + [TestCase("Endpoint=localhost")] + [TestCase("Endpoint=http://localhost:8000;ApiKey=ck-123")] + public void Rejected(string connectionString) + { + Assert.That(() => ChromaConfigurationOptions.FromConnectionString(connectionString), Throws.ArgumentException); + } + + // No endpoint, and an endpoint that is not an http or https URI, have each their message; neither repeats the endpoint or the token, + // which may hold a password, anywhere in the message. + [TestCase("Token=ck-123", "The connection string needs Endpoint, the URI of the server.")] + [TestCase("Endpoint=;Token=ck-123", "The connection string needs Endpoint, the URI of the server.")] + [TestCase("Endpoint=localhost:8000;Token=x", "The Endpoint of the connection string is not an http or https URI: it takes the URI of the server, like http://localhost:8000.")] + [TestCase("Endpoint=localhost", "The Endpoint of the connection string is not an http or https URI: it takes the URI of the server, like http://localhost:8000.")] + [TestCase("Endpoint=ftp://user:secret@host", "The Endpoint of the connection string is not an http or https URI: it takes the URI of the server, like http://localhost:8000.")] + [TestCase("Endpoint=user:secret@localhost:8000;Token=ck-123", "The Endpoint of the connection string is not an http or https URI: it takes the URI of the server, like http://localhost:8000.")] + public void EndpointMessages(string connectionString, string message) + { + Assert.That(() => ChromaConfigurationOptions.FromConnectionString(connectionString), Throws.ArgumentException.With.Message.StartsWith(message).And.Message.Not.Contains("secret").And.Message.Not.Contains("ck-123")); + } +} diff --git a/ChromaDB.Client.Tests/CreateIfMissingServerTests.cs b/ChromaDB.Client.Tests/CreateIfMissingServerTests.cs new file mode 100644 index 0000000..99c7adf --- /dev/null +++ b/ChromaDB.Client.Tests/CreateIfMissingServerTests.cs @@ -0,0 +1,37 @@ +using NUnit.Framework; + +namespace ChromaDB.Client.Tests; + +// GetCollectionClient(name, createIfMissing: true) and GetOrCreateCollectionClientAsync on the server. +[TestFixture] +public class CreateIfMissingServerTests : ChromaTestsBase +{ + // The first write creates the collection; deleted elsewhere, it is created again, empty, and the write goes to it. + [Test] + public async Task CreatedAtTheFirstWriteAndAgainAfterADeletion() + { + var client = new ChromaClient(BaseConfigurationOptions, HttpClient); + var name = $"create{Guid.NewGuid():N}"; + try + { + var records = client.GetCollectionClient(name, createIfMissing: true); + Assert.That(await client.CollectionExistsAsync(name), Is.False); + await records.AddAsync(["a"], [new([1f, 0f])]); + Assert.That(await records.CountAsync(), Is.EqualTo(1)); + var first = records.Collection.Id; + await client.DeleteCollectionAsync(name); + await records.AddAsync(["b"], [new([0f, 1f])]); + Assert.That(records.Collection.Id, Is.Not.EqualTo(first)); + Assert.That((await records.GetAsync()).Select(x => x.Id), Is.EqualTo(new[] { "b" })); + var now = await client.GetOrCreateCollectionClientAsync(name); + Assert.That(now.Collection.Id, Is.EqualTo(records.Collection.Id)); + } + finally + { + if (await client.CollectionExistsAsync(name)) + { + await client.DeleteCollectionAsync(name); + } + } + } +} diff --git a/ChromaDB.Client.Tests/CreatedOnTheServer.cs b/ChromaDB.Client.Tests/CreatedOnTheServer.cs new file mode 100644 index 0000000..9b1b4d9 --- /dev/null +++ b/ChromaDB.Client.Tests/CreatedOnTheServer.cs @@ -0,0 +1,63 @@ +using System.Collections.Concurrent; +using System.Text.Json; + +namespace ChromaDB.Client.Tests; + +// What the requests of a fixture created on a server already running: the collections by their id, which stays the same +// when a test renames them, and the databases. The fixture deletes these at its end and nothing else: other clients may +// be creating their own collections in the same database. +sealed class CreatedOnTheServer +{ + public ConcurrentDictionary Collections { get; } = new(); + public ConcurrentDictionary<(string? Tenant, string Name), bool> Databases { get; } = new(); + + public HttpClient NewHttpClient() => new(new Recorder(this)); + + sealed class Recorder(CreatedOnTheServer created) : DelegatingHandler(new HttpClientHandler()) + { + protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + var uri = request.RequestUri!; + var path = uri.AbsolutePath.TrimEnd('/'); + var post = request.Method == HttpMethod.Post; + string? database = null; + if (post && path.EndsWith("/databases", StringComparison.Ordinal) && request.Content is not null) + { + using var body = JsonDocument.Parse(await request.Content.ReadAsStringAsync(cancellationToken)); + database = body.RootElement.GetProperty("name").GetString(); + } + var response = await base.SendAsync(request, cancellationToken); + if (!response.IsSuccessStatusCode) + { + return response; + } + if (database is not null) + { + created.Databases[(Part(uri, "tenants", "tenant"), database)] = true; + } + // A fork, on Chroma Cloud, creates a collection too. + else if (post && (path.EndsWith("/collections", StringComparison.Ordinal) || path.EndsWith("/fork", StringComparison.Ordinal))) + { + using var body = JsonDocument.Parse(await response.Content.ReadAsStringAsync(cancellationToken)); + created.Collections[body.RootElement.GetProperty("id").GetGuid()] = (Part(uri, "tenants", "tenant"), Part(uri, "databases", "database")); + } + return response; + } + + // The tenant or the database of a request: in the path of the v2 API, in the query of the v1 API, null for the default one. + static string? Part(Uri uri, string segment, string parameter) + { + var segments = uri.AbsolutePath.Split('/'); + var index = Array.IndexOf(segments, segment); + if (index >= 0 && index + 1 < segments.Length && segments[index + 1].Length > 0) + { + return Uri.UnescapeDataString(segments[index + 1]); + } + return uri.Query.TrimStart('?').Split('&') + .Select(x => x.Split('=', 2)) + .Where(x => x.Length == 2 && x[0] == parameter) + .Select(x => Uri.UnescapeDataString(x[1])) + .FirstOrDefault(); + } + } +} diff --git a/ChromaDB.Client.Tests/DefinitionChecksTests.cs b/ChromaDB.Client.Tests/DefinitionChecksTests.cs new file mode 100644 index 0000000..48e1f7e --- /dev/null +++ b/ChromaDB.Client.Tests/DefinitionChecksTests.cs @@ -0,0 +1,143 @@ +using System.Net; +using ChromaDB.Client.Models; +using NUnit.Framework; + +namespace ChromaDB.Client.Tests; + +// What a collection definition cannot get: the space it asks for, from a collection that exists with another one, and on Chroma Cloud +// a key of the schema beyond its quota of bytes, which it takes and then rejects on every write. +[TestFixture] +public class DefinitionChecksTests +{ + static readonly ChromaCollectionDefinition Cosine = new("c") { Configuration = new() { Space = ChromaSpace.Cosine } }; + + [Test] + public async Task ACollectionThatExistsWithAnotherSpace() + { + var server = new VersionedServer("1.0.0", """{"id":"11111111-2222-3333-4444-555555555555","name":"c","metadata":{"hnsw:space":"l2"}}"""); + await Assert.ThatAsync(() => Client(server).GetOrCreateCollectionAsync(Cosine), + Throws.InstanceOf().With.Message.EqualTo("The collection has the space l2, not cosine: a collection that exists keeps its space.")); + } + + [Test] + public async Task ACollectionWithTheSpaceOrWithoutOneReported() + { + var server = new VersionedServer("1.0.0", """{"id":"11111111-2222-3333-4444-555555555555","name":"c","metadata":{"hnsw:space":"cosine"}}"""); + Assert.That((await Client(server).GetOrCreateCollectionAsync(Cosine)).Space, Is.EqualTo(ChromaSpace.Cosine)); + server = new VersionedServer("1.0.0", """{"id":"11111111-2222-3333-4444-555555555555","name":"c"}"""); + Assert.That((await Client(server).GetOrCreateCollectionAsync(Cosine)).Space, Is.Null); + } + + // Chroma 0.x keeps the space only in the metadata, and a collection created without it uses l2: the client reads it as L2 there, + // and as unknown on Chroma 1.x, whose configuration may hold another space that 1.0.0 to 1.0.5 do not report. + [Test] + public async Task ACollectionWithoutASpaceOnChroma0IsL2() + { + var server = new VersionedServer("0.6.3", """{"id":"11111111-2222-3333-4444-555555555555","name":"c"}"""); + Assert.That((await Client(server).GetCollectionAsync("c")).Space, Is.EqualTo(ChromaSpace.L2)); + await Assert.ThatAsync(() => Client(server).GetOrCreateCollectionAsync(Cosine), Throws.InstanceOf().With.Message.Contains("space l2, not cosine")); + server = new VersionedServer("1.0.0", """{"id":"11111111-2222-3333-4444-555555555555","name":"c"}"""); + Assert.That((await Client(server).GetCollectionAsync("c")).Space, Is.Null); + } + + // Chroma 0.4 writes the space of the definition into the metadata of a collection that exists, whose index keeps its own: on the + // 0.x servers the client reads the collection first, and sends no get or create when it has another space. + [Test] + public async Task OnChroma0TheCollectionIsReadFirst() + { + var server = new VersionedServer("0.6.3", """{"id":"11111111-2222-3333-4444-555555555555","name":"c","metadata":{"hnsw:space":"l2"}}"""); + await Assert.ThatAsync(() => Client(server).GetOrCreateCollectionAsync(Cosine), Throws.InstanceOf().With.Message.Contains("not cosine")); + Assert.That(server.Requests, Is.EqualTo(new[] { "GET version", "GET c" })); + + server = new VersionedServer("0.6.3", null); + await Client(server).GetOrCreateCollectionAsync(Cosine); + Assert.That(server.Requests, Is.EqualTo(new[] { "GET version", "GET c", "POST collections" })); + + server = new VersionedServer("0.6.3", """{"id":"11111111-2222-3333-4444-555555555555","name":"c","metadata":{"hnsw:space":"cosine"}}"""); + await Client(server).GetOrCreateCollectionAsync(Cosine); + Assert.That(server.Requests, Is.EqualTo(new[] { "GET version", "GET c", "POST collections" })); + } + + // Chroma 1.x keeps the metadata, so the answer tells: the client reads nothing first, and nothing for a definition without a space. + [Test] + public async Task OnChroma1OrWithoutASpaceNothingIsReadFirst() + { + var server = new VersionedServer("1.0.0", """{"id":"11111111-2222-3333-4444-555555555555","name":"c","metadata":{"hnsw:space":"cosine"}}"""); + await Client(server).GetOrCreateCollectionAsync(Cosine); + await Client(server).GetOrCreateCollectionAsync(new ChromaCollectionDefinition("c")); + Assert.That(server.Requests, Is.EqualTo(new[] { "GET version", "POST collections", "POST collections" })); + } + + [TestCase(36, "https://api.trychroma.com", true)] + [TestCase(37, "https://api.trychroma.com", false)] + [TestCase(37, "http://localhost:8000", true)] + public async Task KeysOfTheSchemaOnChromaCloud(int bytes, string uri, bool sent) + { + var key = new string('k', bytes); + var definition = new ChromaCollectionDefinition("c") { Schema = new ChromaCollectionSchema().WithIndex(ChromaSchemaIndex.StringInverted, key) }; + var server = new FakeServer("""{"id":"11111111-2222-3333-4444-555555555555","name":"c","schema":{"defaults":{},"keys":{}}}"""); + var client = Client(server, uri); + if (!sent) + { + Assert.That(() => client.CreateCollectionAsync(definition), Throws.ArgumentException.With.Message.Contains("more than 36 bytes")); + Assert.That(() => client.GetOrCreateCollectionAsync(definition), Throws.ArgumentException); + Assert.That(server.Requests, Is.Empty); + return; + } + await client.GetOrCreateCollectionAsync(definition); + Assert.That(server.Requests, Is.EqualTo(new[] { "POST" })); + } + + // A definition without a schema has no keys to check. + [Test] + public async Task NoSchemaOnChromaCloud() + { + var server = new FakeServer("""{"id":"11111111-2222-3333-4444-555555555555","name":"c"}"""); + await Client(server, "https://api.trychroma.com").CreateCollectionAsync(new ChromaCollectionDefinition("c")); + Assert.That(server.Requests, Is.EqualTo(new[] { "POST" })); + } + + // A key counts in bytes of UTF-8: 19 characters of two bytes each are 38 bytes. + [Test] + public void BytesNotCharacters() + { + var definition = new ChromaCollectionDefinition("c") { Schema = new ChromaCollectionSchema().WithIndex(ChromaSchemaIndex.StringInverted, new string('é', 19)) }; + Assert.That(() => Client(new FakeServer("{}"), "https://api.trychroma.com").CreateCollectionAsync(definition), Throws.ArgumentException); + } + + static ChromaClient Client(HttpMessageHandler handler, string uri = "http://localhost:8000") + => new(new ChromaConfigurationOptions(uri), new HttpClient(handler)); + + // Answers the version, the collection c, or 404 when it is null, and the get or create with it; records the requests. + sealed class VersionedServer(string version, string? collection) : HttpMessageHandler + { + public List Requests { get; } = []; + + protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + var path = request.RequestUri!.AbsolutePath.Split('/').Last(); + Requests.Add($"{request.Method.Method} {path}"); + var (status, body) = path == "version" ? (HttpStatusCode.OK, "\"" + version + "\"") + : collection is null && request.Method == HttpMethod.Get ? (HttpStatusCode.NotFound, """{"error":"NotFoundError","message":"Collection [c] does not exist"}""") + : (HttpStatusCode.OK, collection ?? """{"id":"11111111-2222-3333-4444-555555555555","name":"c","metadata":{"hnsw:space":"cosine"}}"""); + return Task.FromResult(new HttpResponseMessage(status) { Content = new StringContent(body) }); + } + } + + // Answers every request with the collection; records the methods. + sealed class FakeServer(string collection) : HttpMessageHandler + { + public List Requests { get; } = []; + + protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + // The version, which the client asks for a collection without a space: a 1.x server, not recorded. + if (request.RequestUri!.AbsolutePath.EndsWith("/version")) + { + return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent("\"1.0.0\"") }); + } + Requests.Add(request.Method.Method); + return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent(collection) }); + } + } +} diff --git a/ChromaDB.Client.Tests/DeleteCollectionIfExistsTests.cs b/ChromaDB.Client.Tests/DeleteCollectionIfExistsTests.cs new file mode 100644 index 0000000..a15c743 --- /dev/null +++ b/ChromaDB.Client.Tests/DeleteCollectionIfExistsTests.cs @@ -0,0 +1,34 @@ +using System.Net; +using NUnit.Framework; + +namespace ChromaDB.Client.Tests; + +// A missing collection, in the answer of each kind of server, is not an error; any other error is. +[TestFixture] +public class DeleteCollectionIfExistsTests +{ + [TestCase(HttpStatusCode.OK, "{}", true)] + [TestCase(HttpStatusCode.NotFound, """{"error":"NotFoundError","message":"Collection [c] does not exist"}""", false)] + [TestCase(HttpStatusCode.BadRequest, """{"error":"InvalidCollection","message":"Collection c does not exist."}""", false)] + [TestCase(HttpStatusCode.InternalServerError, """{"detail":"Collection c does not exist."}""", false)] + public async Task TellsWhetherItDeleted(HttpStatusCode status, string body, bool deleted) + => Assert.That(await Client(status, body).DeleteCollectionIfExistsAsync("c"), Is.EqualTo(deleted)); + + // A bare 404 comes also from a wrong address. + [TestCase(HttpStatusCode.NotFound, "")] + [TestCase(HttpStatusCode.InternalServerError, """{"error":"InternalError","message":"Error executing plan"}""")] + public async Task OtherErrorsAreThrown(HttpStatusCode status, string body) + => await Assert.ThatAsync(() => Client(status, body).DeleteCollectionIfExistsAsync("c"), Throws.InstanceOf().With.Property(nameof(ChromaException.StatusCode)).EqualTo(status)); + + // A 0.x version, so that the delete is the only other request. + static ChromaClient Client(HttpStatusCode status, string body) + => new(new ChromaConfigurationOptions("http://localhost:8000"), new HttpClient(new FakeServer(status, body))); + + sealed class FakeServer(HttpStatusCode status, string body) : HttpMessageHandler + { + protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + => Task.FromResult(request.RequestUri!.AbsolutePath.EndsWith("/version") + ? new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent("\"0.6.3\"") } + : new HttpResponseMessage(status) { Content = new StringContent(body) }); + } +} diff --git a/ChromaDB.Client.Tests/DeleteRecordsFirstTests.cs b/ChromaDB.Client.Tests/DeleteRecordsFirstTests.cs new file mode 100644 index 0000000..cde6c66 --- /dev/null +++ b/ChromaDB.Client.Tests/DeleteRecordsFirstTests.cs @@ -0,0 +1,308 @@ +using System.Net; +using System.Text.Json; +using ChromaDB.Client.Models; +using NUnit.Framework; + +namespace ChromaDB.Client.Tests; + +// Chroma 1.5 keeps the lists in the metadata of the records of a deleted collection or database, and gives them to the next records +// it stores: with deleteRecordsFirst, on Chroma 1.x the client deletes the records first, a page of the batch size at a time. +[TestFixture] +public class DeleteRecordsFirstTests +{ + const string Id = "11111111-2222-3333-4444-555555555555"; + const string OtherId = "66666666-7777-8888-9999-000000000000"; + + // By default each deletion is one request, as in Chroma. + [Test] + public async Task OneRequestByDefault() + { + var server = new FakeServer("1.0.0", "{}"); + var client = Client(server); + await client.DeleteCollectionAsync("c"); + await client.DeleteCollectionIfExistsAsync("c"); + await client.DeleteDatabaseAsync("d"); + Assert.That(server.Requests.Select(x => x.Request), Is.EqualTo(new[] + { + "DELETE tenants/default_tenant/databases/default_database/collections/c", + "DELETE tenants/default_tenant/databases/default_database/collections/c", + "DELETE tenants/default_tenant/databases/d", + })); + } + + [Test] + public async Task DeleteCollectionDeletesTheRecordsFirst() + { + var server = new FakeServer("1.0.0", $$"""{"id":"{{Id}}","name":"c","tenant":"t2","database":"d2"}""", """["a","b"]""", """["c"]""", "[]"); + await Client(server).DeleteCollectionAsync("c", tenant: "t2", database: "d2", deleteRecordsFirst: true); + var records = $"tenants/t2/databases/d2/collections/{Id}"; + Assert.That(server.Requests.Select(x => x.Request), Is.EqualTo(new[] + { + "GET version", + "GET tenants/t2/databases/d2/collections/c", + "GET pre-flight-checks", + $"POST {records}/get", + $"POST {records}/delete", + $"POST {records}/get", + $"POST {records}/delete", + $"POST {records}/get", + "DELETE tenants/t2/databases/d2/collections/c", + })); + var get = server.Requests[3].Body; + Assert.That(get.GetProperty("limit").GetInt32(), Is.EqualTo(2)); + Assert.That(get.GetProperty("include").EnumerateArray(), Is.Empty); + Assert.That(Ids(server.Requests[4].Body), Is.EqualTo(new[] { "a", "b" })); + Assert.That(Ids(server.Requests[6].Body), Is.EqualTo(new[] { "c" })); + } + + [Test] + public async Task DeleteCollectionOnChroma0() + { + var server = new FakeServer("0.6.3", "{}"); + await Client(server).DeleteCollectionAsync("c", deleteRecordsFirst: true); + Assert.That(server.Requests.Select(x => x.Request), Is.EqualTo(new[] { "GET version", "DELETE tenants/default_tenant/databases/default_database/collections/c" })); + } + + [Test] + public async Task DeleteCollectionOnChromaCloud() + { + var server = new FakeServer("1.0.0", "{}"); + await Client(server, "https://api.trychroma.com").DeleteCollectionAsync("c", deleteRecordsFirst: true); + Assert.That(server.Requests.Select(x => x.Request), Is.EqualTo(new[] { "DELETE tenants/default_tenant/databases/default_database/collections/c" })); + } + + // A page that comes back after its delete would come back forever: the client throws, and the collection stays with its records. + [Test] + public async Task RecordsThatStayStopTheDeletes() + { + var server = new FakeServer("1.0.0", $$"""{"id":"{{Id}}","name":"c"}""", """["a"]""", """["a"]"""); + await Assert.ThatAsync(() => Client(server).DeleteCollectionAsync("c", deleteRecordsFirst: true), Throws.InstanceOf().With.Message.Contains("not deleted")); + var records = $"tenants/default_tenant/databases/default_database/collections/{Id}"; + Assert.That(server.Requests.Select(x => x.Request), Is.EqualTo(new[] + { + "GET version", + "GET tenants/default_tenant/databases/default_database/collections/c", + "GET pre-flight-checks", + $"POST {records}/get", + $"POST {records}/delete", + $"POST {records}/get", + })); + } + + [Test] + public async Task DeleteDatabaseDeletesTheRecordsOfEachCollection() + { + var server = new FakeServer("1.0.0", $$"""[{"id":"{{Id}}","name":"x","tenant":"t","database":"d"},{"id":"{{OtherId}}","name":"y","tenant":"t","database":"d"}]""", + """["a"]""", "[]", """["b"]""", "[]"); + await Client(server).DeleteDatabaseAsync("d", tenant: "t", deleteRecordsFirst: true); + Assert.That(server.Requests.Select(x => x.Request), Is.EqualTo(new[] + { + "GET version", + "GET tenants/t/databases/d/collections", + "GET pre-flight-checks", + $"POST tenants/t/databases/d/collections/{Id}/get", + $"POST tenants/t/databases/d/collections/{Id}/delete", + $"POST tenants/t/databases/d/collections/{Id}/get", + $"POST tenants/t/databases/d/collections/{OtherId}/get", + $"POST tenants/t/databases/d/collections/{OtherId}/delete", + $"POST tenants/t/databases/d/collections/{OtherId}/get", + "DELETE tenants/t/databases/d", + })); + } + + [Test] + public async Task DeleteDatabaseOnChroma0() + { + var server = new FakeServer("0.6.3", "{}"); + await Client(server).DeleteDatabaseAsync("d", deleteRecordsFirst: true); + Assert.That(server.Requests.Select(x => x.Request), Is.EqualTo(new[] { "GET version", "DELETE tenants/default_tenant/databases/d" })); + } + + // A collection client made by name whose collection is created again under the name after the first page: the error says how many + // records went, and no delete reaches the new collection. + [Test] + public async Task DeleteAllStopsOnACollectionCreatedAgain() + { + var server = new RecreatedServer(); + var collectionClient = Client(server).GetCollectionClient("c"); + await collectionClient.CountAsync(); + await Assert.ThatAsync(() => collectionClient.DeleteAllAsync(), Throws.InstanceOf().With.Message.StartsWith("2 records were deleted before the error")); + Assert.That(server.Requests.Where(x => x.EndsWith("/delete")), Is.EqualTo(new[] { $"POST tenants/default_tenant/databases/default_database/collections/{Id}/delete" })); + } + + // Chroma Cloud rejects a get with a limit beyond its quota of 300, "'Limit value'": a client with WithBatchSplitting(1000) learns it and + // reads that page again, in GetAsync and in DeleteAllAsync, and no delete goes twice. + [Test] + public async Task TheQuotaOfTheReads() + { + var server = new QuotaServer(350); + var client = new ChromaCollectionClient(new ChromaCollection("c") { Id = Guid.Parse(Id) }, new ChromaConfigurationOptions("https://api.trychroma.com").WithBatchSplitting(1000), new HttpClient(server)); + Assert.That((await client.GetAsync(include: ChromaGetInclude.None)).Count, Is.EqualTo(350)); + Assert.That(server.Limits, Is.EqualTo(new[] { 1000, 300, 300 })); + + var fresh = new ChromaCollectionClient(new ChromaCollection("c") { Id = Guid.Parse(Id) }, new ChromaConfigurationOptions("https://api.trychroma.com").WithBatchSplitting(1000), new HttpClient(server)); + server.Limits.Clear(); + Assert.That(await fresh.DeleteAllAsync(), Is.EqualTo(350)); + Assert.That(server.Limits, Is.EqualTo(new[] { 1000, 300, 300, 300 })); + Assert.That(server.Deleted, Is.EqualTo(Enumerable.Range(0, 350).Select(i => $"r{i}"))); + } + + // Chroma Cloud rejects a get of more ids than its quota: with a limit beyond it, "'Limit value'", and without a limit, "'Number of + // records'". A client with WithBatchSplitting(1000) learns it and reads the ids in batches. + [TestCase(350)] + [TestCase(null)] + public async Task TheQuotaOfAReadByIds(int? limit) + { + var server = new QuotaServer(350); + var client = new ChromaCollectionClient(new ChromaCollection("c") { Id = Guid.Parse(Id) }, new ChromaConfigurationOptions("https://api.trychroma.com").WithBatchSplitting(1000), new HttpClient(server)); + var ids = Enumerable.Range(0, 350).Select(i => $"r{i}").ToList(); + Assert.That((await client.GetAsync(ids, limit: limit, include: ChromaGetInclude.None)).Select(x => x.Id), Is.EqualTo(ids)); + Assert.That(server.IdCounts, Is.EqualTo(new[] { 350, 300, 50 })); + } + + // More ids than the batch size go in batches; the first, beyond the quota of Chroma Cloud, gets 422: the client learns the quota and + // reads that batch again, smaller, keeping the batches read. + [Test] + public async Task TheQuotaOfAReadByIdsInBatches() + { + var server = new QuotaServer(1001); + var client = new ChromaCollectionClient(new ChromaCollection("c") { Id = Guid.Parse(Id) }, new ChromaConfigurationOptions("https://api.trychroma.com").WithBatchSplitting(1000), new HttpClient(server)); + var ids = Enumerable.Range(0, 1001).Select(i => $"r{i}").ToList(); + Assert.That((await client.GetAsync(ids, include: ChromaGetInclude.None)).Select(x => x.Id), Is.EqualTo(ids)); + Assert.That(server.IdCounts, Is.EqualTo(new[] { 1000, 300, 300, 300, 101 })); + } + + // A tenant of Chroma Cloud whose quota of the reads is higher than the one of the deletes: the delete of a page beyond it gets 422 and + // deletes nothing, and the client reads a smaller page. Every record goes, each id once. + [Test] + public async Task TheQuotaOfTheDeletes() + { + var server = new QuotaServer(350, readQuota: 1000, deleteQuota: 300); + var client = new ChromaCollectionClient(new ChromaCollection("c") { Id = Guid.Parse(Id) }, new ChromaConfigurationOptions("https://api.trychroma.com").WithBatchSplitting(1000), new HttpClient(server)); + Assert.That(await client.DeleteAllAsync(), Is.EqualTo(350)); + Assert.That(server.Limits, Is.EqualTo(new[] { 1000, 300, 300, 300 })); + Assert.That(server.Deleted, Is.EqualTo(Enumerable.Range(0, 350).Select(i => $"r{i}"))); + } + + // Holds the records r0 to r{count - 1}, answers a get with a limit beyond the quota of the reads, and a delete of more ids than the + // quota of the deletes, with the 422 of Chroma Cloud, and records the limits asked and the ids deleted. + sealed class QuotaServer(int count, int readQuota = 300, int deleteQuota = 300) : HttpMessageHandler + { + readonly List _ids = Enumerable.Range(0, count).Select(i => $"r{i}").ToList(); + + public List Limits { get; } = []; + public List IdCounts { get; } = []; + public List Deleted { get; } = []; + + protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + var path = request.RequestUri!.AbsolutePath; + var body = request.Content is null ? default : JsonDocument.Parse(await request.Content.ReadAsStringAsync(cancellationToken)).RootElement.Clone(); + if (path.EndsWith("/pre-flight-checks")) + { + return Answer(HttpStatusCode.OK, """{"max_batch_size":1000}"""); + } + if (path.EndsWith("/get")) + { + int? limit = body.TryGetProperty("limit", out var asked) && asked.ValueKind == JsonValueKind.Number ? asked.GetInt32() : null; + var offset = body.TryGetProperty("offset", out var start) && start.ValueKind == JsonValueKind.Number ? start.GetInt32() : 0; + var requested = body.TryGetProperty("ids", out var given) && given.ValueKind == JsonValueKind.Array ? given.EnumerateArray().Select(x => x.GetString()!).ToList() : null; + if (limit is { } value) + { + Limits.Add(value); + } + if (requested is not null) + { + IdCounts.Add(requested.Count); + } + if (limit > readQuota) + { + return Answer((HttpStatusCode)422, $$"""{"error":"ChromaError","message":"Quota exceeded: 'Limit value' exceeded quota limit for action 'Get': current usage of {{limit}} exceeds limit of {{readQuota}}. https://trychroma.com/request-quota-increase"}"""); + } + if (requested is not null && limit is null && requested.Count > readQuota) + { + return Answer((HttpStatusCode)422, $$"""{"error":"ChromaError","message":"Quota exceeded: 'Number of records' exceeded quota limit for action 'Get': current usage of {{requested.Count}} exceeds limit of {{readQuota}}. https://trychroma.com/request-quota-increase"}"""); + } + var found = requested is null ? _ids : _ids.Where(requested.Contains).ToList(); + return Answer(HttpStatusCode.OK, JsonSerializer.Serialize(new { ids = found.Skip(offset).Take(limit ?? int.MaxValue).ToList() })); + } + if (path.EndsWith("/delete")) + { + var ids = Ids(body).Select(x => x!).ToList(); + if (ids.Count > deleteQuota) + { + return Answer((HttpStatusCode)422, $$"""{"error":"ChromaError","message":"Quota exceeded: 'Number of records' exceeded quota limit for action 'Delete': current usage of {{ids.Count}} exceeds limit of {{deleteQuota}}. https://trychroma.com/request-quota-increase"}"""); + } + Deleted.AddRange(ids); + _ids.RemoveAll(ids.Contains); + return Answer(HttpStatusCode.OK, "{}"); + } + return Answer(HttpStatusCode.OK, "\"1.5.9\""); + } + + static HttpResponseMessage Answer(HttpStatusCode status, string body) => new(status) { Content = new StringContent(body) }; + } + + static ChromaClient Client(HttpMessageHandler handler, string uri = "http://localhost:8000") + => new(new ChromaConfigurationOptions(uri), new HttpClient(handler)); + + static IEnumerable Ids(JsonElement body) => body.GetProperty("ids").EnumerateArray().Select(x => x.GetString()); + + // Answers the version, the collection or the collections with found, pre-flight-checks with a batch size of 2, and each get with + // the next page of ids; records the requests, without the prefix of the API. + // The collection c has the id Id until its second page, which answers 404: from then on c is a new collection, with OtherId. + sealed class RecreatedServer : HttpMessageHandler + { + bool _recreated; + int _pages; + + public List Requests { get; } = []; + + protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + var path = request.RequestUri!.AbsolutePath.Substring("/api/v2/".Length); + Requests.Add($"{request.Method} {path}"); + var (status, answer) = path switch + { + "version" => (HttpStatusCode.OK, "\"1.0.0\""), + "pre-flight-checks" => (HttpStatusCode.OK, """{"max_batch_size":2}"""), + _ when path.EndsWith("/count") => (HttpStatusCode.OK, "4"), + _ when path.EndsWith("/delete") => (HttpStatusCode.OK, "{}"), + _ when path.EndsWith("/get") && path.Contains(OtherId) => (HttpStatusCode.OK, """{"ids":["x","y"]}"""), + _ when path.EndsWith("/get") && _pages++ == 0 => (HttpStatusCode.OK, """{"ids":["a","b"]}"""), + _ when path.EndsWith("/get") => Recreate(), + _ => (HttpStatusCode.OK, $$"""{"id":"{{(_recreated ? OtherId : Id)}}","name":"c"}"""), + }; + return Task.FromResult(new HttpResponseMessage(status) { Content = new StringContent(answer) }); + } + + (HttpStatusCode, string) Recreate() + { + _recreated = true; + return (HttpStatusCode.NotFound, """{"error":"NotFoundError","message":"Collection does not exist."}"""); + } + } + + sealed class FakeServer(string version, string found, params string[] pages) : HttpMessageHandler + { + int _page; + + public List<(string Request, JsonElement Body)> Requests { get; } = []; + + protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + var path = request.RequestUri!.AbsolutePath.Substring("/api/v2/".Length); + var body = request.Content is null ? default : JsonDocument.Parse(await request.Content.ReadAsStringAsync(cancellationToken)).RootElement.Clone(); + Requests.Add(($"{request.Method} {path}", body)); + var answer = path switch + { + "version" => $"\"{version}\"", + "pre-flight-checks" => """{"max_batch_size":2}""", + _ when path.EndsWith("/get") => $$"""{"ids":{{pages[_page++]}}}""", + _ when path.EndsWith("/delete") => "{}", + _ => found, + }; + return new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent(answer) }; + } + } +} diff --git a/ChromaDB.Client.Tests/DeletionsInWritesTests.cs b/ChromaDB.Client.Tests/DeletionsInWritesTests.cs new file mode 100644 index 0000000..804a84a --- /dev/null +++ b/ChromaDB.Client.Tests/DeletionsInWritesTests.cs @@ -0,0 +1,528 @@ +using System.Net; +using System.Text.Json; +using ChromaDB.Client.Models; +using NUnit.Framework; + +namespace ChromaDB.Client.Tests; + +// In an update or an upsert, a null value or an empty list deletes a key, a null document deletes the document with +// NullDocumentsDelete, and a text that goes takes its sparse vectors along. A null goes to every record, whether it has the key or not, +// without a read, and so does the empty document of a deletion; the client reads the records first only on Chroma Cloud beyond its quota +// of keys. The keys the metadata does not have stay. +[TestFixture] +public class DeletionsInWritesTests +{ + static readonly ReadOnlyMemory Embedding = new([1f, 0f]); + const string Bm25 = """{"type":"known","name":"chroma_bm25","config":{}}"""; + const string Schema = """{"defaults":{},"keys":{"doc_bm25":{"sparse_vector":{"sparse_vector_index":{"enabled":true,"config":{"embedding_function":""" + Bm25 + ""","source_key":"#document"}}}},"title_bm25":{"sparse_vector":{"sparse_vector_index":{"enabled":true,"config":{"embedding_function":""" + Bm25 + ""","source_key":"title"}}}}}}"""; + + // Every tested Chroma takes a null for a key the record does not have, also in an upsert that creates the record, and leaves the record + // without it: the write does not depend on a read that another writer could change before it goes. + [Test] + public async Task NullsGoWithoutARead() + { + var server = new FakeServer("""{"ids":["a","b"],"metadatas":[{"k":1,"gone":2},{"other":1}]}"""); + await Client(server).UpsertAsync(new ChromaRecords(["a", "b", "new"]) + { + Embeddings = [Embedding, Embedding, Embedding], + Metadatas = + [ + new Dictionary { ["k"] = null!, ["gone"] = new List(), ["missing"] = null!, ["x"] = 1 }, + new Dictionary { ["k"] = Array.Empty(), ["y"] = 2 }, + new Dictionary { ["k"] = null! }, + ], + }); + Assert.That(server.Paths, Is.EqualTo(new[] { "pre-flight-checks", "upsert" })); + var metadatas = server.Bodies[1].GetProperty("metadatas"); + Assert.That(metadatas[0].GetRawText(), Is.EqualTo("""{"x":1,"k":null,"gone":null,"missing":null}""")); + Assert.That(metadatas[1].GetRawText(), Is.EqualTo("""{"y":2,"k":null}""")); + Assert.That(metadatas[2].GetRawText(), Is.EqualTo("""{"k":null}""")); + } + + [Test] + public async Task UpdateToo() + { + var server = new FakeServer("""{"ids":["a"],"metadatas":[{"k":1}]}"""); + await Client(server).UpdateAsync(new ChromaRecords(["a"]) { Metadatas = [new Dictionary { ["k"] = null!, ["j"] = null! }] }); + Assert.That(server.Paths, Is.EqualTo(new[] { "pre-flight-checks", "update" })); + Assert.That(server.Bodies[1].GetProperty("metadatas")[0].GetRawText(), Is.EqualTo("""{"k":null,"j":null}""")); + } + + // When no record has anything to delete, the client sends the nulls of none and keeps the metadata of the caller. + [Test] + public async Task NothingToDeleteReadsNothing() + { + var server = new FakeServer("""{"ids":[]}"""); + await Client(server).UpsertAsync(new ChromaRecords(["a"]) { Embeddings = [Embedding], Metadatas = [new Dictionary { ["k"] = 1 }], Documents = [null!] }); + Assert.That(server.Paths, Is.EqualTo(new[] { "pre-flight-checks", "upsert" })); + Assert.That(server.Bodies[1].GetProperty("documents")[0].ValueKind, Is.EqualTo(JsonValueKind.Null)); + } + + // Chroma Cloud counts a null against its quota of 32 keys: when the values and the nulls would take the metadata of a record beyond it, + // the client throws before any request, with the record and its count of keys. A record within the quota needs no read. + [TestCase(true)] + [TestCase(false)] + public void BeyondTheQuotaOfKeysOfChromaCloud(bool upsert) + { + var server = new FakeServer("""{"ids":["a"],"metadatas":[{"k0":0,"gone":1}]}"""); + var many = Enumerable.Range(0, 31).ToDictionary(i => $"k{i}", i => (object)(long)i); + many["gone"] = null!; + many["missing"] = null!; + var records = new ChromaRecords(["a", "b"]) + { + Embeddings = upsert ? [Embedding, Embedding] : null, + Metadatas = [many, new Dictionary { ["k"] = null! }], + }; + var client = Client(server, uri: "https://api.trychroma.com"); + var ex = Assert.ThrowsAsync(() => upsert ? client.UpsertAsync(records) : client.UpdateAsync(records)); + Assert.That(ex!.Message, Does.StartWith("Record \"a\" would take 33 metadata keys to Chroma Cloud, beyond its quota of 32: its values and nulls, with the keys that the client adds, like the copy of the document, the mark of a deleted document and the BM25 sparse vectors. Send fewer keys or fewer nulls, or use ChromaConfigurationOptions.WithReadsBeyondKeyQuota() in the options of the ChromaClient: it reads the record first and sends only the nulls of the keys it has, but then two writes of the same record at the same time can mix.")); + Assert.That(ex.ParamName, Is.EqualTo("records")); + Assert.That(server.Paths, Is.Empty); + } + + // With ReadsBeyondKeyQuota the client reads such a record first, and sends the nulls only to the keys it has. The option is off by + // default, and stays through the other options and WithMetadataValues of a collection client. + [Test] + public async Task BeyondTheQuotaOfKeysOfChromaCloudWithTheRead() + { + Assert.That(new ChromaConfigurationOptions("https://api.trychroma.com").ReadsBeyondKeyQuota, Is.False); + Assert.That(new ChromaConfigurationOptions("https://api.trychroma.com").WithReadsBeyondKeyQuota().WithDatabase("d").ReadsBeyondKeyQuota, Is.True); + var server = new FakeServer("""{"ids":["a"],"metadatas":[{"k0":0,"gone":1}]}"""); + var many = Enumerable.Range(0, 31).ToDictionary(i => $"k{i}", i => (object)(long)i); + many["gone"] = null!; + many["missing"] = null!; + await Client(server, uri: "https://api.trychroma.com", readsBeyondKeyQuota: true).WithMetadataValues(ChromaMetadataValues.Exact).UpsertAsync(new ChromaRecords(["a", "b"]) + { + Embeddings = [Embedding, Embedding], + Metadatas = [many, new Dictionary { ["k"] = null! }], + }); + Assert.That(server.Paths, Is.EqualTo(new[] { "pre-flight-checks", "get", "upsert" })); + Assert.That(server.Bodies[1].GetProperty("ids").EnumerateArray().Select(x => x.GetString()), Is.EqualTo(new[] { "a" })); + var metadatas = server.Bodies[2].GetProperty("metadatas"); + Assert.That(metadatas[0].EnumerateObject().Count(), Is.EqualTo(32)); + Assert.That(metadatas[0].GetProperty("gone").ValueKind, Is.EqualTo(JsonValueKind.Null)); + Assert.That(metadatas[0].TryGetProperty("missing", out _), Is.False); + Assert.That(metadatas[1].GetRawText(), Is.EqualTo("""{"k":null}""")); + } + + // The error and the read are only for Chroma Cloud, and only beyond its quota: 33 keys with the nulls on a single server, and exactly 32 + // on Chroma Cloud, also with ReadsBeyondKeyQuota, go without a read. + [TestCase("http://localhost:8000", 31, false)] + [TestCase("http://localhost:8000", 31, true)] + [TestCase("https://api.trychroma.com", 30, false)] + [TestCase("https://api.trychroma.com", 30, true)] + public async Task NoReadWithinTheQuotaOrOnASingleServer(string uri, int values, bool readsBeyondKeyQuota) + { + var server = new FakeServer("""{"ids":["a"],"metadatas":[{"k0":0,"gone":1}]}"""); + var metadata = Enumerable.Range(0, values).ToDictionary(i => $"k{i}", i => (object)(long)i); + metadata["gone"] = null!; + metadata["missing"] = null!; + await Client(server, uri: uri, readsBeyondKeyQuota: readsBeyondKeyQuota).UpsertAsync(new ChromaRecords(["a"]) { Embeddings = [Embedding], Metadatas = [metadata] }); + Assert.That(server.Paths, Is.EqualTo(new[] { "pre-flight-checks", "upsert" })); + Assert.That(server.Bodies[1].GetProperty("metadatas")[0].EnumerateObject().Count(), Is.EqualTo(values + 2)); + } + + // The check is for a write with nulls: a record with 33 values and no null goes as it is, and Chroma Cloud answers. + [Test] + public async Task ValuesWithoutNullsGo() + { + var server = new FakeServer("""{"ids":[]}"""); + var metadata = Enumerable.Range(0, 33).ToDictionary(i => $"k{i}", i => (object)(long)i); + await Client(server, uri: "https://api.trychroma.com").UpsertAsync(new ChromaRecords(["a"]) { Embeddings = [Embedding], Metadatas = [metadata] }); + Assert.That(server.Paths, Is.EqualTo(new[] { "pre-flight-checks", "upsert" })); + Assert.That(server.Bodies[1].GetProperty("metadatas")[0].EnumerateObject().Count(), Is.EqualTo(33)); + } + + // An empty list deletes a key as a null does, also the key of a sparse vector: the write sends its null. A list with values is not a + // sparse vector, and throws. + [TestCase(true)] + [TestCase(false)] + public async Task AnEmptyListDeletesASparseVector(bool upsert) + { + var server = new FakeServer("""{"ids":[]}"""); + ChromaRecords Records(List list) => new(["a"]) + { + Embeddings = upsert ? [Embedding] : null, + Metadatas = [new Dictionary { ["title_bm25"] = list }], + }; + var client = Client(server, Schema); + await (upsert ? client.UpsertAsync(Records([])) : client.UpdateAsync(Records([]))); + Assert.That(server.Bodies[server.Paths.IndexOf(upsert ? "upsert" : "update")].GetProperty("metadatas")[0].GetRawText(), Is.EqualTo("""{"title_bm25":null}""")); + Assert.That(() => upsert ? client.UpsertAsync(Records(["x"])) : client.UpdateAsync(Records(["x"])), Throws.ArgumentException.With.Message.Contains("holds the vectors")); + } + + // The null of a sparse vector that the caller gives, with the null of its text, counts once against the quota of Chroma Cloud: 30 + // values, title and title_bm25 are 32 keys, which go without a read. + [Test] + public async Task ANullSparseVectorCountsOnce() + { + var server = new FakeServer("""{"ids":["a"],"metadatas":[{"title":"T","title_bm25":1}]}"""); + var metadata = Enumerable.Range(0, 30).ToDictionary(i => $"k{i}", i => (object)(long)i); + metadata["title"] = null!; + metadata["title_bm25"] = null!; + await Client(server, Schema, "https://api.trychroma.com").UpsertAsync(new ChromaRecords(["a"]) { Embeddings = [Embedding], Metadatas = [metadata] }); + Assert.That(server.Paths, Is.EqualTo(new[] { "pre-flight-checks", "upsert" })); + Assert.That(server.Bodies[1].GetProperty("metadatas")[0].EnumerateObject().Count(), Is.EqualTo(32)); + } + + // The sparse vector that the client computes from a text counts against the quota of Chroma Cloud too, which counts it: values, title, + // a null and title_bm25. With 29 values they are 32 and go without a read; with 30 they are 33, and the write throws, or reads the + // record first with ReadsBeyondKeyQuota. + [TestCase(29, false)] + [TestCase(30, false)] + [TestCase(30, true)] + public async Task ComputedSparseVectorsCount(int values, bool readsBeyondKeyQuota) + { + var server = new FakeServer("""{"ids":["a"],"metadatas":[{"k0":0,"gone":1}]}"""); + var metadata = Enumerable.Range(0, values).ToDictionary(i => $"k{i}", i => (object)(long)i); + metadata["title"] = "red apple"; + metadata["gone"] = null!; + var write = () => Client(server, Schema, "https://api.trychroma.com", readsBeyondKeyQuota).UpsertAsync(new ChromaRecords(["a"]) { Embeddings = [Embedding], Metadatas = [metadata] }); + if (values == 29) + { + await write(); + Assert.That(server.Paths, Does.Not.Contain("get").And.Contain("upsert")); + Assert.That(server.Bodies[server.Paths.IndexOf("upsert")].GetProperty("metadatas")[0].EnumerateObject().Count(), Is.EqualTo(32)); + } + else if (!readsBeyondKeyQuota) + { + Assert.That(() => write(), Throws.ArgumentException.With.Message.StartsWith("Record \"a\" would take 33 metadata keys to Chroma Cloud")); + Assert.That(server.Paths, Is.Empty); + } + else + { + await write(); + Assert.That(server.Paths, Does.Contain("get").And.Contain("upsert")); + Assert.That(server.Bodies[server.Paths.IndexOf("upsert")].GetProperty("metadatas")[0].TryGetProperty("title_bm25", out _), Is.True); + } + } + + // Chroma has no deletion of a document: the client writes an empty one, which a collection client deletes in one of three ways it + // chooses. Without one, a null document with NullDocumentsDelete throws before any request, with the three ways, also from a client + // made by name, which has not read its collection yet; without NullDocumentsDelete, or without a null document, the write goes. + [TestCase(true)] + [TestCase(false)] + public async Task NullDocumentsDeleteWithoutAWayToDeleteDocuments(bool upsert) + { + var server = new FakeServer("""{"ids":[]}"""); + var byName = new ChromaClient(new ChromaConfigurationOptions("http://localhost:8000"), new HttpClient(server)).GetCollectionClient("c"); + ChromaRecords Records(string?[] documents, bool nullDocumentsDelete) => new(["a", "b"]) + { + Embeddings = upsert ? [Embedding, Embedding] : null, + Documents = documents, + NullDocumentsDelete = nullDocumentsDelete, + }; + Task Write(ChromaCollectionClient client, ChromaRecords records) => upsert ? client.UpsertAsync(records) : client.UpdateAsync(records); + foreach (var client in new[] { Client(server), byName }) + { + var ex = Assert.ThrowsAsync(() => Write(client, Records([null, "doc b"], true))); + Assert.That(ex!.Message, Does.StartWith("NullDocumentsDelete needs a way to tell a deleted document from an empty one: Chroma cannot delete a document, so the client writes an empty one. Choose one on the collection client: ")); + Assert.That(ex.Message, Does.Contain("WithDocumentCopyKey(key)").And.Contain("WithDocumentDeletionKey(key)").And.Contain("WithEmptyDocumentDeletions()")); + Assert.That(ex.ParamName, Is.EqualTo("records")); + } + Assert.That(server.Paths, Is.Empty); + await Write(Client(server), Records([null, "doc b"], false)); + await Write(Client(server), Records(["doc a", "doc b"], true)); + Assert.That(server.Paths.Count(path => path is "upsert" or "update"), Is.EqualTo(2)); + } + + // A collection client chooses once how it deletes documents, and the same call again changes nothing: a client that got it twice, and + // once more through WithMetadataValues, sends the same requests as a client that got it once, and reads the same records: the documents, + // with the copy, the mark or neither, and the metadata values, of the same types. + [TestCase("copy")] + [TestCase("mark")] + [TestCase("empty")] + public async Task TheSameWayAgainChangesNothing(string way) + { + const string Stored = """{"ids":["a","b"],"metadatas":[{"text":"","date":"2026-10-04"},{"deleted":true}],"documents":["",""]}"""; + var once = new FakeServer(Stored); + var again = new FakeServer(Stored); + var onceClient = Way(Client(once).WithMetadataValues(ChromaMetadataValues.Exact), way); + var againClient = Way(Way(Way(Client(again).WithMetadataValues(ChromaMetadataValues.Exact), way), way).WithMetadataValues(ChromaMetadataValues.Exact), way); + await Delete(onceClient); + await Delete(againClient); + Assert.That(again.Paths, Is.EqualTo(once.Paths)); + Assert.That(again.Bodies.Select(Text), Is.EqualTo(once.Bodies.Select(Text))); + Assert.That(await Read(againClient), Is.EqualTo(await Read(onceClient))); + + static Task Delete(ChromaCollectionClient client) + => client.UpsertAsync(new ChromaRecords(["a", "b"]) { Embeddings = [Embedding, Embedding], Documents = [null!, "x"], NullDocumentsDelete = true }); + static string Text(JsonElement body) => body.ValueKind == JsonValueKind.Undefined ? "" : body.GetRawText(); + static async Task> Read(ChromaCollectionClient client) + => (await client.GetAsync(include: ChromaGetInclude.Documents | ChromaGetInclude.Metadatas)) + .Select(x => $"{x.Id} [{x.Document ?? "null"}] {string.Join(", ", x.Metadata!.Select(pair => $"{pair.Key}={pair.Value} ({pair.Value.GetType().Name})"))}"); + } + + // A call that would change the way throws: another method, or the same method with another key, also through WithMetadataValues. The + // message says the way the client has and the call it got. + [TestCase("copy", "mark")] + [TestCase("copy", "empty")] + [TestCase("copy", "copy other")] + [TestCase("mark", "copy")] + [TestCase("mark", "empty")] + [TestCase("mark", "mark other")] + [TestCase("empty", "copy")] + [TestCase("empty", "mark")] + public void AChangeOfWayThrows(string first, string second) + { + var client = Way(Client(new FakeServer("""{"ids":[]}""")), first); + var ex = Assert.Throws(() => Way(client, second)); + Assert.That(ex!.Message, Is.EqualTo($"This collection client already deletes documents with {Call(first)}, and a collection client does not change that: call {Call(second)} on a new collection client of the collection.")); + Assert.That(() => Way(client.WithMetadataValues(ChromaMetadataValues.Exact), second), Throws.InvalidOperationException); + } + + // A null key throws the ArgumentNullException of the key, before the way of the client counts. + [Test] + public void ANullKeyThrowsFirst() + { + var client = Client(new FakeServer("""{"ids":[]}""")).WithDocumentCopyKey("text"); + Assert.That(() => client.WithDocumentCopyKey(null!), Throws.ArgumentNullException); + Assert.That(() => client.WithDocumentDeletionKey(null!), Throws.ArgumentNullException); + } + + // A client of WithMetadataValues deletes documents in the way of the client it comes from. + [TestCase("mark", """{"deleted":true}""")] + [TestCase("empty", "null")] + public async Task MetadataValuesKeepTheWay(string way, string metadata) + { + var server = new FakeServer("""{"ids":[]}"""); + var client = Way(Client(server), way).WithMetadataValues(ChromaMetadataValues.Exact); + await client.UpsertAsync(new ChromaRecords(["a"]) { Embeddings = [Embedding], Documents = [null!], NullDocumentsDelete = true }); + var upsert = server.Bodies[server.Paths.IndexOf("upsert")]; + Assert.That(upsert.GetProperty("documents")[0].GetString(), Is.EqualTo("")); + Assert.That(upsert.GetProperty("metadatas").ValueKind == JsonValueKind.Null ? "null" : upsert.GetProperty("metadatas")[0].GetRawText(), Is.EqualTo(metadata)); + } + + static ChromaCollectionClient Way(ChromaCollectionClient client, string way) + => way switch + { + "copy" => client.WithDocumentCopyKey("text"), + "copy other" => client.WithDocumentCopyKey("other"), + "mark" => client.WithDocumentDeletionKey("deleted"), + "mark other" => client.WithDocumentDeletionKey("gone"), + _ => client.WithEmptyDocumentDeletions(), + }; + + static string Call(string way) + => way switch + { + "copy" => "WithDocumentCopyKey(\"text\")", + "copy other" => "WithDocumentCopyKey(\"other\")", + "mark" => "WithDocumentDeletionKey(\"deleted\")", + "mark other" => "WithDocumentDeletionKey(\"gone\")", + _ => "WithEmptyDocumentDeletions()", + }; + + // With a document copy key the copy tells a deleted document from an empty one: the client writes an empty document and the null of + // its copy to every record whose document goes, without a read. + [TestCase(true)] + [TestCase(false)] + public async Task NullDocumentsDeleteWithADocumentCopyKey(bool upsert) + { + var server = new FakeServer("""{"ids":["a"],"metadatas":[{"text":"doc a"}],"documents":["doc a"]}"""); + var client = Client(server).WithDocumentCopyKey("text"); + var records = new ChromaRecords(["a", "b", "c"]) + { + Embeddings = upsert ? [Embedding, Embedding, Embedding] : null, + Documents = [null!, null!, "new"], + NullDocumentsDelete = true, + }; + await (upsert ? client.UpsertAsync(records) : client.UpdateAsync(records)); + Assert.That(server.Paths, Is.EqualTo(new[] { "pre-flight-checks", upsert ? "upsert" : "update" })); + var write = server.Bodies[1]; + Assert.That(write.GetProperty("documents").EnumerateArray().Select(x => x.GetString()), Is.EqualTo(new[] { "", "", "new" })); + Assert.That(write.GetProperty("metadatas").EnumerateArray().Select(x => x.GetRawText()), Is.EqualTo(new[] { """{"text":null}""", """{"text":null}""", """{"text":"new"}""" })); + } + + // With a document deletion key the client marks a deleted document instead of copying every one: an empty document with true under + // the key, and the null of the mark for any other document, without a read. A null document that stays keeps its mark, and an add + // writes no mark. + [TestCase(true)] + [TestCase(false)] + public async Task NullDocumentsDeleteWithADocumentDeletionKey(bool upsert) + { + var server = new FakeServer("""{"ids":[]}"""); + var client = Client(server).WithDocumentDeletionKey("deleted"); + var records = new ChromaRecords(["a", "b", "c"]) + { + Embeddings = upsert ? [Embedding, Embedding, Embedding] : null, + Metadatas = [null, new Dictionary { ["k"] = 1L }, null], + Documents = [null!, "", "new"], + NullDocumentsDelete = true, + }; + await (upsert ? client.UpsertAsync(records) : client.UpdateAsync(records)); + Assert.That(server.Paths, Is.EqualTo(new[] { "pre-flight-checks", upsert ? "upsert" : "update" })); + var write = server.Bodies[1]; + Assert.That(write.GetProperty("documents").EnumerateArray().Select(x => x.GetString()), Is.EqualTo(new[] { "", "", "new" })); + Assert.That(write.GetProperty("metadatas").EnumerateArray().Select(x => x.GetRawText()), Is.EqualTo(new[] { """{"deleted":true}""", """{"k":1,"deleted":null}""", """{"deleted":null}""" })); + await client.UpdateAsync(new ChromaRecords(["a"]) { Documents = [null!] }); + Assert.That(server.Bodies[^1].GetProperty("metadatas").ValueKind, Is.EqualTo(JsonValueKind.Null)); + await client.AddAsync(new ChromaRecords(["d"]) { Embeddings = [Embedding], Documents = [""] }); + Assert.That(server.Bodies[^1].GetProperty("metadatas").ValueKind, Is.EqualTo(JsonValueKind.Null)); + } + + // With empty document deletions the client writes an empty document, and the null of the sparse vector of the document, without a + // read and without any key of its own. + [TestCase(true)] + [TestCase(false)] + public async Task NullDocumentsDeleteWithEmptyDocumentDeletions(bool upsert) + { + var server = new FakeServer("""{"ids":[]}"""); + var client = Client(server, Schema).WithEmptyDocumentDeletions(); + var records = new ChromaRecords(["a", "b"]) + { + Embeddings = upsert ? [Embedding, Embedding] : null, + Metadatas = [null, new Dictionary { ["k"] = 1L }], + Documents = [null!, null!], + NullDocumentsDelete = true, + }; + await (upsert ? client.UpsertAsync(records) : client.UpdateAsync(records)); + Assert.That(server.Paths, Is.EqualTo(new[] { "pre-flight-checks", upsert ? "upsert" : "update" })); + var write = server.Bodies[1]; + Assert.That(write.GetProperty("documents").EnumerateArray().Select(x => x.GetString()), Is.EqualTo(new[] { "", "" })); + Assert.That(write.GetProperty("metadatas").EnumerateArray().Select(x => x.GetRawText()), Is.EqualTo(new[] { """{"doc_bm25":null}""", """{"k":1,"doc_bm25":null}""" })); + } + + // The sparse vectors of a text that goes are deleted too, unless the caller gives them; an empty text gets its empty vector. + [Test] + public async Task SparseVectorsOfATextThatGoes() + { + var server = new FakeServer("""{"ids":["a","b"],"metadatas":[{"title":"T","title_bm25":1,"doc_bm25":1},{"title":"U","title_bm25":1}],"documents":["doc a","doc b"]}"""); + await Client(server, Schema).WithDocumentCopyKey("text").UpsertAsync(new ChromaRecords(["a", "b"]) + { + Embeddings = [Embedding, Embedding], + Metadatas = [new Dictionary { ["title"] = null! }, new Dictionary { ["title"] = null!, ["title_bm25"] = new ChromaSparseVector([1], [0.5f]) }], + Documents = [null!, ""], + NullDocumentsDelete = true, + }); + var upsert = server.Bodies[server.Paths.IndexOf("upsert")]; + var metadatas = upsert.GetProperty("metadatas"); + Assert.That(metadatas[0].GetRawText(), Is.EqualTo("""{"title":null,"text":null,"doc_bm25":null,"title_bm25":null}""")); + Assert.That(metadatas[1].GetProperty("title").ValueKind, Is.EqualTo(JsonValueKind.Null)); + Assert.That(metadatas[1].GetProperty("title_bm25").GetProperty("indices")[0].GetInt32(), Is.EqualTo(1)); + Assert.That(metadatas[1].GetProperty("doc_bm25").GetProperty("indices").GetArrayLength(), Is.EqualTo(0)); + Assert.That(upsert.GetProperty("documents")[0].GetString(), Is.EqualTo("")); + } + + // An empty text, in the metadata or as the document, gets its empty vector, as in the Python client: it replaces the vector of the old + // text, which would otherwise stay and be found by a search of the old text. + [TestCase(true)] + [TestCase(false)] + public async Task AnEmptyTextGetsAnEmptyVector(bool upsert) + { + var server = new FakeServer("""{"ids":[]}"""); + var records = new ChromaRecords(["a"]) + { + Embeddings = upsert ? [Embedding] : null, + Metadatas = [new Dictionary { ["title"] = "" }], + Documents = [""], + }; + var client = Client(server, Schema); + await (upsert ? client.UpsertAsync(records) : client.UpdateAsync(records)); + var metadata = server.Bodies[server.Paths.IndexOf(upsert ? "upsert" : "update")].GetProperty("metadatas")[0]; + Assert.That(metadata.GetProperty("title_bm25").GetProperty("indices").GetArrayLength(), Is.EqualTo(0)); + Assert.That(metadata.GetProperty("doc_bm25").GetProperty("indices").GetArrayLength(), Is.EqualTo(0)); + } + + // Without metadata, a document that goes takes its sparse vector along; a null goes to a stored record without metadata too. + [Test] + public async Task DocumentThatGoesWithoutMetadata() + { + var server = new FakeServer("""{"ids":["a","b"],"metadatas":[{"doc_bm25":1},null],"documents":["doc a","doc b"]}"""); + await Client(server, Schema).WithDocumentCopyKey("text").UpsertAsync(new ChromaRecords(["a"]) { Embeddings = [Embedding], Documents = [null!], NullDocumentsDelete = true }); + Assert.That(server.Bodies[server.Paths.IndexOf("upsert")].GetProperty("metadatas")[0].GetRawText(), Is.EqualTo("""{"text":null,"doc_bm25":null}""")); + await Client(server).UpsertAsync(new ChromaRecords(["b"]) { Embeddings = [Embedding], Metadatas = [new Dictionary { ["k"] = null! }] }); + Assert.That(server.Bodies.Last().GetProperty("metadatas")[0].GetRawText(), Is.EqualTo("""{"k":null}""")); + } + + // The copy of each document under the document copy key replaces what the metadata has there; a document too long for Chroma Cloud + // goes without it, and only there. + [TestCase("http://localhost:8000", true)] + [TestCase("https://api.trychroma.com", false)] + public async Task DocumentCopyInAnAdd(string uri, bool longCopied) + { + var server = new FakeServer("""{"ids":[]}"""); + var longText = new string('a', ChromaCloudQuotas.MaxMetadataValueBytes + 1); + await Client(server, uri: uri).WithDocumentCopyKey("text").AddAsync(new ChromaRecords(["a", "b", "c"]) + { + Embeddings = [Embedding, Embedding, Embedding], + Metadatas = [new Dictionary { ["text"] = "old", ["k"] = 1 }, null!, null!], + Documents = ["short", longText, null!], + }); + var metadatas = server.Bodies[server.Paths.IndexOf("add")].GetProperty("metadatas"); + Assert.That(metadatas[0].GetRawText(), Is.EqualTo("""{"text":"short","k":1}""")); + Assert.That(metadatas[1].ValueKind == JsonValueKind.Object && metadatas[1].TryGetProperty("text", out _), Is.EqualTo(longCopied)); + Assert.That(metadatas[2].ValueKind, Is.EqualTo(JsonValueKind.Null)); + } + + // In an upsert a document without its copy deletes the stored one: one too long for Chroma Cloud, and one deleted; a null document that + // stays keeps its copy. + [TestCase(true)] + [TestCase(false)] + public async Task DocumentCopyInAnUpsert(bool nullDocumentsDelete) + { + var server = new FakeServer("""{"ids":["a","b"],"metadatas":[{"text":"old a"},{"text":"old b"}],"documents":["old a","old b"]}"""); + var longText = new string('a', ChromaCloudQuotas.MaxMetadataValueBytes + 1); + await Client(server, uri: "https://api.trychroma.com").WithDocumentCopyKey("text").UpsertAsync(new ChromaRecords(["a", "b", "c"]) + { + Embeddings = [Embedding, Embedding, Embedding], + Documents = [longText, null!, "new c"], + NullDocumentsDelete = nullDocumentsDelete, + }); + var upsert = server.Bodies[server.Paths.IndexOf("upsert")]; + var metadatas = upsert.GetProperty("metadatas"); + Assert.That(metadatas[0].GetRawText(), Is.EqualTo("""{"text":null}""")); + Assert.That(nullDocumentsDelete ? metadatas[1].GetRawText() : metadatas[1].ValueKind.ToString(), Is.EqualTo(nullDocumentsDelete ? """{"text":null}""" : "Null")); + Assert.That(metadatas[2].GetRawText(), Is.EqualTo("""{"text":"new c"}""")); + } + + [Test] + public async Task NoDocumentsNoCopy() + { + var server = new FakeServer("""{"ids":[]}"""); + await Client(server).WithDocumentCopyKey("text").UpsertAsync(new ChromaRecords(["a"]) { Embeddings = [Embedding] }); + Assert.That(server.Paths, Is.EqualTo(new[] { "pre-flight-checks", "upsert" })); + Assert.That(server.Bodies[1].GetProperty("metadatas").ValueKind, Is.EqualTo(JsonValueKind.Null)); + } + + // A list of metadata that are all null goes as no metadata. + [Test] + public async Task AllMetadatasNull() + { + var server = new FakeServer("""{"ids":[]}"""); + await Client(server).UpsertAsync(new ChromaRecords(["a", "b"]) { Embeddings = [Embedding, Embedding], Metadatas = [null, null] }); + Assert.That(server.Bodies[server.Paths.IndexOf("upsert")].GetProperty("metadatas").ValueKind, Is.EqualTo(JsonValueKind.Null)); + } + + // Records without metadata whose documents get no copy send no metadata. + [Test] + public async Task NoCopyNoMetadata() + { + var server = new FakeServer("""{"ids":[]}"""); + await Client(server).WithDocumentCopyKey("text").AddAsync(new ChromaRecords(["a"]) { Embeddings = [Embedding], Documents = [null!] }); + Assert.That(server.Bodies[server.Paths.IndexOf("add")].GetProperty("metadatas").ValueKind, Is.EqualTo(JsonValueKind.Null)); + } + + static ChromaCollectionClient Client(HttpMessageHandler handler, string? schema = null, string uri = "http://localhost:8000", bool readsBeyondKeyQuota = false) + => new(new ChromaCollection("c") { Id = Guid.NewGuid(), SchemaJson = schema is null ? null : JsonDocument.Parse(schema).RootElement }, + new ChromaConfigurationOptions(uri).WithReadsBeyondKeyQuota(readsBeyondKeyQuota), new HttpClient(handler)); + + // Answers pre-flight-checks with a batch size, the reads with the stored records, and the writes with nothing; records the requests. + sealed class FakeServer(string stored) : HttpMessageHandler + { + public List Paths { get; } = []; + public List Bodies { get; } = []; + + protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + var path = request.RequestUri!.AbsolutePath; + Paths.Add(path.Substring(path.LastIndexOf('/') + 1)); + Bodies.Add(request.Content is null ? default : JsonDocument.Parse(await request.Content.ReadAsStringAsync(cancellationToken)).RootElement.Clone()); + var answer = path.EndsWith("/version") ? "\"1.5.9\"" + : path.EndsWith("/pre-flight-checks") ? """{"max_batch_size":100,"supports_base64_encoding":false}""" + : path.EndsWith("/get") ? stored + : "{}"; + return new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent(answer) }; + } + } +} diff --git a/ChromaDB.Client.Tests/DependencyInjectionFromServicesTests.cs b/ChromaDB.Client.Tests/DependencyInjectionFromServicesTests.cs new file mode 100644 index 0000000..6e25c9f --- /dev/null +++ b/ChromaDB.Client.Tests/DependencyInjectionFromServicesTests.cs @@ -0,0 +1,100 @@ +using System.Net; +using ChromaDB.Client.DependencyInjection; +using Microsoft.Extensions.DependencyInjection; +using NUnit.Framework; + +namespace ChromaDB.Client.Tests; + +// AddChromaClientFromServices and AddKeyedChromaClientFromServices: the options come from the services when the client is resolved. +[TestFixture] +public class DependencyInjectionFromServicesTests +{ + sealed record Settings(string Uri); + + // The options are made once, when the client or the options are resolved, from services registered also after the client. + [Test] + public void TheOptionsComeFromTheServices() + { + var services = new ServiceCollection(); + var calls = 0; + services.AddChromaClientFromServices(serviceProvider => + { + calls++; + return new ChromaConfigurationOptions(serviceProvider.GetRequiredService().Uri); + }); + services.AddSingleton(new Settings("http://chroma.example:8000")); + Assert.That(calls, Is.Zero); + using var provider = services.BuildServiceProvider(); + var client = provider.GetRequiredService(); + Assert.That(client.Options.Uri, Is.EqualTo(new Uri("http://chroma.example:8000"))); + Assert.That(provider.GetRequiredService().Uri, Is.EqualTo(new Uri("http://chroma.example:8000"))); + Assert.That(provider.GetRequiredService(), Is.SameAs(client)); + Assert.That(calls, Is.EqualTo(1)); + } + + // The HttpClient of the client goes through the builder, as with AddChromaClient. + [Test] + public async Task TheHttpClientGoesThroughTheBuilder() + { + var handler = new Recorder(); + var services = new ServiceCollection(); + services.AddSingleton(new Settings("http://chroma.example:8000")); + services.AddChromaClientFromServices(serviceProvider => new ChromaConfigurationOptions(serviceProvider.GetRequiredService().Uri), + builder => builder.ConfigurePrimaryHttpMessageHandler(() => handler)); + using var provider = services.BuildServiceProvider(); + await provider.GetRequiredService().HeartbeatAsync(); + Assert.That(handler.Uris.Single(), Is.EqualTo(new Uri("http://chroma.example:8000/api/v2/heartbeat"))); + } + + [Test] + public async Task KeyedClientsFromTheServices() + { + var first = new Recorder(); + var second = new Recorder(); + var services = new ServiceCollection(); + services.AddKeyedSingleton("first", new Settings("http://first:8000")); + services.AddKeyedSingleton("second", new Settings("http://second:8000")); + services.AddKeyedChromaClientFromServices("first", serviceProvider => new ChromaConfigurationOptions(serviceProvider.GetRequiredKeyedService("first").Uri), + builder => builder.ConfigurePrimaryHttpMessageHandler(() => first)); + services.AddKeyedChromaClientFromServices("second", serviceProvider => new ChromaConfigurationOptions(serviceProvider.GetRequiredKeyedService("second").Uri), + builder => builder.ConfigurePrimaryHttpMessageHandler(() => second)); + using var provider = services.BuildServiceProvider(); + await provider.GetRequiredKeyedService("first").HeartbeatAsync(); + await provider.GetRequiredKeyedService("second").HeartbeatAsync(); + Assert.That(first.Uris.Single().Host, Is.EqualTo("first")); + Assert.That(second.Uris.Single().Host, Is.EqualTo("second")); + Assert.That(provider.GetRequiredKeyedService("second").Uri.Host, Is.EqualTo("second")); + } + + [Test] + public void WrongArguments() + { + var services = new ServiceCollection(); + Assert.That(() => services.AddChromaClientFromServices(null!), Throws.ArgumentNullException); + Assert.That(() => services.AddKeyedChromaClientFromServices(null!, _ => new ChromaConfigurationOptions()), Throws.ArgumentNullException); + Assert.That(() => services.AddKeyedChromaClientFromServices("k", null!), Throws.ArgumentNullException); + services.AddChromaClientFromServices(_ => null!); + using var provider = services.BuildServiceProvider(); + Assert.That(() => provider.GetRequiredService(), Throws.InvalidOperationException.With.Message.Contains("returned null")); + } + + [Test] + public void RegistrationsReturnTheServices() + { + var services = new ServiceCollection(); + Assert.That(services.AddChromaClientFromServices(_ => new ChromaConfigurationOptions()), Is.SameAs(services)); + Assert.That(services.AddKeyedChromaClientFromServices("k", _ => new ChromaConfigurationOptions(), _ => { }), Is.SameAs(services)); + } + + // Answers every request with a heartbeat, and records the URIs. + sealed class Recorder : HttpMessageHandler + { + public List Uris { get; } = []; + + protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + Uris.Add(request.RequestUri!); + return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent("""{"nanosecond heartbeat":1}""") }); + } + } +} diff --git a/ChromaDB.Client.Tests/DependencyInjectionLifetimeTests.cs b/ChromaDB.Client.Tests/DependencyInjectionLifetimeTests.cs new file mode 100644 index 0000000..4299ac5 --- /dev/null +++ b/ChromaDB.Client.Tests/DependencyInjectionLifetimeTests.cs @@ -0,0 +1,113 @@ +using ChromaDB.Client.DependencyInjection; +using Microsoft.Extensions.DependencyInjection; +using NUnit.Framework; + +namespace ChromaDB.Client.Tests; + +// The registrations, without a server: one ChromaClient per container, also for the services that live as long as it. +[TestFixture] +public class DependencyInjectionLifetimeTests +{ + static readonly ChromaConfigurationOptions Options = new("http://localhost:8000"); + + [Test] + public void AddChromaClientRegistersOneSingleton() + { + var services = new ServiceCollection(); + services.AddChromaClient(_ => Options); + Assert.That(services.Where(x => x.ServiceType == typeof(ChromaClient)).Select(x => x.Lifetime), Is.EqualTo(new[] { ServiceLifetime.Singleton })); + using var provider = services.BuildServiceProvider(new ServiceProviderOptions { ValidateScopes = true }); + var fromRoot = provider.GetRequiredService(); + using var scope = provider.CreateScope(); + Assert.That(scope.ServiceProvider.GetRequiredService(), Is.SameAs(fromRoot)); + Assert.That(scope.ServiceProvider.GetRequiredService(), Is.SameAs(fromRoot)); + Assert.That(fromRoot.Options, Is.SameAs(Options)); + } + + [Test] + public void AddKeyedChromaClientRegistersOneSingleton() + { + var services = new ServiceCollection(); + services.AddKeyedChromaClient("first", _ => Options); + services.AddKeyedChromaClient("second", _ => Options.WithTenant("t")); + Assert.That(services.Where(x => x.ServiceType == typeof(ChromaClient)).Select(x => x.Lifetime), Is.All.EqualTo(ServiceLifetime.Singleton)); + using var provider = services.BuildServiceProvider(new ServiceProviderOptions { ValidateScopes = true }); + var first = provider.GetRequiredKeyedService("first"); + using var scope = provider.CreateScope(); + Assert.That(scope.ServiceProvider.GetRequiredKeyedService("first"), Is.SameAs(first)); + Assert.That(provider.GetRequiredKeyedService("second"), Is.Not.SameAs(first)); + Assert.That(provider.GetRequiredKeyedService("second").Options.Tenant, Is.EqualTo("t")); + } + + // A singleton service can take the ChromaClient, as a vector store does. + [Test] + public void SingletonsCanTakeTheClient() + { + var services = new ServiceCollection(); + services.AddChromaClient(_ => Options); + services.AddKeyedChromaClient("k", _ => Options); + services.AddSingleton(); + services.AddKeyedSingleton("k", (sp, key) => new Consumer(sp.GetRequiredKeyedService(key))); + using var provider = services.BuildServiceProvider(new ServiceProviderOptions { ValidateScopes = true, ValidateOnBuild = true }); + Assert.That(provider.GetRequiredService().Client, Is.SameAs(provider.GetRequiredService())); + Assert.That(provider.GetRequiredKeyedService("k").Client, Is.SameAs(provider.GetRequiredKeyedService("k"))); + } + + // The singleton keeps its HttpClient, but each request takes the current handler of the factory, which renews it. + [Test] + public async Task RequestsUseTheRenewedHandler() + { + var handlers = new List(); + var created = 0; + var services = new ServiceCollection(); + services.AddChromaClient(_ => Options); + services.AddHttpClient(nameof(ChromaClient)) + .ConfigurePrimaryHttpMessageHandler(() => new HeartbeatHandler(Interlocked.Increment(ref created), handlers)) + .SetHandlerLifetime(TimeSpan.FromSeconds(1)); + using var provider = services.BuildServiceProvider(); + var client = provider.GetRequiredService(); + await client.HeartbeatAsync(); + await client.HeartbeatAsync(); + await Task.Delay(TimeSpan.FromSeconds(2)); + await client.HeartbeatAsync(); + Assert.That(handlers, Is.EqualTo(new[] { 1, 1, 2 })); + } + + // A CreateChromaClient for an HttpClient name of its own, as an integration keeps it: the handler of that name, renewed by the + // factory, and the settings of that HttpClient. + [Test] + public async Task CreateChromaClientUsesTheHandlerOfTheName() + { + var handlers = new List(); + var headers = new List(); + var created = 0; + var services = new ServiceCollection(); + services.AddHttpClient("connection") + .ConfigurePrimaryHttpMessageHandler(() => new HeartbeatHandler(Interlocked.Increment(ref created), handlers, headers)) + .ConfigureHttpClient(httpClient => httpClient.DefaultRequestHeaders.Add("X-Connection", "connection")) + .SetHandlerLifetime(TimeSpan.FromSeconds(1)); + using var provider = services.BuildServiceProvider(); + var client = provider.CreateChromaClient(Options, "connection"); + await client.HeartbeatAsync(); + await Task.Delay(TimeSpan.FromSeconds(2)); + await client.HeartbeatAsync(); + Assert.That(handlers, Is.EqualTo(new[] { 1, 2 })); + Assert.That(headers, Is.EqualTo(new[] { "connection", "connection" })); + Assert.That(client.Options, Is.SameAs(Options)); + } + + sealed class HeartbeatHandler(int id, List handlers, List? headers = null) : HttpMessageHandler + { + protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + lock (handlers) handlers.Add(id); + headers?.Add(request.Headers.TryGetValues("X-Connection", out var values) ? values.Single() : null); + return Task.FromResult(new HttpResponseMessage(System.Net.HttpStatusCode.OK) { Content = new StringContent("{\"nanosecond heartbeat\":1}") }); + } + } + + sealed class Consumer(ChromaClient client) + { + public ChromaClient Client { get; } = client; + } +} diff --git a/ChromaDB.Client.Tests/DependencyInjectionTests.cs b/ChromaDB.Client.Tests/DependencyInjectionTests.cs new file mode 100644 index 0000000..c43e609 --- /dev/null +++ b/ChromaDB.Client.Tests/DependencyInjectionTests.cs @@ -0,0 +1,58 @@ +using ChromaDB.Client.DependencyInjection; +using Microsoft.Extensions.DependencyInjection; +using NUnit.Framework; + +namespace ChromaDB.Client.Tests; + +[TestFixture] +public class DependencyInjectionTests : ChromaTestsBase +{ + [Test] + public async Task AddChromaClientUsesTheOptions() + { + var services = new ServiceCollection(); + services.AddChromaClient(_ => BaseConfigurationOptions); + using var provider = services.BuildServiceProvider(); + var client = provider.GetRequiredService(); + var result = await client.HeartbeatAsync(); + Assert.That(result.NanosecondHeartbeat, Is.GreaterThan(0)); + } + + [Test] + public async Task AddKeyedChromaClientUsesTheOptionsOfTheKey() + { + var services = new ServiceCollection(); + services.AddKeyedChromaClient("first", _ => BaseConfigurationOptions); + services.AddKeyedChromaClient("second", _ => BaseConfigurationOptions.WithUri("http://localhost:1/")); + using var provider = services.BuildServiceProvider(); + Assert.That(provider.GetRequiredKeyedService("first"), Is.SameAs(BaseConfigurationOptions)); + Assert.That(provider.GetRequiredKeyedService("second").Uri, Is.EqualTo(new Uri("http://localhost:1/"))); + var result = await provider.GetRequiredKeyedService("first").HeartbeatAsync(); + Assert.That(result.NanosecondHeartbeat, Is.GreaterThan(0)); + await Assert.ThatAsync(() => provider.GetRequiredKeyedService("second").HeartbeatAsync(), Throws.InstanceOf()); + } + + // The registrations return the services, so that more registrations follow. + [Test] + public void RegistrationsReturnTheServices() + { + var services = new ServiceCollection(); + Assert.That(services.AddChromaClient(), Is.SameAs(services)); + Assert.That(services.AddChromaClient(null, _ => { }), Is.SameAs(services)); + Assert.That(services.AddKeyedChromaClient("first"), Is.SameAs(services)); + Assert.That(services.AddKeyedChromaClient("second", null, _ => { }), Is.SameAs(services)); + } + + [Test] + public void KeyedAndDefaultClientsTogether() + { + var services = new ServiceCollection(); + services.AddChromaClient(_ => BaseConfigurationOptions); + services.AddKeyedChromaClient("other", _ => BaseConfigurationOptions.WithTenant("other_tenant")); + using var provider = services.BuildServiceProvider(); + Assert.That(provider.GetRequiredService().Tenant, Is.EqualTo(BaseConfigurationOptions.Tenant)); + Assert.That(provider.GetRequiredKeyedService("other").Tenant, Is.EqualTo("other_tenant")); + Assert.That(provider.GetRequiredService(), Is.Not.Null); + Assert.That(provider.GetRequiredKeyedService("other"), Is.Not.Null); + } +} diff --git a/ChromaDB.Client.Tests/DocumentCopyReadTests.cs b/ChromaDB.Client.Tests/DocumentCopyReadTests.cs new file mode 100644 index 0000000..f5f75e1 --- /dev/null +++ b/ChromaDB.Client.Tests/DocumentCopyReadTests.cs @@ -0,0 +1,155 @@ +using System.Net; +using System.Text.Json; +using ChromaDB.Client.Models; +using NUnit.Framework; + +namespace ChromaDB.Client.Tests; + +// A client with a document copy key reads an empty document without its copy as null: the client wrote it for a document deleted with +// NullDocumentsDelete. An empty document with its copy was written empty. A client with a document deletion key reads an empty document +// with its mark as null, and one without it as empty. The metadata goes with the documents, and away from the results that did not ask +// for it. +[TestFixture] +public class DocumentCopyReadTests +{ + static readonly ReadOnlyMemory Embedding = new([1f, 0f]); + + [Test] + public async Task Get() + { + var server = new FakeServer("""{"ids":["empty","deleted","text"],"metadatas":[{"text":""},{"k":1},null],"documents":["","","x"]}"""); + var client = Client(server).WithDocumentCopyKey("text"); + + var documents = await client.GetAsync(include: ChromaGetInclude.Documents); + Assert.That(server.Include(), Is.EquivalentTo(new[] { "metadatas", "documents" })); + Assert.That(documents.Select(x => x.Document), Is.EqualTo(new[] { "", null, "x" })); + Assert.That(documents.Select(x => x.Metadata), Is.All.Null); + + var both = await client.GetAsync(include: ChromaGetInclude.Metadatas | ChromaGetInclude.Documents); + Assert.That(both.Select(x => x.Document), Is.EqualTo(new[] { "", null, "x" })); + Assert.That(both[1].Metadata!["k"], Is.EqualTo(1L)); + + var withoutKey = await Client(server).GetAsync(include: ChromaGetInclude.Documents); + Assert.That(server.Include(), Is.EqualTo(new[] { "documents" })); + Assert.That(withoutKey.Select(x => x.Document), Is.EqualTo(new[] { "", "", "x" })); + } + + [Test] + public async Task Query() + { + var server = new FakeServer("""{"ids":[["empty","deleted"]],"metadatas":[[{"text":""},null]],"documents":[["",""]],"distances":[[0.1,0.2]]}"""); + + var results = (await Client(server).WithDocumentCopyKey("text").QueryAsync(new ChromaQuery([Embedding]) { Include = ChromaQueryInclude.Documents | ChromaQueryInclude.Distances }))[0]; + + Assert.That(server.Include(), Is.EquivalentTo(new[] { "metadatas", "documents", "distances" })); + Assert.That(results.Select(x => x.Document), Is.EqualTo(new[] { "", null })); + Assert.That(results.Select(x => x.Metadata), Is.All.Null); + } + + // A search selects the copy key with the documents, unless it selects all the metadata or the key itself. + [TestCase(new[] { ChromaSearchKeys.Document, ChromaSearchKeys.Score }, new[] { ChromaSearchKeys.Document, ChromaSearchKeys.Score, "text" }, false)] + [TestCase(new[] { ChromaSearchKeys.Document, "text" }, new[] { ChromaSearchKeys.Document, "text" }, true)] + [TestCase(new[] { ChromaSearchKeys.Document, ChromaSearchKeys.Metadata }, new[] { ChromaSearchKeys.Document, ChromaSearchKeys.Metadata }, true)] + public async Task Search(string[] select, string[] sent, bool metadataKept) + { + var server = new FakeServer("""{"ids":[["empty","deleted"]],"documents":[["",""]],"metadatas":[[{"text":""},{}]],"scores":[[0.1,0.2]]}"""); + + var results = await Client(server).WithDocumentCopyKey("text").SearchAsync(new ChromaSearch { Select = select }); + + var keys = server.Bodies[^1].GetProperty("searches")[0].GetProperty("select").GetProperty("keys").EnumerateArray().Select(x => x.GetString()); + Assert.That(keys, Is.EqualTo(sent)); + Assert.That(results.Select(x => x.Document), Is.EqualTo(new[] { "", null })); + Assert.That(results[0].Metadata is not null, Is.EqualTo(metadataKept)); + } + + // The mark is true under the key: an empty document with it reads as null, in a get, a query and a search, and a search selects the + // key with the documents. + [Test] + public async Task DocumentDeletionKey() + { + var get = new FakeServer("""{"ids":["empty","deleted","text","unmarked"],"metadatas":[null,{"deleted":true},{"deleted":true},{"deleted":false}],"documents":["","","x",""]}"""); + var documents = await Client(get).WithDocumentDeletionKey("deleted").GetAsync(include: ChromaGetInclude.Documents); + Assert.That(get.Include(), Is.EquivalentTo(new[] { "metadatas", "documents" })); + Assert.That(documents.Select(x => x.Document), Is.EqualTo(new[] { "", null, "x", "" })); + Assert.That(documents.Select(x => x.Metadata), Is.All.Null); + + var query = new FakeServer("""{"ids":[["empty","deleted"]],"metadatas":[[null,{"deleted":true}]],"documents":[["",""]],"distances":[[0.1,0.2]]}"""); + var results = (await Client(query).WithDocumentDeletionKey("deleted").QueryAsync(new ChromaQuery([Embedding]) { Include = ChromaQueryInclude.Documents }))[0]; + Assert.That(results.Select(x => x.Document), Is.EqualTo(new[] { "", null })); + + var search = new FakeServer("""{"ids":[["empty","deleted"]],"documents":[["",""]],"metadatas":[[{},{"deleted":true}]],"scores":[[0.1,0.2]]}"""); + var found = await Client(search).WithDocumentDeletionKey("deleted").SearchAsync(new ChromaSearch { Select = [ChromaSearchKeys.Document] }); + var keys = search.Bodies[^1].GetProperty("searches")[0].GetProperty("select").GetProperty("keys").EnumerateArray().Select(x => x.GetString()); + Assert.That(keys, Is.EqualTo(new[] { ChromaSearchKeys.Document, "deleted" })); + Assert.That(found.Select(x => x.Document), Is.EqualTo(new[] { "", null })); + Assert.That(() => Client(get).WithDocumentDeletionKey(null!), Throws.ArgumentNullException); + } + + // With empty document deletions a client reads the documents as they are, without the metadata. + [Test] + public async Task EmptyDocumentDeletions() + { + var server = new FakeServer("""{"ids":["empty","text"],"documents":["","x"]}"""); + var documents = await Client(server).WithEmptyDocumentDeletions().GetAsync(include: ChromaGetInclude.Documents); + Assert.That(server.Include(), Is.EqualTo(new[] { "documents" })); + Assert.That(documents.Select(x => x.Document), Is.EqualTo(new[] { "", "x" })); + } + + // The copies of a client by name go to the collection it reads. + [Test] + public async Task ByName() + { + var server = new FakeServer("""{"ids":[]}"""); + using var httpClient = new HttpClient(server); + var client = new ChromaClient(new ChromaConfigurationOptions("http://localhost:8000"), httpClient).GetCollectionClient("c").WithDocumentCopyKey("text"); + + await client.AddAsync(new ChromaRecords(["a"]) { Embeddings = [Embedding], Documents = ["x"] }); + + Assert.That(server.Paths, Does.Contain("c")); + Assert.That(server.Bodies[server.Paths.IndexOf("add")].GetProperty("metadatas")[0].GetRawText(), Is.EqualTo("""{"text":"x"}""")); + Assert.That(() => client.WithDocumentCopyKey(null!), Throws.ArgumentNullException); + } + + // A collection client of a client that infers metadata values can read them exactly, and keeps its document copy key. + [Test] + public async Task MetadataValues() + { + var server = new FakeServer("""{"ids":["a"],"metadatas":[{"date":"2026-10-04"}],"documents":[""]}"""); + using var httpClient = new HttpClient(server); + var inferred = new ChromaClient(new ChromaConfigurationOptions("http://localhost:8000").WithMetadataValues(ChromaMetadataValues.Inferred), httpClient) + .GetCollectionClient("c").WithDocumentCopyKey("text"); + + var exact = (await inferred.WithMetadataValues(ChromaMetadataValues.Exact).GetAsync()).Single(); + var asIs = (await inferred.GetAsync()).Single(); + + Assert.That((exact.Metadata!["date"], exact.Document), Is.EqualTo(("2026-10-04", (string?)null))); + Assert.That(asIs.Metadata!["date"], Is.InstanceOf()); + } + + static ChromaCollectionClient Client(HttpMessageHandler handler) + => new(new ChromaCollection("c") { Id = Guid.NewGuid() }, new ChromaConfigurationOptions("http://localhost:8000"), new HttpClient(handler)); + + // Answers the version, pre-flight-checks with a batch size, the collection c, the reads with the records, and the writes with + // nothing; records the requests. + sealed class FakeServer(string records) : HttpMessageHandler + { + public List Paths { get; } = []; + public List Bodies { get; } = []; + + public IEnumerable Include() + => Bodies[^1].GetProperty("include").EnumerateArray().Select(x => x.GetString()); + + protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + var path = request.RequestUri!.AbsolutePath; + Paths.Add(path.Substring(path.LastIndexOf('/') + 1)); + Bodies.Add(request.Content is null ? default : JsonDocument.Parse(await request.Content.ReadAsStringAsync(cancellationToken)).RootElement.Clone()); + var answer = path.EndsWith("/version") ? "\"1.5.9\"" + : path.EndsWith("/pre-flight-checks") ? """{"max_batch_size":100,"supports_base64_encoding":false}""" + : path.EndsWith("/collections/c") ? """{"id":"11111111-2222-3333-4444-555555555555","name":"c"}""" + : path.EndsWith("/get") || path.EndsWith("/query") || path.EndsWith("/search") ? records + : "{}"; + return new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent(answer) }; + } + } +} diff --git a/ChromaDB.Client.Tests/DocumentFiltersTests.cs b/ChromaDB.Client.Tests/DocumentFiltersTests.cs new file mode 100644 index 0000000..4f9df66 --- /dev/null +++ b/ChromaDB.Client.Tests/DocumentFiltersTests.cs @@ -0,0 +1,324 @@ +using System.Net; +using ChromaDB.Client.Models; +using NUnit.Framework; + +namespace ChromaDB.Client.Tests; + +// KD-56 and KD-57: on Chroma Cloud a filter on the documents gives the wrong records, also in a delete: a $regex whose pattern takes an +// empty text finds every record and its $not_regex none, and, once Chroma Cloud has indexed the records, a $contains of fewer than 3 +// characters finds none and its $not_contains every record. Such a filter throws on Chroma Cloud before any request, in every read and +// delete, also inside $and, $or and Not, and so does a pattern that the client cannot read, which Chroma rejects. The client reads a +// pattern as Chroma does, with the syntax of the Rust crate regex (RegexMinimumLengthTests). Every other filter goes, and every filter +// goes on a single server. +[TestFixture] +public class DocumentFiltersTests +{ + const string Cloud = "https://api.trychroma.com"; + const string Local = "http://localhost:8000"; + static readonly ReadOnlyMemory Embedding = new([1f, 0f]); + static readonly string[] Operations = ["get", "get with where", "query", "delete", "delete by filters", "search", "search with where document"]; + + static IEnumerable WrongOnChromaCloud() + { + yield return ChromaWhereDocumentOperator.Regex("^$"); + yield return ChromaWhereDocumentOperator.Regex("^"); + yield return ChromaWhereDocumentOperator.Regex("^.*$"); + yield return ChromaWhereDocumentOperator.NotRegex("^$"); + yield return ChromaWhereDocumentOperator.Contains("xyz") & ChromaWhereDocumentOperator.Regex("x*"); + yield return ChromaWhereDocumentOperator.Not(ChromaWhereDocumentOperator.Regex("^$") | ChromaWhereDocumentOperator.Contains("xyz")); + // Written in a way that .NET reads otherwise than Chroma, or not at all: Chroma 1.5.9 finds an empty document with them. + yield return ChromaWhereDocumentOperator.Regex("^[[:alpha:]]*$"); + yield return ChromaWhereDocumentOperator.Regex("^[a[b]]*$"); + yield return ChromaWhereDocumentOperator.Regex("^[]a[:alpha:]]*$"); + yield return ChromaWhereDocumentOperator.Regex("^[^]a[:alpha:]]*$"); + yield return ChromaWhereDocumentOperator.Regex("^(?U)a*$"); + yield return ChromaWhereDocumentOperator.Regex("(?Px*)"); + yield return ChromaWhereDocumentOperator.Regex("^\\b{start-half}$"); + // In verbose mode the blanks do not count, and x+ ? is (x+)?. + yield return ChromaWhereDocumentOperator.Regex("(?x) a* "); + yield return ChromaWhereDocumentOperator.Regex("(?x)a+ ?"); + // Patterns that the client cannot read, which Chroma rejects. + yield return ChromaWhereDocumentOperator.Regex("\\Z"); + yield return ChromaWhereDocumentOperator.Regex("(?=)"); + // Texts of fewer than 3 characters, as Chroma counts them: a character outside the BMP is one. + yield return ChromaWhereDocumentOperator.Contains(""); + yield return ChromaWhereDocumentOperator.Contains("a"); + yield return ChromaWhereDocumentOperator.Contains('a'); + yield return ChromaWhereDocumentOperator.Contains("ab"); + yield return ChromaWhereDocumentOperator.NotContains("ab"); + yield return ChromaWhereDocumentOperator.Contains("αβ"); + yield return ChromaWhereDocumentOperator.Contains("😀😀"); + yield return ChromaWhereDocumentOperator.Contains(" "); + yield return ChromaWhereDocumentOperator.Contains("abc") & ChromaWhereDocumentOperator.NotContains("x"); + yield return ChromaWhereDocumentOperator.Not(ChromaWhereDocumentOperator.Contains("ab") | ChromaWhereDocumentOperator.Regex("xyz")); + // The same checks for a filter given as a dictionary. + yield return ChromaWhereDocumentOperator.FromDictionary(new Dictionary { ["$regex"] = "^$" }); + yield return ChromaWhereDocumentOperator.FromDictionary(new Dictionary { ["$and"] = new object[] { new Dictionary { ["$contains"] = "abc" }, new Dictionary { ["$contains"] = "ab" } } }); + // And for one from JSON, whose values are JsonElement. + yield return ChromaWhereDocumentOperator.FromDictionary(FromJson("""{"$regex":"^$"}""")); + yield return ChromaWhereDocumentOperator.FromDictionary(FromJson("""{"$and":[{"$contains":"abc"},{"$or":[{"$regex":"x"},{"$not_contains":"ab"}]}]}""")); + // And for values of other types that the client writes: a typed dictionary, a JsonNode. + yield return ChromaWhereDocumentOperator.FromDictionary(new Dictionary { ["$or"] = new object[] { new Dictionary { ["$regex"] = "^$" }, new Dictionary { ["$contains"] = "abc" } } }); + yield return ChromaWhereDocumentOperator.FromDictionary(new Dictionary { ["$or"] = new object[] { System.Text.Json.Nodes.JsonNode.Parse("""{"$regex":"^$"}""")!, new Dictionary { ["$contains"] = "abc" } } }); + } + + static Dictionary FromJson(string json) => System.Text.Json.JsonSerializer.Deserialize>(json)!; + + static IEnumerable RightOnChromaCloud() + { + yield return ChromaWhereDocumentOperator.Regex("^x$"); + yield return ChromaWhereDocumentOperator.Regex("."); + yield return ChromaWhereDocumentOperator.Regex("(?s)."); + yield return ChromaWhereDocumentOperator.NotRegex("(?s)."); + yield return ChromaWhereDocumentOperator.NotRegex("^.+$"); + yield return ChromaWhereDocumentOperator.Regex("ab"); + // A ']' right after '[' or '[^' and an escaped '[' are characters of the class, not a nested class. + yield return ChromaWhereDocumentOperator.Regex("^[]a]+$"); + yield return ChromaWhereDocumentOperator.Regex("^[^]]$"); + yield return ChromaWhereDocumentOperator.Regex("[\\[]x*"); + yield return ChromaWhereDocumentOperator.Regex("a{2,}"); + // Written in a way that only Chroma reads, needing a character. + yield return ChromaWhereDocumentOperator.Regex("^[[:alpha:]]+$"); + yield return ChromaWhereDocumentOperator.Regex("(?Px)"); + yield return ChromaWhereDocumentOperator.Regex("\\b{start}x"); + yield return ChromaWhereDocumentOperator.Regex("(?x) a b # c"); + yield return ChromaWhereDocumentOperator.Contains("abc"); + yield return ChromaWhereDocumentOperator.NotContains("abc"); + yield return ChromaWhereDocumentOperator.Contains("αβγ"); + yield return ChromaWhereDocumentOperator.Contains("😀😀😀"); + yield return ChromaWhereDocumentOperator.Contains("abc") & ChromaWhereDocumentOperator.Regex("x"); + yield return ChromaWhereDocumentOperator.FromDictionary(new Dictionary { ["$contains"] = "abc" }); + yield return ChromaWhereDocumentOperator.FromDictionary(FromJson("""{"$and":[{"$contains":"abc"},{"$regex":"x"}]}""")); + } + + [Test] + public void ThrowsOnChromaCloud([ValueSource(nameof(WrongOnChromaCloud))] ChromaWhereDocumentOperator filter, [ValueSource(nameof(Operations))] string operation) + { + var server = new Server(); + var ex = Assert.ThrowsAsync(() => Run(Client(server, Cloud), operation, filter)); + Assert.That(ex!.Message, Does.StartWith("On Chroma Cloud the ").And.Contain("gives the wrong records")); + Assert.That(server.Paths, Is.Empty); + } + + [Test] + public async Task GoesOnChromaCloud([ValueSource(nameof(RightOnChromaCloud))] ChromaWhereDocumentOperator filter, [ValueSource(nameof(Operations))] string operation) + { + var server = new Server(); + await Run(Client(server, Cloud), operation, filter); + Assert.That(server.Paths, Does.Contain(operation.Split(' ')[0])); + } + + [Test] + public async Task GoesOnASingleServer([ValueSource(nameof(WrongOnChromaCloud))] ChromaWhereDocumentOperator filter, [ValueSource(nameof(Operations))] string operation) + { + var server = new Server(); + await Run(Client(server, Local), operation, filter); + Assert.That(server.Paths, Does.Contain(operation.Split(' ')[0])); + } + + // The message names the filter and what is right instead. + [TestCase("^$", "the pattern \"^$\" of $regex", "it takes an empty text")] + [TestCase("\\Z", "the pattern \"\\Z\" of $regex", "the client cannot read it")] + public void TheMessageOfAPattern(string pattern, string filter, string reason) + { + var ex = Assert.ThrowsAsync(() => Client(new Server(), Cloud).GetAsync(whereDocument: ChromaWhereDocumentOperator.Regex(pattern))); + Assert.That(ex!.Message, Does.Contain(filter).And.Contain(reason).And.Contain("Regex(\"(?s).\")").And.Contain("NotRegex(\"(?s).\")")); + Assert.That(ex.ParamName, Is.EqualTo("whereDocument")); + } + + [Test] + public void TheMessageOfAText() + { + var ex = Assert.ThrowsAsync(() => Client(new Server(), Cloud).GetAsync(whereDocument: ChromaWhereDocumentOperator.NotContains("ab"))); + Assert.That(ex!.Message, Does.Contain("the text \"ab\" of $not_contains").And.Contain("fewer than 3 characters").And.Contain("$regex")); + } + + // Chroma Cloud rejects a regex pattern beyond the quota of the tenant, in bytes of UTF-8: the client sends it, as the quota can be + // higher, and on the 422 throws a ChromaException that names the pattern, its bytes and the limit of the answer. + [Test] + public void TheQuotaOfARegexPattern([ValueSource(nameof(Operations))] string operation) + { + var pattern = new string('a', 257); + var server = new Server { RegexQuota = (257, 256) }; + var ex = Assert.ThrowsAsync(() => Run(Client(server, Cloud), operation, ChromaWhereDocumentOperator.Regex(pattern))); + Assert.That(server.Paths, Does.Contain(operation.Split(' ')[0])); + Assert.That(ex!.Message, Does.StartWith($"Chroma Cloud rejects the pattern \"{pattern}\" of $regex, of 257 bytes in UTF-8: the quota of the tenant for a regex pattern is 256 bytes").And.Contain("A tenant can ask for a higher quota: https://trychroma.com/request-quota-increase")); + Assert.That(ex.StatusCode, Is.EqualTo(HttpStatusCode.UnprocessableEntity)); + Assert.That(ex.ErrorType, Is.EqualTo("ChromaError")); + Assert.That(ex.InnerException, Is.InstanceOf()); + } + + // The bytes in UTF-8, and the limit of the answer, so a tenant with another quota reads its own. + [TestCase("é", 129, 256, 258)] + [TestCase("é", 128, 200, 256)] + [TestCase("😀", 65, 256, 260)] + public void TheQuotaOfARegexPatternInBytes(string character, int count, int limit, int bytes) + { + var pattern = string.Concat(Enumerable.Repeat(character, count)); + var ex = Assert.ThrowsAsync(() => Client(new Server { RegexQuota = (bytes, limit) }, Cloud).GetAsync(whereDocument: ChromaWhereDocumentOperator.NotRegex(pattern))); + Assert.That(ex!.Message, Does.StartWith($"Chroma Cloud rejects the pattern \"{pattern}\" of $not_regex, of {bytes} bytes in UTF-8: the quota of the tenant for a regex pattern is {limit} bytes")); + } + + // A filter given as a dictionary goes in every operation as the same filter written with the operators: get, query and delete send it as + // it is, and the Search API gets it on #document, also nested. + [Test] + public async Task FromDictionaryAsTheOperators([ValueSource(nameof(Operations))] string operation) + { + var dictionary = ChromaWhereDocumentOperator.FromDictionary(new Dictionary + { + ["$or"] = new object[] + { + new Dictionary { ["$contains"] = "abc" }, + new Dictionary { ["$and"] = new object[] { new Dictionary { ["$regex"] = "x+" }, new Dictionary { ["$not_contains"] = "zzz" } } }, + }, + }); + var operators = ChromaWhereDocumentOperator.Contains("abc") | (ChromaWhereDocumentOperator.Regex("x+") & ChromaWhereDocumentOperator.NotContains("zzz")); + var json = ChromaWhereDocumentOperator.FromDictionary(FromJson("""{"$or":[{"$contains":"abc"},{"$and":[{"$regex":"x+"},{"$not_contains":"zzz"}]}]}""")); + var last = operation.Split(' ')[0]; + var fromOperators = new Server(); + await Run(Client(fromOperators, Local), operation, operators); + foreach (var filter in new[] { dictionary, json }) + { + var server = new Server(); + await Run(Client(server, Local), operation, filter); + Assert.That(server.Body(last), Is.Not.Null.And.EqualTo(fromOperators.Body(last))); + Assert.That(filter.ToString(), Is.EqualTo(operators.ToString())); + } + } + + // ChromaWhereOperator.FromDictionary with #document in the where clause of the Search API: Chroma Cloud checks its texts too, also from + // JSON, before the search; a single server takes it. + [TestCase("""{"#document":{"$regex":"^$"}}""", true)] + [TestCase("""{"$and":[{"k":{"$eq":1}},{"#document":{"$contains":"ab"}}]}""", true)] + [TestCase("""{"#document":{"$contains":"abc"}}""", false)] + public async Task WhereFromDictionaryOnDocumentInTheSearch(string json, bool wrongOnChromaCloud) + { + foreach (var where in new[] { ChromaWhereOperator.FromDictionary(FromJson(json)), ChromaWhereOperator.FromDictionary(FromClr(System.Text.Json.JsonDocument.Parse(json).RootElement)) }) + { + var cloud = new Server(); + var search = new ChromaSearch { Where = where, Limit = 5 }; + if (wrongOnChromaCloud) + { + Assert.ThrowsAsync(() => Client(cloud, Cloud).SearchAsync(search)); + Assert.That(cloud.Paths, Is.Empty); + } + else + { + await Client(cloud, Cloud).SearchAsync(search); + Assert.That(cloud.Paths, Does.Contain("search")); + } + var local = new Server(); + await Client(local, Local).SearchAsync(search); + Assert.That(local.Paths, Does.Contain("search")); + } + } + + // The same dictionary with .NET values: objects as Dictionary, arrays as object[], and the scalars as they are. + static Dictionary FromClr(System.Text.Json.JsonElement element) + => element.EnumerateObject().ToDictionary(property => property.Name, property => Clr(property.Value)); + + static object Clr(System.Text.Json.JsonElement element) => element.ValueKind switch + { + System.Text.Json.JsonValueKind.Object => FromClr(element), + System.Text.Json.JsonValueKind.Array => element.EnumerateArray().Select(Clr).ToArray(), + System.Text.Json.JsonValueKind.String => element.GetString()!, + _ => element.GetInt64(), + }; + + static IEnumerable> NotForTheSearch() + { + yield return new Dictionary { ["$foo"] = "x" }; + yield return new Dictionary { ["#document"] = new Dictionary { ["$contains"] = "abc" } }; + yield return new Dictionary { ["$contains"] = "abc", ["$regex"] = "x" }; + yield return new Dictionary(); + yield return new Dictionary { ["$contains"] = 3 }; + yield return new Dictionary { ["$and"] = "abc" }; + yield return new Dictionary { ["$or"] = new object[] { new Dictionary { ["$contains"] = "abc" }, new Dictionary { ["$bar"] = "x" } } }; + } + + // What the client cannot write for the Search API throws there without a search, and a get sends it as it is. + [Test] + public async Task FromDictionaryThatTheSearchCannotWrite([ValueSource(nameof(NotForTheSearch))] IReadOnlyDictionary filter) + { + foreach (var operation in new[] { "search", "search with where document" }) + { + var server = new Server(); + var ex = Assert.ThrowsAsync(() => Run(Client(server, Local), operation, ChromaWhereDocumentOperator.FromDictionary(filter))); + Assert.That(ex!.Message, Does.StartWith("The filter on the documents given as a dictionary ")); + Assert.That(server.Paths, Does.Not.Contain("search")); + } + var get = new Server(); + await Client(get, Local).GetAsync(whereDocument: ChromaWhereDocumentOperator.FromDictionary(filter)); + Assert.That(System.Text.Json.JsonDocument.Parse(get.Body("get")!).RootElement.GetProperty("where_document").GetRawText(), Is.EqualTo(System.Text.Json.JsonSerializer.Serialize(filter))); + } + + [Test] + public void FromDictionaryNullAndNot() + { + Assert.Throws(() => ChromaWhereDocumentOperator.FromDictionary(null!)); + Assert.Throws(() => ChromaWhereDocumentOperator.Not(ChromaWhereDocumentOperator.FromDictionary(new Dictionary { ["$contains"] = "abc" }))); + } + + // A filter that get rejects, a condition on the documents joined with |, fails the task as before: the check leaves it to the get, on + // Chroma Cloud as on a single server. + [TestCase(Cloud)] + [TestCase(Local)] + public void AFilterThatTheGetRejectsFailsItsTask(string uri) + { + var where = ChromaWhereOperator.Document(ChromaWhereDocumentOperator.Regex("^$")) | ChromaWhereOperator.Equal("k", 1L); + Task? get = null; + Assert.That(() => { get = Client(new Server(), uri).GetAsync(where: where); }, Throws.Nothing); + Assert.That(() => get!, Throws.InstanceOf()); + } + + static Task Run(ChromaCollectionClient client, string operation, ChromaWhereDocumentOperator filter) => operation switch + { + "get" => client.GetAsync(whereDocument: filter), + "get with where" => client.GetAsync(where: ChromaWhereOperator.Document(filter)), + "query" => client.QueryAsync(new ChromaQuery([Embedding]) { WhereDocument = filter }), + "delete" => client.DeleteAsync(["a"], whereDocument: filter), + "delete by filters" => client.DeleteAsync(new ChromaDelete { WhereDocument = filter }), + "search" => client.SearchAsync(new ChromaSearch { Where = ChromaWhereOperator.Document(filter), Limit = 5 }), + _ => client.SearchAsync(new ChromaSearch { WhereDocument = filter, Limit = 5 }), + }; + + static ChromaCollectionClient Client(HttpMessageHandler handler, string uri) + => new(new ChromaCollection("c") { Id = Guid.NewGuid() }, new ChromaConfigurationOptions(uri), new HttpClient(handler)); + + // Answers each request with an empty result, and records the last segment of its path. With RegexQuota, a get, query, search or + // delete gets the 422 of Chroma Cloud for a regex pattern beyond its quota. + sealed class Server : HttpMessageHandler + { + public List Paths { get; } = []; + public List Bodies { get; } = []; + public (int Usage, int Limit)? RegexQuota { get; init; } + + // The body of the last request whose path ends with the segment. + public string? Body(string last) => Bodies[Paths.LastIndexOf(last)]; + + protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + var path = request.RequestUri!.AbsolutePath; + var last = path[(path.LastIndexOf('/') + 1)..]; + Paths.Add(last); + Bodies.Add(request.Content?.ReadAsStringAsync().GetAwaiter().GetResult()); + if (RegexQuota is var (usage, limit) && last is "get" or "query" or "search" or "delete") + { + var action = char.ToUpperInvariant(last[0]) + last[1..]; + var message = $"Quota exceeded: 'Length of regex pattern' exceeded quota limit for action '{action}': current usage of {usage} exceeds limit of {limit}. https://trychroma.com/request-quota-increase?tenant=t&action={action}"a=RegexPatternLength¤t={limit}&request={usage}"; + return Task.FromResult(new HttpResponseMessage(HttpStatusCode.UnprocessableEntity) { Content = new StringContent($$"""{"error":"ChromaError","message":"{{message}}"}""") }); + } + var answer = last switch + { + "version" => "\"1.5.9\"", + "pre-flight-checks" => """{"max_batch_size":100,"supports_base64_encoding":false}""", + "get" => """{"ids":[],"include":[]}""", + "query" => """{"ids":[[]],"include":[]}""", + "search" => """{"ids":[[]]}""", + "delete" => """{"deleted":0}""", + _ => "{}", + }; + return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent(answer) }); + } + } +} diff --git a/ChromaDB.Client.Tests/EmbeddingGeneratorServerTests.cs b/ChromaDB.Client.Tests/EmbeddingGeneratorServerTests.cs new file mode 100644 index 0000000..c9035df --- /dev/null +++ b/ChromaDB.Client.Tests/EmbeddingGeneratorServerTests.cs @@ -0,0 +1,63 @@ +#if !CHROMA_CLIENT_NETSTANDARD2_0 +using ChromaDB.Client.Models; +using Microsoft.Extensions.AI; +using NUnit.Framework; + +namespace ChromaDB.Client.Tests; + +// The embeddings computed by a generator, on the server: the records written with their documents only are found by the query texts. A new +// collection with the generator of Chroma declares "default" on Chroma 1.x, and on 0.x goes without. +[TestFixture] +public class EmbeddingGeneratorServerTests : ChromaTestsBase +{ + [Test] + public async Task DocumentsAndQueryTexts() + { + var client = new ChromaClient(BaseConfigurationOptions, HttpClient); + var collection = await client.CreateCollectionAsync(new ChromaCollectionDefinition($"collection{Random.Shared.Next()}")); + var records = client.GetCollectionClient(collection).WithEmbeddingGenerator(new LetterGenerator()); + await records.AddAsync(["a", "b"], documents: ["apple", "banana"]); + await records.UpsertAsync(["c"], documents: ["cherry"]); + await records.UpdateAsync(["b"], documents: ["blueberry"]); + Assert.That((await records.QueryAsync("avocado", nResults: 1)).Single().Id, Is.EqualTo("a")); + var nearest = await records.QueryTextsAsync(["bean", "coconut"], nResults: 1); + Assert.That(nearest.Select(x => (x.Single().Id, x.Single().Document)), Is.EqualTo(new[] { ("b", "blueberry"), ("c", "cherry") })); + var stored = await records.GetAsync(ids: ["c"], include: ChromaGetInclude.Embeddings); + Assert.That(stored.Single().Embedding!.Value.ToArray(), Is.EqualTo(LetterGenerator.Embed("cherry"))); + } + + [Test] + public async Task DefaultEmbeddingFunction() + { + var client = new ChromaClient(BaseConfigurationOptions, HttpClient); + var collection = await client.CreateCollectionAsync(new ChromaCollectionDefinition($"collection{Random.Shared.Next()}") { EmbeddingGenerator = new LetterGenerator("chroma", "all-MiniLM-L6-v2") }); + var read = await client.GetCollectionAsync(collection.Name); + string? Declared(ChromaCollection c) + => c.ConfigurationJson is { ValueKind: System.Text.Json.JsonValueKind.Object } configuration + && configuration.TryGetProperty("embedding_function", out var function) && function.ValueKind == System.Text.Json.JsonValueKind.Object + ? function.GetProperty("type").GetString() + " " + function.GetProperty("name").GetString() : null; + // Chroma 1.0.0 to 1.0.5 take the embedding function without reporting it, as the space (docs/COMPATIBILITY.md). + Assert.That((Declared(collection), Declared(read)), Is.EqualTo(IsChroma0 || !ConfigurationSpaceReported ? (null, null) : ("known default", "known default"))); + } + + // One dimension for each letter from a to e, by the first letter of the text, and one for the rest: texts that start with the same + // letter are nearest. + sealed class LetterGenerator(string provider = "test", string model = "letters") : IEmbeddingGenerator> + { + public static float[] Embed(string text) + { + var embedding = new float[6]; + embedding[text.Length > 0 && text[0] is >= 'a' and <= 'e' ? text[0] - 'a' : 5] = 1f; + return embedding; + } + + public Task>> GenerateAsync(IEnumerable values, EmbeddingGenerationOptions? options = null, CancellationToken cancellationToken = default) + => Task.FromResult(new GeneratedEmbeddings>(values.Select(text => new Embedding(Embed(text))))); + + public object? GetService(Type serviceType, object? serviceKey = null) + => serviceType == typeof(EmbeddingGeneratorMetadata) ? new EmbeddingGeneratorMetadata(provider, null, model, 6) : null; + + public void Dispose() { } + } +} +#endif diff --git a/ChromaDB.Client.Tests/EmbeddingGeneratorTests.cs b/ChromaDB.Client.Tests/EmbeddingGeneratorTests.cs new file mode 100644 index 0000000..a624f1c --- /dev/null +++ b/ChromaDB.Client.Tests/EmbeddingGeneratorTests.cs @@ -0,0 +1,206 @@ +#if !CHROMA_CLIENT_NETSTANDARD2_0 +using System.Net; +using System.Text.Json; +using ChromaDB.Client.Models; +using Microsoft.Extensions.AI; +using NUnit.Framework; + +namespace ChromaDB.Client.Tests; + +// The embeddings computed by a generator, as an embedding function does in the Python client of Chroma: from the documents of the records +// given without embeddings, and from the query texts. A new collection with the generator of Chroma declares "default". +[TestFixture] +public class EmbeddingGeneratorTests +{ + static readonly ReadOnlyMemory Embedding = new([9f, 9f]); + + [Test] + public async Task WritesComputeTheEmbeddingsOfTheDocuments() + { + var server = new FakeServer(); + var generator = new FakeGenerator(); + var client = Client(server).WithEmbeddingGenerator(generator); + await client.AddAsync(["a", "b"], documents: ["apple", "kiwi"]); + await client.UpdateAsync(["a"], documents: ["banana"]); + await client.UpsertAsync(new ChromaRecords(["c"]) { Documents = ["fig"] }); + Assert.That(generator.Calls, Is.EqualTo(new[] { new[] { "apple", "kiwi" }, new[] { "banana" }, new[] { "fig" } })); + Assert.That(server.Embeddings("/add"), Is.EqualTo(new[] { new[] { 5f, 1f }, new[] { 4f, 1f } })); + Assert.That(server.Embeddings("/update"), Is.EqualTo(new[] { new[] { 6f, 1f } })); + Assert.That(server.Embeddings("/upsert"), Is.EqualTo(new[] { new[] { 3f, 1f } })); + Assert.That(server.Body("/add").GetProperty("documents").EnumerateArray().Select(x => x.GetString()), Is.EqualTo(new[] { "apple", "kiwi" })); + } + + // Records given with embeddings keep them, and records without documents have nothing to compute from. + [Test] + public async Task GivenEmbeddingsAndRecordsWithoutDocumentsGoAsTheyAre() + { + var server = new FakeServer(); + var generator = new FakeGenerator(); + var client = Client(server).WithEmbeddingGenerator(generator); + await client.AddAsync(["a"], [Embedding], documents: ["apple"]); + await client.UpdateAsync(["a"], metadatas: [new Dictionary { ["k"] = 1L }]); + Assert.That(generator.Calls, Is.Empty); + Assert.That(server.Embeddings("/add"), Is.EqualTo(new[] { new[] { 9f, 9f } })); + Assert.That(server.Body("/update").TryGetProperty("embeddings", out var embeddings) && embeddings.ValueKind != JsonValueKind.Null, Is.False); + } + + // A null document has no text to compute from: the write throws before any request. + [Test] + public void NullDocumentWithoutEmbeddings() + { + var server = new FakeServer(); + var client = Client(server).WithEmbeddingGenerator(new FakeGenerator()); + Assert.That(() => client.UpdateAsync(["a", "b"], documents: ["apple", null!]), Throws.ArgumentException.With.Message.Contains("null document")); + Assert.That(() => client.UpsertAsync(new ChromaRecords(["a"]) { Documents = [null], NullDocumentsDelete = true }), Throws.ArgumentException); + Assert.That(server.Paths, Is.Empty); + } + + [Test] + public async Task QueryTexts() + { + var server = new FakeServer(); + var generator = new FakeGenerator(); + var client = Client(server).WithEmbeddingGenerator(generator); + var one = await client.QueryAsync("apple", nResults: 3); + Assert.That(one.Select(x => x.Id), Is.EqualTo(new[] { "r0" })); + Assert.That(server.Body("/query").GetProperty("n_results").GetInt32(), Is.EqualTo(3)); + var two = await client.QueryTextsAsync(["apple", "kiwi"]); + Assert.That(two.Select(x => x.Single().Id), Is.EqualTo(new[] { "r0", "r1" })); + Assert.That(server.Embeddings("/query", "query_embeddings"), Is.EqualTo(new[] { new[] { 5f, 1f }, new[] { 4f, 1f } })); + Assert.That(generator.Calls, Is.EqualTo(new[] { new[] { "apple" }, new[] { "apple", "kiwi" } })); + } + + [Test] + public void QueryTextsNeedAGenerator() + { + var server = new FakeServer(); + var client = Client(server); + Assert.That(() => client.QueryAsync("apple"), Throws.InvalidOperationException.With.Message.Contains("WithEmbeddingGenerator")); + Assert.That(() => client.QueryTextsAsync(["apple"]), Throws.InvalidOperationException); + Assert.That(() => client.WithEmbeddingGenerator(new FakeGenerator()).QueryTextsAsync(["apple", null!]), Throws.ArgumentException); + Assert.That(() => client.WithEmbeddingGenerator(null!), Throws.ArgumentNullException); + Assert.That(server.Paths, Is.Empty); + } + + // A generator that returns another number of embeddings than of texts. + [Test] + public void EmbeddingsForEveryText() + { + var client = Client(new FakeServer()).WithEmbeddingGenerator(new FakeGenerator { Extra = 1 }); + Assert.That(() => client.AddAsync(["a"], documents: ["apple"]), Throws.InvalidOperationException.With.Message.Contains("2 embeddings for 1 texts")); + Assert.That(() => client.QueryAsync("apple"), Throws.InvalidOperationException); + } + + // The computed embeddings must be finite, as the given ones: a write throws before any batch, also with base64 embeddings. + [Test] + public void FiniteComputedEmbeddings() + { + var server = new FakeServer(base64: true); + var client = Client(server).WithEmbeddingGenerator(new FakeGenerator { Number = float.NaN }); + Assert.That(() => client.AddAsync(["a"], documents: ["apple"]), Throws.ArgumentException.With.Message.Contains("NaN")); + Assert.That(() => client.UpsertAsync(["a"], documents: ["apple"]), Throws.ArgumentException); + Assert.That(server.Paths, Is.Empty); + } + + // The other With methods keep the generator, and it keeps their settings. + [Test] + public async Task GeneratorStaysWithTheOtherSettings() + { + var server = new FakeServer(); + var generator = new FakeGenerator(); + var client = Client(server).WithEmbeddingGenerator(generator).WithDocumentCopyKey("text"); + Assert.That(client.EmbeddingGenerator, Is.SameAs(generator)); + await client.AddAsync(["a"], documents: ["apple"]); + Assert.That(server.Body("/add").GetProperty("metadatas")[0].GetProperty("text").GetString(), Is.EqualTo("apple")); + Assert.That(server.Embeddings("/add"), Is.EqualTo(new[] { new[] { 5f, 1f } })); + Assert.That(Client(server).EmbeddingGenerator, Is.Null); + Assert.That(Client(server).WithDocumentCopyKey("text").WithEmbeddingGenerator(generator).WithMetadataValues(ChromaMetadataValues.Exact).EmbeddingGenerator, Is.SameAs(generator)); + } + + // The generator of the default embedding function of Chroma has the provider "chroma" and the model + // all-MiniLM-L6-v2: a new collection with it declares "default", with no settings, as the Python client of Chroma 1.5.9 stores it. + [TestCase("chroma", "all-MiniLM-L6-v2", "1.5.9", """{"type":"known","name":"default","config":{}}""")] + [TestCase("other", "all-MiniLM-L6-v2", "1.5.9", null)] + [TestCase("chroma", "other", "1.5.9", null)] + [TestCase("chroma", "all-MiniLM-L6-v2", "0.6.3", null)] + public async Task DefaultEmbeddingFunction(string provider, string model, string version, string? declared) + { + var server = new FakeServer(version); + var client = new ChromaClient(new ChromaConfigurationOptions("http://localhost:8000"), new HttpClient(server)); + var generator = new FakeGenerator(provider, model); + await client.CreateCollectionAsync(new ChromaCollectionDefinition("c") { EmbeddingGenerator = generator }); + await client.GetOrCreateCollectionAsync(new ChromaCollectionDefinition("c") { EmbeddingGenerator = generator, Metadata = new Dictionary { ["k"] = 1L } }); + Assert.That(server.Configurations, Is.EqualTo(new[] { declared, declared })); + } + + // The settings of the configuration stay, and a function it declares wins over the one of the generator. + [Test] + public async Task DeclaredFunctionAndSettingsStay() + { + var server = new FakeServer(); + var client = new ChromaClient(new ChromaConfigurationOptions("http://localhost:8000"), new HttpClient(server)); + var generator = new FakeGenerator("chroma", "all-MiniLM-L6-v2"); + await client.CreateCollectionAsync(new ChromaCollectionDefinition("c") { EmbeddingGenerator = generator, Configuration = new() { Space = ChromaSpace.Cosine } }); + await client.CreateCollectionAsync(new ChromaCollectionDefinition("c") { EmbeddingGenerator = generator, Configuration = new() { EmbeddingFunction = ChromaEmbeddingFunctionReference.Known("mine") } }); + await client.CreateCollectionAsync(new ChromaCollectionDefinition("c")); + Assert.That(server.Configurations, Is.EqualTo(new[] { """{"type":"known","name":"default","config":{}}""", """{"type":"known","name":"mine","config":{}}""", null })); + Assert.That(server.Bodies.First(x => x.Path.EndsWith("/collections")).Body.GetProperty("metadata").GetProperty("hnsw:space").GetString(), Is.EqualTo("cosine")); + } + + static ChromaCollectionClient Client(HttpMessageHandler handler) + => new(new ChromaCollection("c") { Id = Guid.NewGuid() }, new ChromaConfigurationOptions("http://localhost:8000"), new HttpClient(handler)); + + // The embedding of a text: its length, and 1 or the number given. + sealed class FakeGenerator(string provider = "test", string model = "test") : IEmbeddingGenerator> + { + public List Calls { get; } = []; + public int Extra { get; set; } + public float Number { get; set; } = 1f; + + public Task>> GenerateAsync(IEnumerable values, EmbeddingGenerationOptions? options = null, CancellationToken cancellationToken = default) + { + var texts = values.ToArray(); + Calls.Add(texts); + var embeddings = texts.Concat(Enumerable.Repeat("", Extra)).Select(text => new Embedding(new[] { (float)text.Length, Number })); + return Task.FromResult(new GeneratedEmbeddings>(embeddings)); + } + + public object? GetService(Type serviceType, object? serviceKey = null) + => serviceType == typeof(EmbeddingGeneratorMetadata) ? new EmbeddingGeneratorMetadata(provider, null, model, 2) : null; + + public void Dispose() { } + } + + // Answers the version, pre-flight-checks, a new collection, a query with one record per embedding, and the writes with nothing; records + // the path and the body of each request, and the embedding function of the configuration of each new collection. + sealed class FakeServer(string version = "1.5.9", bool base64 = false) : HttpMessageHandler + { + public List<(string Path, JsonElement Body)> Bodies { get; } = []; + public List Configurations { get; } = []; + public IEnumerable Paths => Bodies.Select(x => x.Path).Where(path => !path.EndsWith("/version") && !path.EndsWith("/pre-flight-checks")); + + public JsonElement Body(string path) => Bodies.Last(x => x.Path.EndsWith(path)).Body; + + public float[][] Embeddings(string path, string name = "embeddings") + => Body(path).GetProperty(name).EnumerateArray().Select(x => x.EnumerateArray().Select(v => v.GetSingle()).ToArray()).ToArray(); + + protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + var path = request.RequestUri!.AbsolutePath; + var body = request.Content is null ? default : JsonDocument.Parse(await request.Content.ReadAsStringAsync()).RootElement.Clone(); + Bodies.Add((path, body)); + if (path.EndsWith("/collections")) + { + Configurations.Add(body.TryGetProperty("configuration", out var configuration) && configuration.ValueKind == JsonValueKind.Object + && configuration.TryGetProperty("embedding_function", out var function) ? function.GetRawText() : null); + } + var answer = path.EndsWith("/version") ? "\"" + version + "\"" + : path.EndsWith("/pre-flight-checks") ? "{\"max_batch_size\":100,\"supports_base64_encoding\":" + (base64 ? "true" : "false") + "}" + : path.EndsWith("/collections") ? """{"id":"11111111-2222-3333-4444-555555555555","name":"c"}""" + : path.EndsWith("/query") ? "{\"ids\":[" + string.Join(",", body.GetProperty("query_embeddings").EnumerateArray().Select((_, i) => "[\"r" + i + "\"]")) + "]}" + : "{}"; + return new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent(answer) }; + } + } +} +#endif diff --git a/ChromaDB.Client.Tests/EmbeddingsAndErrorsTests.cs b/ChromaDB.Client.Tests/EmbeddingsAndErrorsTests.cs new file mode 100644 index 0000000..dee79b5 --- /dev/null +++ b/ChromaDB.Client.Tests/EmbeddingsAndErrorsTests.cs @@ -0,0 +1,47 @@ +using ChromaDB.Client.Models; +using NUnit.Framework; + +namespace ChromaDB.Client.Tests; + +[TestFixture] +public class EmbeddingsAndErrorsTests : ChromaTestsBase +{ + // Base64 from Chroma 1.0.13, which declares it, numbers before: the server keeps the same float32 values either way. + [Test] + public async Task EmbeddingsReadBackBitForBit() + { + var client = new ChromaClient(BaseConfigurationOptions, HttpClient); + var collectionClient = client.GetCollectionClient(await client.CreateCollectionAsync($"collection{Random.Shared.Next()}")); + ReadOnlyMemory[] embeddings = [new([0.1f, -2.5f, 3.25f, 1e-7f]), new([123456.79f, -0.333333343f, 1f / 3f, 6.5e-3f])]; + await collectionClient.AddAsync(new ChromaRecords(["a", "b"]) { Embeddings = [.. embeddings] }); + await collectionClient.UpsertAsync(new ChromaRecords(["c"]) { Embeddings = [embeddings[1]] }); + await collectionClient.UpdateAsync(new ChromaRecords(["a"]) { Embeddings = [embeddings[1]] }); + var result = (await collectionClient.GetAsync(["a", "b", "c"], include: ChromaGetInclude.Embeddings)).ToDictionary(x => x.Id, x => x.Embedding!.Value.ToArray()); + Assert.That(result["a"], Is.EqualTo(embeddings[1].ToArray())); + Assert.That(result["b"], Is.EqualTo(embeddings[1].ToArray())); + Assert.That(result["c"], Is.EqualTo(embeddings[1].ToArray())); + } + + [Test] + public async Task BatchesOfTheLimitOfTheCaller() + { + var options = BaseConfigurationOptions.WithBatchSplitting(maxBatchSize: 2); + var client = new ChromaClient(options, HttpClient); + var collectionClient = client.GetCollectionClient(await client.CreateCollectionAsync($"collection{Random.Shared.Next()}")); + var ids = new List { "a", "b", "c", "d", "e" }; + await collectionClient.AddAsync(new ChromaRecords(ids) { Embeddings = Enumerable.Repeat(new ReadOnlyMemory([1f, 0f]), 5).ToList() }); + Assert.That(await collectionClient.CountAsync(), Is.EqualTo(5)); + await collectionClient.DeleteAsync(ids); + Assert.That(await collectionClient.CountAsync(), Is.EqualTo(0)); + } + + // The kind of error of a missing collection: ValueError from the v1 API of Chroma 0.4.10 to 0.5.5, + // InvalidCollection from 0.5.6 to 0.6.3, NotFoundError from 1.x. + [Test] + public void ErrorTypeOfAMissingCollection() + { + var expected = IsChroma1 ? "NotFoundError" : ChromaDB.Client.Tests.TestContainer.ChromaImage.Version >= new Version(0, 5, 6) ? "InvalidCollection" : "ValueError"; + var ex = Assert.ThrowsAsync(() => new ChromaClient(BaseConfigurationOptions, HttpClient).GetCollectionAsync($"collection{Random.Shared.Next()}")); + Assert.That(ex!.ErrorType, Is.EqualTo(expected)); + } +} diff --git a/ChromaDB.Client.Tests/ExceptionTests.cs b/ChromaDB.Client.Tests/ExceptionTests.cs new file mode 100644 index 0000000..7539744 --- /dev/null +++ b/ChromaDB.Client.Tests/ExceptionTests.cs @@ -0,0 +1,48 @@ +using System.Net; +using NUnit.Framework; + +namespace ChromaDB.Client.Tests; + +// What a ChromaException wraps: the failures of the request, and nothing else. +[TestFixture] +public class ExceptionTests +{ + [Test] + public void NetworkErrorIsAChromaException() + { + var client = Client(_ => throw new HttpRequestException("Connection refused")); + Assert.That(() => client.HeartbeatAsync(), Throws.InstanceOf().With.InnerException.InstanceOf()); + } + + [Test] + public void AnswerThatIsNotJsonIsAChromaException() + { + var client = Client(_ => new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent("") }); + Assert.That(() => client.HeartbeatAsync(), Throws.InstanceOf().With.InnerException.InstanceOf()); + } + + // A timeout is a cancellation the caller did not ask for. + [Test] + public void TimeoutIsAChromaException() + { + var client = Client(_ => throw new TaskCanceledException("The request was canceled due to the configured HttpClient.Timeout")); + Assert.That(() => client.HeartbeatAsync(), Throws.InstanceOf().With.InnerException.InstanceOf()); + } + + // Not about Chroma, like an assembly that does not load: it goes as it is. + [Test] + public void OtherExceptionsGoAsTheyAre() + { + var client = Client(_ => throw new FileLoadException("Could not load file or assembly 'System.Numerics.Vectors'")); + Assert.That(() => client.HeartbeatAsync(), Throws.InstanceOf()); + } + + static ChromaClient Client(Func answer) + => new(new ChromaConfigurationOptions("http://localhost:8000"), new HttpClient(new Handler(answer))); + + sealed class Handler(Func answer) : HttpMessageHandler + { + protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + => Task.FromResult(answer(request)); + } +} diff --git a/ChromaDB.Client.Tests/FilterConstantsTests.cs b/ChromaDB.Client.Tests/FilterConstantsTests.cs new file mode 100644 index 0000000..6cd00a8 --- /dev/null +++ b/ChromaDB.Client.Tests/FilterConstantsTests.cs @@ -0,0 +1,169 @@ +using System.Net; +using System.Text.Json; +using ChromaDB.Client.Models; +using NUnit.Framework; + +namespace ChromaDB.Client.Tests; + +// Chroma has no $not and no filter that matches every record or none: the client pushes a negation into the operators, sends no +// where for All, and no request for None. +[TestFixture] +public class FilterConstantsTests +{ + static readonly ChromaWhereOperator A = ChromaWhereOperator.Equal("a", 1); + static readonly ChromaWhereOperator B = ChromaWhereOperator.Equal("b", 2); + + [Test] + public void AllAndNone() + { + Assert.That((ChromaWhereOperator.All.ToString(), ChromaWhereOperator.None.ToString()), Is.EqualTo(("true", "false"))); + Assert.That(() => ChromaWhereOperator.None.ToWhere(), Throws.InvalidOperationException.With.Message.Contains("None")); + Assert.That(() => ChromaWhereOperator.All.ToWhere(), Throws.InvalidOperationException.With.Message.Contains("All")); + Assert.That(ChromaWhereOperator.ToRequestWhere(ChromaWhereOperator.All), Is.Null); + Assert.That(ChromaWhereOperator.ToRequestWhere(null), Is.Null); + } + + [Test] + public void InAndNotInWithoutValues() + { + Assert.That(ChromaWhereOperator.In("k"), Is.SameAs(ChromaWhereOperator.None)); + Assert.That(ChromaWhereOperator.NotIn("k", []), Is.SameAs(ChromaWhereOperator.All)); + } + + [Test] + public void AndAndOrWithAllAndNone() + { + var all = ChromaWhereOperator.All; + var none = ChromaWhereOperator.None; + Assert.That(A & none, Is.SameAs(none)); + Assert.That(none & A, Is.SameAs(none)); + Assert.That(all & A, Is.SameAs(A)); + Assert.That(A & all, Is.SameAs(A)); + Assert.That(A | all, Is.SameAs(all)); + Assert.That(all | A, Is.SameAs(all)); + Assert.That(none | A, Is.SameAs(A)); + Assert.That(A | none, Is.SameAs(A)); + Assert.That((A & B).ToString(), Is.EqualTo("""{"$and":[{"a":{"$eq":1}},{"b":{"$eq":2}}]}""")); + } + + [TestCase("$eq", "$ne")] + [TestCase("$ne", "$eq")] + [TestCase("$gt", "$lte")] + [TestCase("$gte", "$lt")] + [TestCase("$lt", "$gte")] + [TestCase("$lte", "$gt")] + [TestCase("$in", "$nin")] + [TestCase("$nin", "$in")] + [TestCase("$contains", "$not_contains")] + [TestCase("$not_contains", "$contains")] + public void NotOfAComparison(string @operator, string negated) + { + var filter = @operator switch + { + "$eq" => ChromaWhereOperator.Equal("k", 1), + "$ne" => ChromaWhereOperator.NotEqual("k", 1), + "$gt" => ChromaWhereOperator.GreaterThan("k", 1), + "$gte" => ChromaWhereOperator.GreaterThanOrEqual("k", 1), + "$lt" => ChromaWhereOperator.LessThan("k", 1), + "$lte" => ChromaWhereOperator.LessThanOrEqual("k", 1), + "$in" => ChromaWhereOperator.In("k", 1), + "$nin" => ChromaWhereOperator.NotIn("k", 1), + "$contains" => ChromaWhereOperator.Contains("k", 1), + _ => ChromaWhereOperator.NotContains("k", 1), + }; + var value = @operator is "$in" or "$nin" ? "[1]" : "1"; + Assert.That(ChromaWhereOperator.Not(filter).ToString(), Is.EqualTo("{\"k\":{\"" + negated + "\":" + value + "}}")); + Assert.That(ChromaWhereOperator.Not(ChromaWhereOperator.Not(filter)).ToString(), Is.EqualTo(filter.ToString())); + } + + // !(a && (b || c)) is !a || (!b && !c); the negation of All is None, and of None All. + [Test] + public void NotOfAndAndOr() + { + var c = ChromaWhereOperator.Equal("c", 3); + Assert.That(ChromaWhereOperator.Not(A & (B | c)).ToString(), + Is.EqualTo("""{"$or":[{"a":{"$ne":1}},{"$and":[{"b":{"$ne":2}},{"c":{"$ne":3}}]}]}""")); + Assert.That(ChromaWhereOperator.Not(ChromaWhereOperator.All), Is.SameAs(ChromaWhereOperator.None)); + Assert.That(ChromaWhereOperator.Not(ChromaWhereOperator.None), Is.SameAs(ChromaWhereOperator.All)); + } + + [Test] + public void NotOfADocumentFilter() + { + var contains = ChromaWhereDocumentOperator.Contains("x"); + var regex = ChromaWhereDocumentOperator.Regex("^a"); + Assert.That(ChromaWhereDocumentOperator.Not(contains).ToString(), Is.EqualTo("""{"$not_contains":"x"}""")); + Assert.That(ChromaWhereDocumentOperator.Not(ChromaWhereDocumentOperator.NotContains("x")).ToString(), Is.EqualTo("""{"$contains":"x"}""")); + Assert.That(ChromaWhereDocumentOperator.Not(regex).ToString(), Is.EqualTo("""{"$not_regex":"^a"}""")); + Assert.That(ChromaWhereDocumentOperator.Not(ChromaWhereDocumentOperator.NotRegex("^a")).ToString(), Is.EqualTo("""{"$regex":"^a"}""")); + Assert.That(ChromaWhereDocumentOperator.Not(contains & regex).ToString(), Is.EqualTo("""{"$or":[{"$not_contains":"x"},{"$not_regex":"^a"}]}""")); + Assert.That(ChromaWhereDocumentOperator.Not(contains | regex).ToString(), Is.EqualTo("""{"$and":[{"$not_contains":"x"},{"$not_regex":"^a"}]}""")); + } + + [Test] + public async Task NoneSendsNoRequest() + { + var server = new FakeServer(); + var client = Client(server); + var none = ChromaWhereOperator.None; + Assert.That(await client.GetAsync(where: none), Is.Empty); + Assert.That(await client.GetAsync(["a"], where: none), Is.Empty); + var query = await client.QueryAsync(new ChromaQuery([new([1f, 0f]), new([0f, 1f])]) { Where = none }); + Assert.That(query, Has.Count.EqualTo(2).And.All.Empty); + await client.DeleteAsync(["a"], where: none); + Assert.That(await client.DeleteAsync(new ChromaDelete { Where = none }), Is.EqualTo(0)); + var search = await client.SearchAsync([new ChromaSearch { Where = none }, new ChromaSearch { Where = none }]); + Assert.That(search, Has.Count.EqualTo(2).And.All.Empty); + Assert.That(server.Requests, Is.Empty); + } + + // A search with None is left out of the request, and its results are empty in its place. + [Test] + public async Task SearchesWithNoneAmongOthers() + { + var server = new FakeServer(); + var results = await Client(server).SearchAsync([new ChromaSearch { Where = ChromaWhereOperator.None }, new ChromaSearch { Where = A }, new ChromaSearch { Where = ChromaWhereOperator.All }]); + Assert.That(results.Select(x => x.Select(e => e.Id)), Is.EqualTo(new[] { new string[0], ["r0"], ["r1"] })); + var searches = server.Requests.Single().Body.GetProperty("searches").EnumerateArray().ToList(); + Assert.That(searches, Has.Count.EqualTo(2)); + Assert.That(searches[0].GetProperty("filter").GetRawText(), Is.EqualTo("""{"a":{"$eq":1}}""")); + Assert.That(searches[1].GetProperty("filter").ValueKind, Is.EqualTo(JsonValueKind.Null)); + } + + // All is no where clause; a delete needs ids or a filter, and All is not one. + [Test] + public async Task AllSendsNoWhere() + { + var server = new FakeServer(); + var client = Client(server); + await client.GetAsync(["a"], where: ChromaWhereOperator.All); + await client.QueryAsync(new ChromaQuery([new([1f, 0f])]) { Where = ChromaWhereOperator.All }); + await client.DeleteAsync(["a"], where: ChromaWhereOperator.All); + await client.DeleteAsync(new ChromaDelete { Ids = ["a"], Where = ChromaWhereOperator.All }); + Assert.That(server.Requests.Select(x => x.Body.GetProperty("where").ValueKind), Has.All.EqualTo(JsonValueKind.Null)); + Assert.That(() => client.DeleteAsync(new ChromaDelete { Where = ChromaWhereOperator.All }), Throws.ArgumentException); + Assert.That(() => client.DeleteAsync(new ChromaDelete { Ids = ["a"], Where = ChromaWhereOperator.All, Limit = 1 }), Throws.ArgumentException); + } + + static ChromaCollectionClient Client(HttpMessageHandler handler) + => new(new ChromaCollection("c") { Id = Guid.NewGuid() }, new ChromaConfigurationOptions("http://localhost:8000").WithBatchSplitting(false), new HttpClient(handler)); + + // Answers a search with one record per search sent, and the other requests with what they need; records the requests. + sealed class FakeServer : HttpMessageHandler + { + public List<(string Path, JsonElement Body)> Requests { get; } = []; + + protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + var path = request.RequestUri!.AbsolutePath; + var body = request.Content is null ? default : JsonDocument.Parse(await request.Content.ReadAsStringAsync(cancellationToken)).RootElement.Clone(); + Requests.Add((path, body)); + var answer = path.EndsWith("/search") + ? JsonSerializer.Serialize(new { ids = body.GetProperty("searches").EnumerateArray().Select((_, i) => new[] { $"r{i}" }) }) + : path.EndsWith("/query") ? """{"ids":[["a"]]}""" + : path.EndsWith("/get") ? """{"ids":["a"]}""" + : "{}"; + return new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent(answer) }; + } + } +} diff --git a/ChromaDB.Client.Tests/GetPagesTests.cs b/ChromaDB.Client.Tests/GetPagesTests.cs new file mode 100644 index 0000000..c01befe --- /dev/null +++ b/ChromaDB.Client.Tests/GetPagesTests.cs @@ -0,0 +1,120 @@ +using System.Net; +using System.Text.Json; +using NUnit.Framework; + +namespace ChromaDB.Client.Tests; + +// Get with WithBatchSplitting, against a fake server that answers at most `cap` records, as Chroma Cloud answers at most 300 without +// an error: pages of the batch size, or the ids in batches. +[TestFixture] +public class GetPagesTests +{ + [Test] + public async Task AllTheRecordsInPages() + { + var server = new Server(records: 7, cap: 3); + var entries = await Client(server, 3).GetAsync(); + Assert.That(entries.Select(x => x.Id), Is.EqualTo(Ids(0, 7))); + Assert.That(server.Pages, Is.EqualTo(new (int?, int?, int?)[] { (null, 3, 0), (null, 3, 3), (null, 3, 6) })); + } + + // With WithBatchSplitting(false), one request, and the server leaves records out. + [Test] + public async Task OneRequestWithoutBatchSplitting() + { + var server = new Server(records: 7, cap: 3); + var entries = await new ChromaCollectionClient(Guid.Empty, "c", new ChromaConfigurationOptions("http://localhost:8000").WithBatchSplitting(false), new HttpClient(server)).GetAsync(); + Assert.That(entries.Select(x => x.Id), Is.EqualTo(Ids(0, 3))); + Assert.That(server.Pages, Is.EqualTo(new (int?, int?, int?)[] { (null, null, null) })); + } + + // A full last page needs one more request, which comes back empty. + [Test] + public async Task FullLastPage() + { + var server = new Server(records: 6, cap: 3); + Assert.That((await Client(server, 3).GetAsync()).Count, Is.EqualTo(6)); + Assert.That(server.Pages, Is.EqualTo(new (int?, int?, int?)[] { (null, 3, 0), (null, 3, 3), (null, 3, 6) })); + } + + [Test] + public async Task LimitAndOffsetInPages() + { + var server = new Server(records: 7, cap: 3); + var entries = await Client(server, 3).GetAsync(limit: 5, offset: 1); + Assert.That(entries.Select(x => x.Id), Is.EqualTo(Ids(1, 5))); + Assert.That(server.Pages, Is.EqualTo(new (int?, int?, int?)[] { (null, 3, 1), (null, 2, 4) })); + } + + // The ids in batches, each read whole; the limit and the offset apply to all of them. + [Test] + public async Task IdsInBatches() + { + var server = new Server(records: 7, cap: 3); + var entries = await Client(server, 3).GetAsync(Ids(0, 7)); + Assert.That(entries.Select(x => x.Id), Is.EqualTo(Ids(0, 7))); + Assert.That(server.Pages, Is.EqualTo(new (int?, int?, int?)[] { (3, null, null), (3, null, null), (1, null, null) })); + + server.Pages.Clear(); + entries = await Client(server, 3).GetAsync(Ids(0, 7), limit: 2, offset: 4); + Assert.That(entries.Select(x => x.Id), Is.EqualTo(Ids(4, 2))); + Assert.That(server.Pages, Has.Count.EqualTo(3)); + } + + // Within the batch size, the request is the one without WithBatchSplitting. + [Test] + public async Task SmallReadsInOneRequest() + { + var server = new Server(records: 7, cap: 3); + await Client(server, 3).GetAsync(limit: 3, offset: 2); + await Client(server, 3).GetAsync(Ids(0, 3)); + Assert.That(server.Pages, Is.EqualTo(new (int?, int?, int?)[] { (null, 3, 2), (3, null, null) })); + } + + // With ids, the limit is never beyond them. + [Test] + public async Task LimitBeyondTheIds() + { + var server = new Server(records: 7, cap: 3); + var entries = await Client(server, 3).GetAsync(Ids(0, 2), limit: 1000); + Assert.That(entries.Select(x => x.Id), Is.EqualTo(Ids(0, 2))); + Assert.That(server.Pages, Is.EqualTo(new (int?, int?, int?)[] { (2, 2, null) })); + } + + // An id given twice comes back once, as in one request, also when the ids go in batches. + [Test] + public async Task RepeatedIdsInBatches() + { + var server = new Server(records: 7, cap: 3); + var entries = await Client(server, 3).GetAsync(["r0", "r1", "r2", "r3", "r0"]); + Assert.That(entries.Select(x => x.Id), Is.EqualTo(Ids(0, 4))); + } + + static List Ids(int start, int count) => Enumerable.Range(start, count).Select(i => $"r{i}").ToList(); + + static ChromaCollectionClient Client(HttpMessageHandler handler, int maxBatchSize) + => new(Guid.Empty, "c", new ChromaConfigurationOptions("http://localhost:8000").WithBatchSplitting(maxBatchSize), new HttpClient(handler)); + + // The records r0, r1, ... in order; each answer has at most `cap` of them. Pages records the ids count, the limit and the offset of each get. + sealed class Server(int records, int cap) : HttpMessageHandler + { + public List<(int? Ids, int? Limit, int? Offset)> Pages { get; } = []; + + protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + if (request.RequestUri!.AbsolutePath.EndsWith("/pre-flight-checks")) + { + return Answer("""{"max_batch_size":1000}"""); + } + using var body = JsonDocument.Parse(await request.Content!.ReadAsStringAsync(cancellationToken)); + var root = body.RootElement; + int? Number(string name) => root.TryGetProperty(name, out var value) && value.ValueKind == JsonValueKind.Number ? value.GetInt32() : null; + var ids = root.TryGetProperty("ids", out var list) && list.ValueKind == JsonValueKind.Array ? list.EnumerateArray().Select(x => x.GetString()!).ToHashSet() : null; + Pages.Add((ids?.Count, Number("limit"), Number("offset"))); + var selected = Ids(0, records).Where(id => ids is null || ids.Contains(id)).Skip(Number("offset") ?? 0).Take(Math.Min(Number("limit") ?? int.MaxValue, cap)).ToList(); + return Answer(JsonSerializer.Serialize(new { ids = selected, documents = (object?)null, metadatas = (object?)null, embeddings = (object?)null })); + } + + static HttpResponseMessage Answer(string body) => new(HttpStatusCode.OK) { Content = new StringContent(body) }; + } +} diff --git a/ChromaDB.Client.Tests/InputChecksTests.cs b/ChromaDB.Client.Tests/InputChecksTests.cs new file mode 100644 index 0000000..9c25e49 --- /dev/null +++ b/ChromaDB.Client.Tests/InputChecksTests.cs @@ -0,0 +1,249 @@ +using System.Net; +using System.Text.Json; +using ChromaDB.Client.DependencyInjection; +using ChromaDB.Client.Models; +using Microsoft.Extensions.DependencyInjection; +using NUnit.Framework; + +namespace ChromaDB.Client.Tests; + +// What the client checks before a request, and what it reads back, against a fake server. +[TestFixture] +public class InputChecksTests +{ + static readonly ReadOnlyMemory Embedding = new([1f, 0f]); + + // A negative limit or offset is rejected whatever the number of ids, as SearchAsync and ChromaQuery.Offset do; a limit of 0 reads + // nothing, also on Chroma 0.6.3, which takes it as no limit. + [Test] + public async Task GetLimitAndOffset() + { + var server = new Server(); + var collection = Collection(server, maxBatchSize: 2); + var ids = new List { "a", "b", "c" }; + Assert.That(() => collection.GetAsync(ids, limit: -1), Throws.InstanceOf()); + Assert.That(() => collection.GetAsync(ids, offset: -1), Throws.InstanceOf()); + Assert.That(() => collection.GetAsync(limit: -1), Throws.InstanceOf()); + Assert.That(await collection.GetAsync(ids, limit: 0), Is.Empty); + Assert.That(await collection.GetAsync(limit: 0), Is.Empty); + Assert.That(server.Paths.Where(path => path.EndsWith("/get")), Is.Empty); + } + + [Test] + public void QueryWithANegativeNumberOfResults() + { + Assert.That(() => new ChromaQuery([Embedding]) { NResults = -5 }, Throws.InstanceOf()); + } + + // The connection string takes http and https endpoints only, as the constructor does. + [TestCase("Endpoint=localhost:8000;Token=x")] + [TestCase("Endpoint=ftp://host;Token=x")] + public void EndpointWithoutHttp(string connectionString) + { + Assert.That(() => ChromaConfigurationOptions.FromConnectionString(connectionString), Throws.ArgumentException); + } + + // Chroma takes none of these as a name; GetCollectionAsync already rejects them. + [TestCase(".")] + [TestCase("..")] + public void CollectionNamesThatAreDots(string name) + { + using var client = new ChromaClient(new ChromaConfigurationOptions("http://localhost:8000"), new HttpClient(new Server())); + Assert.That(() => client.CreateCollectionAsync(name), Throws.ArgumentException); + Assert.That(() => client.GetOrCreateCollectionAsync(name), Throws.ArgumentException); + } + + // An id condition with a repeated id sends it once. + [Test] + public async Task RepeatedIdsInAnIdCondition() + { + var server = new Server(); + await Collection(server).GetAsync(where: ChromaWhereOperator.In(ChromaSearchKeys.Id, "a", "a", "b")); + var get = server.Bodies.Single(x => x.Path.EndsWith("/get")).Body; + Assert.That(get.GetProperty("ids").EnumerateArray().Select(x => x.GetString()), Is.EqualTo(new[] { "a", "b" })); + } + + // The Python client takes a list of values of one type only: rejected before the request. An empty metadata goes as null. + [Test] + public void MetadataThatChromaCannotTake() + { + var collection = Collection(new Server()); + var mixed = new Dictionary { ["mixed"] = new List { "a", 1L } }; + Assert.That(() => collection.AddAsync(["a"], [Embedding], [mixed]), Throws.ArgumentException.With.Message.Contains("\"mixed\"")); + Assert.That(() => collection.UpsertAsync(["a"], [Embedding], [new Dictionary { ["n"] = new object[] { 1, 2.5 } }]), Throws.ArgumentException); + Assert.That(() => collection.UpdateAsync(["a"], metadatas: [new Dictionary { ["n"] = new object[] { true, 1 } }]), Throws.ArgumentException); + } + + // Chroma keeps the document of a record in the metadata key chroma:document. A value that is not a text, in an add, an update or an + // upsert, makes Chroma 1.x fail every write and read of the server until it restarts: rejected before the request. + public static IEnumerable ValuesThatAreNotTexts() + { + yield return 1.0; + yield return false; + yield return 3; + yield return null; + yield return new List { "a" }; + yield return new[] { 1 }; + yield return JsonDocument.Parse("2.5").RootElement; + yield return new DerivedInts { 1 }; + // A value whose JSON the client cannot tell, which it could not write either. + yield return new Action(() => { }); + } + + public sealed class DerivedInts : List { } + + [TestCaseSource(nameof(ValuesThatAreNotTexts))] + public void ChromaDocumentThatIsNotAText(object? value) + { + var server = new Server(); + var collection = Collection(server); + var metadata = new Dictionary { ["chroma:document"] = value! }; + Assert.That(() => collection.AddAsync(["a"], [Embedding], [metadata]), Throws.ArgumentException.With.Message.Contains("chroma:document")); + Assert.That(() => collection.UpdateAsync(["a"], metadatas: [metadata]), Throws.ArgumentException.With.Message.Contains("chroma:document")); + Assert.That(() => collection.UpsertAsync(["a"], [Embedding], [metadata]), Throws.ArgumentException.With.Message.Contains("chroma:document")); + Assert.That(server.Paths, Is.Empty); + } + + // The document copy key and the document deletion key add metadata after the given one is checked: under chroma:document they would + // write a null, or true, which Chroma 1.x fails on in the same way. Rejected before the request; a copy of a text goes. + [Test] + public async Task ChromaDocumentAsTheKeyOfCopiesOrMarks() + { + var server = new Server(); + var copies = Collection(server).WithDocumentCopyKey("chroma:document"); + var marks = Collection(server).WithDocumentDeletionKey("chroma:document"); + ChromaRecords Deletion() => new(["a"]) { Embeddings = [Embedding], Documents = [null], NullDocumentsDelete = true }; + ChromaRecords Text() => new(["a"]) { Embeddings = [Embedding], Documents = ["text"] }; + Assert.That(() => copies.UpdateAsync(Deletion()), Throws.ArgumentException.With.Message.Contains("chroma:document")); + Assert.That(() => copies.UpsertAsync(Deletion()), Throws.ArgumentException.With.Message.Contains("chroma:document")); + Assert.That(() => marks.UpdateAsync(Deletion()), Throws.ArgumentException.With.Message.Contains("chroma:document")); + Assert.That(() => marks.UpsertAsync(Deletion()), Throws.ArgumentException.With.Message.Contains("chroma:document")); + Assert.That(() => marks.UpdateAsync(Text()), Throws.ArgumentException.With.Message.Contains("chroma:document")); + Assert.That(() => marks.UpsertAsync(Text()), Throws.ArgumentException.With.Message.Contains("chroma:document")); + Assert.That(server.Bodies, Is.Empty); + await copies.AddAsync(Text()); + await copies.UpdateAsync(Text()); + Assert.That(server.Bodies.Select(x => x.Body.GetProperty("metadatas")[0].GetProperty("chroma:document").GetString()), Is.EqualTo(new[] { "text", "text" })); + } + + // What works today goes as it is: chroma:document as a text sets the document, and the other keys with the prefix chroma: go + // with any value; Chroma does not return them in reads, filters on them, and reads chroma:uri as the URI of the record. + public static IEnumerable ReservedKeysThatGo() + { + yield return new TestCaseData("chroma:document", "x", "\"x\""); + yield return new TestCaseData("chroma:document", JsonDocument.Parse("\"x\"").RootElement, "\"x\""); + // The values the client writes as JSON strings are texts for Chroma too. + yield return new TestCaseData("chroma:document", Guid.Empty, "\"00000000-0000-0000-0000-000000000000\""); + yield return new TestCaseData("chroma:document", new DateTime(2026, 10, 21, 8, 30, 0, DateTimeKind.Utc), "\"2026-10-21T08:30:00Z\""); + yield return new TestCaseData("chroma:document", new DateTimeOffset(2026, 10, 21, 8, 30, 0, TimeSpan.FromHours(2)), "\"2026-10-21T08:30:00+02:00\""); + yield return new TestCaseData("chroma:document", new byte[] { 1, 2 }, "\"AQI=\""); + yield return new TestCaseData("chroma:foo", 1.5, "1.5"); + yield return new TestCaseData("chroma:uri", "u", "\"u\""); + } + + [TestCaseSource(nameof(ReservedKeysThatGo))] + public async Task ReservedKeyThatGoes(string key, object value, string json) + { + var server = new Server(); + var collection = Collection(server); + var metadata = new Dictionary { [key] = value }; + await collection.AddAsync(["a"], [Embedding], [metadata]); + await collection.UpdateAsync(["a"], metadatas: [metadata]); + await collection.UpsertAsync(["a"], [Embedding], [metadata]); + Assert.That(server.Bodies.Select(x => x.Path.Split('/').Last()), Is.EqualTo(new[] { "add", "update", "upsert" })); + Assert.That(server.Bodies.Select(x => x.Body.GetProperty("metadatas")[0].GetProperty(key).GetRawText()), Is.All.EqualTo(json)); + } + + // A record without keys comes back with an empty metadata when the metadatas are read, and with none when they are not. + [Test] + public async Task RecordsWithoutKeys() + { + var server = new Server { GetAnswer = """{"ids":["a","b"],"metadatas":[null,{"k":1}],"documents":null,"embeddings":null}""" }; + var entries = await Collection(server).GetAsync(include: ChromaGetInclude.Metadatas); + Assert.That(entries[0].Metadata, Is.Not.Null.And.Empty); + Assert.That(entries[1].Metadata!.Keys, Is.EqualTo(new[] { "k" })); + server.GetAnswer = """{"ids":["a"],"metadatas":null,"documents":null,"embeddings":null}"""; + Assert.That((await Collection(server).GetAsync(include: ChromaGetInclude.None))[0].Metadata, Is.Null); + } + + // Chroma Cloud behind another address, like a proxy: the options say it, and the batches are of 300 from the start. + [Test] + public void ChromaCloudBehindAProxy() + { + var options = new ChromaConfigurationOptions("https://chroma.example.com"); + Assert.That(options.IsChromaCloud, Is.False); + Assert.That(options.WithChromaCloud().IsChromaCloud, Is.True); + Assert.That(options.WithChromaCloud().WithDatabase("d").IsChromaCloud, Is.True); + Assert.That(new ChromaConfigurationOptions("https://api.trychroma.com").WithChromaCloud(false).IsChromaCloud, Is.False); + } + + [Test] + public async Task ChromaCloudBehindAProxyWritesInBatchesOf300() + { + var server = new Server { MaxBatchSize = 1000 }; + var collection = new ChromaCollectionClient(Guid.Empty, "c", new ChromaConfigurationOptions("https://chroma.example.com").WithChromaCloud(), new HttpClient(server)); + var ids = Enumerable.Range(0, 301).Select(i => $"r{i}").ToList(); + await collection.AddAsync(ids, ids.Select(_ => Embedding).ToList()); + Assert.That(server.Bodies.Where(x => x.Path.EndsWith("/add")).Select(x => x.Body.GetProperty("ids").GetArrayLength()), Is.EqualTo(new[] { 300, 1 })); + } + + // The creation sends get_or_create once, as the API of Chroma names it. + [Test] + public async Task CreationSendsGetOrCreateOnce() + { + var server = new Server(); + using var client = new ChromaClient(new ChromaConfigurationOptions("http://localhost:8000"), new HttpClient(server)); + try { await client.CreateCollectionAsync("abc"); } catch (ChromaException) { } + try { await client.GetOrCreateCollectionAsync("abc"); } catch (ChromaException) { } + var bodies = server.Bodies.Where(x => x.Path.EndsWith("/collections")).Select(x => x.Body.EnumerateObject().ToList()).ToList(); + Assert.That(bodies, Has.Count.EqualTo(2)); + // Once in each body, false to create and true to get or create, and under no other name. + Assert.That(bodies.Select(body => body.Where(p => p.Name.Equals("get_or_create", StringComparison.OrdinalIgnoreCase) || p.Name == "GetOrCreate").Select(p => $"{p.Name}={p.Value.GetRawText()}")), + Is.EqualTo(new[] { new[] { "get_or_create=false" }, new[] { "get_or_create=true" } })); + } + + // Keys with the same text, like 1 and "1", get an HttpClient each; a null key would register the client without a key. + [Test] + public async Task KeyedClientsWithTheSameText() + { + var number = new Server(); + var text = new Server(); + var services = new ServiceCollection(); + services.AddKeyedChromaClient(1, null, builder => builder.ConfigurePrimaryHttpMessageHandler(() => number)); + services.AddKeyedChromaClient("1", null, builder => builder.ConfigurePrimaryHttpMessageHandler(() => text)); + using var provider = services.BuildServiceProvider(); + await provider.GetRequiredKeyedService(1).GetVersionAsync(); + Assert.That((number.Paths.Count, text.Paths.Count), Is.EqualTo((1, 0))); + Assert.That(() => new ServiceCollection().AddKeyedChromaClient(null), Throws.InstanceOf()); + } + + static ChromaCollectionClient Collection(HttpMessageHandler handler, int? maxBatchSize = null) + { + var options = new ChromaConfigurationOptions("http://localhost:8000"); + return new(Guid.Empty, "c", maxBatchSize is { } size ? options.WithBatchSplitting(size) : options, new HttpClient(handler)); + } + + // Answers pre-flight-checks, the version, a get with GetAnswer, and {} to the rest; records the paths and the bodies. + sealed class Server : HttpMessageHandler + { + public int MaxBatchSize { get; set; } = 100; + public string GetAnswer { get; set; } = """{"ids":[],"metadatas":null,"documents":null,"embeddings":null}"""; + public List Paths { get; } = []; + public List<(string Path, JsonElement Body)> Bodies { get; } = []; + + protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + var path = request.RequestUri!.AbsolutePath; + Paths.Add(path); + if (request.Content is not null) + { + Bodies.Add((path, JsonDocument.Parse(await request.Content.ReadAsStringAsync(cancellationToken)).RootElement.Clone())); + } + var body = path.EndsWith("/pre-flight-checks") ? $$"""{"max_batch_size":{{MaxBatchSize}},"supports_base64_encoding":false}""" + : path.EndsWith("/version") ? "\"1.0.0\"" + : path.EndsWith("/get") ? GetAnswer + : "{}"; + return new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent(body) }; + } + } +} diff --git a/ChromaDB.Client.Tests/MetadataAndErrorsTests.cs b/ChromaDB.Client.Tests/MetadataAndErrorsTests.cs new file mode 100644 index 0000000..fbd2c9c --- /dev/null +++ b/ChromaDB.Client.Tests/MetadataAndErrorsTests.cs @@ -0,0 +1,308 @@ +using System.Net; +using System.Text.Json; +using ChromaDB.Client.Models; +using NUnit.Framework; + +namespace ChromaDB.Client.Tests; + +[TestFixture] +public class MetadataAndErrorsTests +{ + static readonly ReadOnlyMemory Embedding = new([1f, 0f]); + const string Metadata = """{"ids":["a"],"metadatas":[{"date":"2026-10-04","text":"t","int":1,"float":1.5,"bool":true,"texts":["x","y"],"ints":[1,2],"floats":[1.5,2.25],"bools":[true,false],"empty":[]}]}"""; + + [Test] + public async Task MetadataValuesAreExactByDefault() + { + var metadata = (await CollectionClient(new ChromaConfigurationOptions("http://localhost:8000"), Respond(Metadata)).GetAsync()).Single().Metadata!; + Assert.That(metadata["date"], Is.EqualTo("2026-10-04")); + Assert.That(metadata["texts"], Is.EqualTo(new List { "x", "y" })); + } + + // Dates as DateTime, lists as JsonElement. + [Test] + public async Task InferredMetadataValues() + { + var options = new ChromaConfigurationOptions("http://localhost:8000").WithMetadataValues(ChromaMetadataValues.Inferred); + var metadata = (await CollectionClient(options, Respond(Metadata)).GetAsync()).Single().Metadata!; + Assert.That(metadata["date"], Is.EqualTo(new DateTime(2026, 10, 4))); + Assert.That(metadata["texts"], Is.InstanceOf()); + } + + [Test] + public async Task ExactMetadataValues() + { + var options = new ChromaConfigurationOptions("http://localhost:8000").WithMetadataValues(ChromaMetadataValues.Exact); + var metadata = (await CollectionClient(options, Respond(Metadata)).GetAsync()).Single().Metadata!; + Assert.That(metadata["date"], Is.EqualTo("2026-10-04")); + Assert.That(metadata["text"], Is.EqualTo("t")); + Assert.That(metadata["int"], Is.EqualTo(1L)); + Assert.That(metadata["float"], Is.EqualTo(1.5)); + Assert.That(metadata["bool"], Is.EqualTo(true)); + Assert.That(metadata["texts"], Is.EqualTo(new List { "x", "y" })); + Assert.That(metadata["ints"], Is.EqualTo(new List { 1L, 2L })); + Assert.That(metadata["floats"], Is.EqualTo(new List { 1.5, 2.25 })); + Assert.That(metadata["bools"], Is.EqualTo(new List { true, false })); + Assert.That(metadata["empty"], Is.EqualTo(new List())); + } + + [Test] + public async Task ExactMetadataValuesOfCollections() + { + var options = new ChromaConfigurationOptions("http://localhost:8000").WithMetadataValues(ChromaMetadataValues.Exact); + using var httpClient = new HttpClient(Respond("""{"id":"11111111-2222-3333-4444-555555555555","name":"c","metadata":{"date":"2026-10-04","texts":["x"]}}""")); + var collection = await new ChromaClient(options, httpClient).GetCollectionAsync("c"); + Assert.That(collection.Metadata!["date"], Is.EqualTo("2026-10-04")); + Assert.That(collection.Metadata["texts"], Is.EqualTo(new List { "x" })); + } + + // A ChromaClient from a container may read the default way: WithMetadataValues gives one that reads exactly. + [Test] + public async Task ClientWithMetadataValues() + { + using var httpClient = new HttpClient(Respond("""{"id":"11111111-2222-3333-4444-555555555555","name":"c","metadata":{"date":"2026-10-04"}}""")); + var exact = new ChromaClient(new ChromaConfigurationOptions("http://localhost:8000", tenant: "t"), httpClient); + var inferred = exact.WithMetadataValues(ChromaMetadataValues.Inferred); + Assert.That((await exact.GetCollectionAsync("c")).Metadata!["date"], Is.EqualTo("2026-10-04")); + Assert.That((await inferred.GetCollectionAsync("c")).Metadata!["date"], Is.EqualTo(new DateTime(2026, 10, 4))); + Assert.That(exact.Options.MetadataValues, Is.EqualTo(ChromaMetadataValues.Exact)); + Assert.That(inferred.Options.Tenant, Is.EqualTo("t")); + Assert.That(inferred.Options.MetadataValues, Is.EqualTo(ChromaMetadataValues.Inferred)); + } + + // The two clients share what they learned about the server: the version is asked once. + [Test] + public async Task ClientWithMetadataValuesSharesTheVersion() + { + var handler = new VersionHandler(); + handler.Release.SetResult(true); + var inferred = new ChromaClient(new ChromaConfigurationOptions("http://localhost:8000"), new HttpClient(handler)); + var exact = inferred.WithMetadataValues(ChromaMetadataValues.Exact); + var records = new ChromaRecords(["a"]) { Embeddings = [Embedding], Metadatas = [new Dictionary { ["tags"] = new[] { "x" } }] }; + await inferred.GetCollectionClient(Guid.NewGuid(), "a").AddAsync(records); + await exact.GetCollectionClient(Guid.NewGuid(), "b").AddAsync(records); + Assert.That(handler.VersionRequests, Is.EqualTo(1)); + } + + // The server can be upgraded or replaced while a client lives: what it knows about it expires and is asked again. + [Test] + public async Task VersionIsAskedAgainAfterItsLifetime() + { + var lifetime = ChromaDB.Client.Common.ChromaHttpClient.ServerFacts.Lifetime; + try + { + ChromaDB.Client.Common.ChromaHttpClient.ServerFacts.Lifetime = TimeSpan.Zero; + var handler = new VersionHandler(); + handler.Release.SetResult(true); + var client = new ChromaClient(new ChromaConfigurationOptions("http://localhost:8000"), new HttpClient(handler)).GetCollectionClient(Guid.NewGuid(), "c"); + var records = new ChromaRecords(["a"]) { Embeddings = [Embedding], Metadatas = [new Dictionary { ["tags"] = new[] { "x" } }] }; + await client.AddAsync(records); + await client.AddAsync(records); + Assert.That(handler.VersionRequests, Is.EqualTo(2)); + } + finally + { + ChromaDB.Client.Common.ChromaHttpClient.ServerFacts.Lifetime = lifetime; + } + } + + // The generated serialization knows the usual types of values; with reflection enabled, as here, the others work as before. + [Test] + public async Task MetadataValuesOfOtherTypes() + { + var handler = Respond("true"); + await CollectionClient(new ChromaConfigurationOptions("http://localhost:8000"), handler).AddAsync(new ChromaRecords(["a"]) { Embeddings = [Embedding], Metadatas = [new Dictionary { ["span"] = TimeSpan.FromSeconds(1), ["text"] = "x", ["number"] = 1.5m }] }); + Assert.That(handler.Bodies.Single().GetProperty("metadatas")[0].GetRawText(), Is.EqualTo("""{"span":"00:00:01","text":"x","number":1.5}""")); + } + + [Test] + public void OtherOptionsKeepTheMetadataValues() + => Assert.That(new ChromaConfigurationOptions().WithMetadataValues(ChromaMetadataValues.Exact).WithTenant("t").WithApiVersion(ChromaApiVersion.V1).MetadataValues, Is.EqualTo(ChromaMetadataValues.Exact)); + + [TestCase(HttpStatusCode.NotFound, """{"error":"NotFoundError","message":"Collection [c] does not exist"}""")] + [TestCase(HttpStatusCode.BadRequest, """{"error":"InvalidCollection","message":"Collection c does not exist."}""")] + [TestCase(HttpStatusCode.InternalServerError, """{"error":"ValueError('Collection c does not exist.')"}""")] + [TestCase(HttpStatusCode.NotFound, "")] + public async Task StatusCodeOfTheAnswer(HttpStatusCode statusCode, string body) + { + using var httpClient = new HttpClient(new FixedHandler(statusCode, body)); + var ex = Assert.ThrowsAsync(() => new ChromaClient(new ChromaConfigurationOptions("http://localhost:8000"), httpClient).GetCollectionAsync("c")); + Assert.That(ex!.StatusCode, Is.EqualTo(statusCode)); + } + + [Test] + public async Task NoStatusCodeOnTimeout() + { + using var httpClient = new HttpClient(new PendingHandler()) { Timeout = TimeSpan.FromMilliseconds(100) }; + var ex = Assert.ThrowsAsync(() => new ChromaClient(new ChromaConfigurationOptions("http://localhost:8000"), httpClient).HeartbeatAsync()); + Assert.That(ex!.StatusCode, Is.Null); + } + + [TestCase(HttpStatusCode.OK, """{"id":"11111111-2222-3333-4444-555555555555","name":"c"}""", true)] + [TestCase(HttpStatusCode.NotFound, """{"error":"NotFoundError","message":"Collection [c] does not exists"}""", false)] + [TestCase(HttpStatusCode.BadRequest, """{"error":"InvalidCollection","message":"Collection c does not exist."}""", false)] + [TestCase(HttpStatusCode.InternalServerError, """{"error":"ValueError('Collection c does not exist.')"}""", false)] + public async Task CollectionExists(HttpStatusCode statusCode, string body, bool expected) + { + using var httpClient = new HttpClient(new FixedHandler(statusCode, body)); + Assert.That(await new ChromaClient(new ChromaConfigurationOptions("http://localhost:8000"), httpClient).CollectionExistsAsync("c"), Is.EqualTo(expected)); + } + + // Other errors are not a missing collection. + [TestCase(HttpStatusCode.InternalServerError, """{"error":"InternalError","message":"Database is locked"}""")] + [TestCase(HttpStatusCode.Unauthorized, """{"error":"AuthError","message":"Unauthorized"}""")] + [TestCase(HttpStatusCode.NotFound, """{"detail":"Not Found"}""")] + [TestCase(HttpStatusCode.NotFound, "")] + [TestCase(HttpStatusCode.NotFound, """{"error":"NotFoundError","message":"Tenant [t] not found"}""")] + public async Task CollectionExistsThrowsOnOtherErrors(HttpStatusCode statusCode, string body) + { + using var httpClient = new HttpClient(new FixedHandler(statusCode, body)); + await Assert.ThatAsync(() => new ChromaClient(new ChromaConfigurationOptions("http://localhost:8000"), httpClient).CollectionExistsAsync("c"), Throws.InstanceOf()); + } + + [Test] + public async Task ContainsAndNotContains() + { + var handler = Respond("""{"ids":[]}"""); + var client = CollectionClient(new ChromaConfigurationOptions("http://localhost:8000"), handler); + await client.GetAsync(where: ChromaWhereOperator.Contains("tags", "x")); + Assert.That(handler.Bodies.Last().GetProperty("where").GetRawText(), Is.EqualTo("""{"tags":{"$contains":"x"}}""")); + await client.GetAsync(where: ChromaWhereOperator.NotContains("nums", 3)); + Assert.That(handler.Bodies.Last().GetProperty("where").GetRawText(), Is.EqualTo("""{"nums":{"$not_contains":3}}""")); + } + + // Chroma 0.x stores the record but drops its lists: the client stops before sending them. + [Test] + public async Task ListsInMetadataToChroma0Throw() + { + var handler = Respond("\"0.6.3\""); + var client = CollectionClient(new ChromaConfigurationOptions("http://localhost:8000"), handler); + var ex = Assert.ThrowsAsync(() => client.AddAsync(new ChromaRecords(["a"]) { Embeddings = [Embedding], Metadatas = [new Dictionary { ["tags"] = new[] { "x" } }] })); + Assert.That(ex!.Message, Does.Contain("1.5.0")); + Assert.That(ex.StatusCode, Is.Null); + await Assert.ThatAsync(() => client.UpsertAsync(new ChromaRecords(["a"]) { Embeddings = [Embedding], Metadatas = [new Dictionary { ["tags"] = new List { 1 } }] }), Throws.InstanceOf()); + await Assert.ThatAsync(() => client.UpdateAsync(new ChromaRecords(["a"]) { Metadatas = [new Dictionary { ["tags"] = new List { true } }] }), Throws.InstanceOf()); + // The version is asked once, and no record is sent. + Assert.That(handler.Paths, Is.EqualTo(new[] { "/api/v2/version" })); + } + + // A list read with the default ChromaMetadataValues.Inferred is a JsonElement: written back, it is a list too. + [Test] + public async Task JsonArraysInMetadataToChroma0Throw() + { + var handler = Respond("\"0.6.3\""); + var client = CollectionClient(new ChromaConfigurationOptions("http://localhost:8000"), handler); + var list = JsonDocument.Parse("""["x"]""").RootElement.Clone(); + Assert.ThrowsAsync(() => client.AddAsync(new ChromaRecords(["a"]) { Embeddings = [Embedding], Metadatas = [new Dictionary { ["tags"] = list }] })); + Assert.That(handler.Paths, Is.EqualTo(new[] { "/api/v2/version" })); + } + + [Test] + public async Task ConcurrentListsAskTheVersionOnce() + { + var handler = new VersionHandler(); + var client = CollectionClient(new ChromaConfigurationOptions("http://localhost:8000"), handler); + var adds = Enumerable.Range(0, 5).Select(_ => client.AddAsync(new ChromaRecords(["a"]) { Embeddings = [Embedding], Metadatas = [new Dictionary { ["tags"] = new[] { "x" } }] })).ToList(); + handler.Release.SetResult(true); + await Task.WhenAll(adds); + Assert.That(handler.VersionRequests, Is.EqualTo(1)); + } + + // A canceled version request is not kept: the next call asks again. + [Test] + public async Task CanceledVersionRequestIsAskedAgain() + { + var handler = new VersionHandler(); + var client = CollectionClient(new ChromaConfigurationOptions("http://localhost:8000"), handler); + using var cts = new CancellationTokenSource(TimeSpan.FromMilliseconds(100)); + await Assert.ThatAsync(() => client.AddAsync(new ChromaRecords(["a"]) { Embeddings = [Embedding], Metadatas = [new Dictionary { ["tags"] = new[] { "x" } }] }, cts.Token), Throws.InstanceOf()); + handler.Release.SetResult(true); + await client.AddAsync(new ChromaRecords(["a"]) { Embeddings = [Embedding], Metadatas = [new Dictionary { ["tags"] = new[] { "x" } }] }); + Assert.That(handler.VersionRequests, Is.EqualTo(2)); + } + + [Test] + public async Task ListsInMetadataToChroma1AreSent() + { + var handler = Respond("\"1.0.0\""); + var client = CollectionClient(new ChromaConfigurationOptions("http://localhost:8000"), handler); + await client.AddAsync(new ChromaRecords(["a"]) { Embeddings = [Embedding], Metadatas = [new Dictionary { ["tags"] = new[] { "x" } }] }); + // pre-flight-checks aside, asked for the base64 of the embeddings. + Assert.That(handler.Paths.Where(x => !x.EndsWith("/pre-flight-checks")), Is.EqualTo(new[] { "/api/v2/version", $"/api/v2/tenants/default_tenant/databases/default_database/collections/{client.Collection.Id}/add" })); + Assert.That(handler.Bodies.Last().GetProperty("metadatas")[0].GetProperty("tags").GetRawText(), Is.EqualTo("""["x"]""")); + } + + // Without lists the version is not needed. + [Test] + public async Task MetadataWithoutListsDoesNotAskTheVersion() + { + var handler = Respond("true"); + var client = CollectionClient(new ChromaConfigurationOptions("http://localhost:8000"), handler); + await client.AddAsync(new ChromaRecords(["a"]) { Embeddings = [Embedding], Metadatas = [new Dictionary { ["text"] = "x", ["int"] = 1 }] }); + Assert.That(handler.Paths, Has.None.EndsWith("/version")); + Assert.That(handler.Paths.Last(), Does.EndWith("/add")); + } + + static ChromaCollectionClient CollectionClient(ChromaConfigurationOptions options, HttpMessageHandler handler) + { + var collection = new ChromaCollection("collection") { Id = Guid.NewGuid() }; + return new ChromaCollectionClient(collection, options, new HttpClient(handler)); + } + + static RecordingHandler Respond(string body) => new(body); + + sealed class RecordingHandler(string response) : HttpMessageHandler + { + public List Paths { get; } = []; + public List Bodies { get; } = []; + + protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + Paths.Add(request.RequestUri!.AbsolutePath); + if (request.Content is not null) + { + Bodies.Add(JsonDocument.Parse(await request.Content.ReadAsStringAsync(cancellationToken)).RootElement.Clone()); + } + // The version, which the client also asks for a collection without a space: 1.0.0 unless the answer is a version. + var version = request.RequestUri.AbsolutePath.EndsWith("/version") && !response.StartsWith("\"") ? "\"1.0.0\"" : response; + return new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent(version) }; + } + } + + // Answers "1.0.0" to the version requests once Release is set, and true to the others. + sealed class VersionHandler : HttpMessageHandler + { + int _versionRequests; + public int VersionRequests => _versionRequests; + public TaskCompletionSource Release { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously); + + protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + if (request.RequestUri!.AbsolutePath.EndsWith("/version")) + { + Interlocked.Increment(ref _versionRequests); + await Release.Task.WaitAsync(cancellationToken); + return new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent("\"1.0.0\"") }; + } + return new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent("true") }; + } + } + + sealed class FixedHandler(HttpStatusCode statusCode, string body) : HttpMessageHandler + { + // The version, which the client also asks for a collection without a space: 1.0.0. + protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + => Task.FromResult(request.RequestUri!.AbsolutePath.EndsWith("/version") && statusCode == HttpStatusCode.OK && !body.StartsWith("\"") + ? new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent("\"1.0.0\"") } + : new HttpResponseMessage(statusCode) { Content = new StringContent(body) }); + } + + sealed class PendingHandler : HttpMessageHandler + { + protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + await Task.Delay(Timeout.Infinite, cancellationToken); + throw new InvalidOperationException(); + } + } +} diff --git a/ChromaDB.Client.Tests/MetadataTests.cs b/ChromaDB.Client.Tests/MetadataTests.cs new file mode 100644 index 0000000..e4d8cfb --- /dev/null +++ b/ChromaDB.Client.Tests/MetadataTests.cs @@ -0,0 +1,485 @@ +using System.Net; +using ChromaDB.Client.Models; +using NUnit.Framework; + +namespace ChromaDB.Client.Tests; + +[TestFixture] +public class MetadataTests : ChromaTestsBase +{ + static readonly ReadOnlyMemory Embedding1 = new([1f, 0f]); + static readonly ReadOnlyMemory Embedding2 = new([0f, 1f]); + + [Test] + public async Task StringsStayStrings() + { + var client = await Init(BaseConfigurationOptions.WithMetadataValues(ChromaMetadataValues.Exact)); + await client.AddAsync(new ChromaRecords(["a"]) { Embeddings = [Embedding1], Metadatas = [new Dictionary { ["date"] = "2026-10-04", ["text"] = "t" }] }); + var metadata = (await client.GetAsync("a", include: ChromaGetInclude.Metadatas))!.Metadata!; + Assert.That(metadata["date"], Is.EqualTo("2026-10-04")); + Assert.That(metadata["text"], Is.EqualTo("t")); + } + + // A whole double goes as 2.0, as the Python client writes it: Chroma keeps it a float, and a list of doubles in a + // filter is not a list of ints and floats, which Chroma rejects. + [Test] + public async Task WholeDoublesStayFloats() + { + var client = await Init(BaseConfigurationOptions.WithMetadataValues(ChromaMetadataValues.Exact)); + await client.AddAsync(new ChromaRecords(["a", "b"]) + { + Embeddings = [Embedding1, Embedding2], + Metadatas = [new Dictionary { ["d"] = 2.0, ["f"] = 3f, ["m"] = 4m }, new Dictionary { ["d"] = 2.25 }], + }); + var metadata = (await client.GetAsync("a", include: ChromaGetInclude.Metadatas))!.Metadata!; + Assert.That(metadata["d"], Is.InstanceOf().And.EqualTo(2.0)); + Assert.That(metadata["f"], Is.InstanceOf().And.EqualTo(3.0)); + Assert.That(metadata["m"], Is.InstanceOf().And.EqualTo(4.0)); + var found = await client.GetAsync(where: ChromaWhereOperator.In("d", 2.0, 2.25), include: ChromaGetInclude.None); + Assert.That(found.Select(e => e.Id), Is.EquivalentTo(new[] { "a", "b" })); + // Stored as an int, 2 would not be under 2.2 for Chroma. + found = await client.GetAsync(where: ChromaWhereOperator.LessThan("d", 2.2), include: ChromaGetInclude.None); + Assert.That(found.Select(e => e.Id), Is.EqualTo(new[] { "a" })); + } + + // The keys with the prefix chroma: that work today keep working: chroma:document as a text sets the document in an update, and a + // filter finds a record by chroma:foo, which Chroma does not return in reads. A chroma:document that is not a text is rejected + // before the request (KD-58). + [Test] + public async Task ReservedKeysThatWork() + { + var client = await Init(BaseConfigurationOptions.WithMetadataValues(ChromaMetadataValues.Exact)); + await client.AddAsync(new ChromaRecords(["a"]) { Embeddings = [Embedding1], Documents = ["old"], Metadatas = [new Dictionary { ["chroma:foo"] = "tag", ["k"] = 1L }] }); + await client.UpdateAsync(new ChromaRecords(["a"]) { Metadatas = [new Dictionary { ["chroma:document"] = "new" }] }); + var entry = (await client.GetAsync("a", include: ChromaGetInclude.Documents | ChromaGetInclude.Metadatas))!; + Assert.That(entry.Document, Is.EqualTo("new")); + Assert.That(entry.Metadata!.Keys, Is.EqualTo(new[] { "k" })); + var found = await client.GetAsync(where: ChromaWhereOperator.Equal("chroma:foo", "tag"), include: ChromaGetInclude.None); + Assert.That(found.Select(e => e.Id), Is.EqualTo(new[] { "a" })); + Assert.That(() => client.UpdateAsync(new ChromaRecords(["a"]) { Metadatas = [new Dictionary { ["chroma:document"] = 1.0 }] }), Throws.ArgumentException); + } + + // A null deletes the key in an update and in an upsert on every tested Chroma, and a record left without keys comes back with + // Metadata null; AddAsync rejects a null before the request. + [Test] + public async Task NullValuesDeleteKeys() + { + var client = await Init(BaseConfigurationOptions.WithMetadataValues(ChromaMetadataValues.Exact)); + await client.AddAsync(new ChromaRecords(["a", "b"]) + { + Embeddings = [Embedding1, Embedding2], + Metadatas = [new Dictionary { ["x"] = 1L, ["y"] = 2L }, new Dictionary { ["x"] = 1L }], + }); + await client.UpdateAsync(new ChromaRecords(["a"]) { Metadatas = [new Dictionary { ["x"] = null! }] }); + await client.UpsertAsync(new ChromaRecords(["b"]) { Embeddings = [Embedding2], Metadatas = [new Dictionary { ["x"] = null! }] }); + var records = (await client.GetAsync(["a", "b"], include: ChromaGetInclude.Metadatas)).ToDictionary(x => x.Id, x => x.Metadata); + Assert.That(records["a"], Is.EquivalentTo(new Dictionary { ["y"] = 2L })); + // A record without keys comes back with an empty metadata. + Assert.That(records["b"], Is.Not.Null.And.Empty); + Assert.That(() => client.AddAsync(new ChromaRecords(["c"]) { Embeddings = [Embedding1], Metadatas = [new Dictionary { ["x"] = null! }] }), Throws.ArgumentException); + } + + [Test] + public async Task DatesStayStringsByDefault() + { + var client = await Init(BaseConfigurationOptions); + await client.AddAsync(new ChromaRecords(["a"]) { Embeddings = [Embedding1], Metadatas = [new Dictionary { ["date"] = "2026-10-04" }] }); + Assert.That((await client.GetAsync("a", include: ChromaGetInclude.Metadatas))!.Metadata!["date"], Is.EqualTo("2026-10-04")); + } + + [Test] + public async Task DatesAreInferredWithInferred() + { + var client = await Init(BaseConfigurationOptions.WithMetadataValues(ChromaMetadataValues.Inferred)); + await client.AddAsync(new ChromaRecords(["a"]) { Embeddings = [Embedding1], Metadatas = [new Dictionary { ["date"] = "2026-10-04" }] }); + Assert.That((await client.GetAsync("a", include: ChromaGetInclude.Metadatas))!.Metadata!["date"], Is.EqualTo(new DateTime(2026, 10, 4))); + } + + [Test] + public async Task ListsInMetadata() + { + var client = await Init(BaseConfigurationOptions.WithMetadataValues(ChromaMetadataValues.Exact)); + var records = new ChromaRecords(["a"]) + { + Embeddings = [Embedding1], + Metadatas = [new Dictionary { ["texts"] = new List { "x", "y" }, ["array"] = new[] { "z" }, ["ints"] = new List { 1, 2 }, ["floats"] = new List { 1.5, 2.25 }, ["bools"] = new List { true, false } }], + }; + if (IsChroma0) + { + // The server would store the record without its lists: the client stops before sending it. + var ex = Assert.ThrowsAsync(() => client.AddAsync(records)); + Assert.That(ex!.StatusCode, Is.Null); + Assert.That(await client.CountAsync(), Is.EqualTo(0)); + return; + } + if (!MetadataListsSupported) + { + var ex = Assert.ThrowsAsync(() => client.AddAsync(records)); + Assert.That(ex!.StatusCode, Is.EqualTo((HttpStatusCode)422)); + return; + } + await client.AddAsync(records); + var metadata = (await client.GetAsync("a", include: ChromaGetInclude.Metadatas))!.Metadata!; + Assert.That(metadata["texts"], Is.EqualTo(new List { "x", "y" })); + Assert.That(metadata["array"], Is.EqualTo(new List { "z" })); + Assert.That(metadata["ints"], Is.EqualTo(new List { 1L, 2L })); + Assert.That(metadata["floats"], Is.EqualTo(new List { 1.5, 2.25 })); + Assert.That(metadata["bools"], Is.EqualTo(new List { true, false })); + // A whole double goes as 1.0: Chroma finds 1 only in a list of ints, and 1.0 only in a list of doubles. + await client.AddAsync(new ChromaRecords(["b"]) { Embeddings = [Embedding2], Metadatas = [new Dictionary { ["floats"] = new List { 1.0, 2.5 } }] }); + var found = await client.GetAsync(where: ChromaWhereOperator.Contains("floats", 1.0), include: ChromaGetInclude.None); + Assert.That(found.Select(e => e.Id), Is.EqualTo(new[] { "b" })); + } + + [Test] + public async Task UpsertAndUpdateListsInMetadata() + { + Assume.That(MetadataListsSupported, Is.True, "Chroma 1.4.1 and earlier do not store lists in metadata."); + var client = await Init(BaseConfigurationOptions.WithMetadataValues(ChromaMetadataValues.Exact)); + await client.UpsertAsync(new ChromaRecords(["a"]) { Embeddings = [Embedding1], Metadatas = [new Dictionary { ["texts"] = new[] { "x" } }] }); + await client.UpdateAsync(new ChromaRecords(["a"]) { Metadatas = [new Dictionary { ["texts"] = new[] { "y", "z" } }] }); + Assert.That((await client.GetAsync("a", include: ChromaGetInclude.Metadatas))!.Metadata!["texts"], Is.EqualTo(new List { "y", "z" })); + } + + [Test] + public async Task ContainsAndNotContains() + { + Assume.That(MetadataListsSupported, Is.True, "Chroma 1.4.1 and earlier do not store lists in metadata."); + var client = await Init(BaseConfigurationOptions); + await client.AddAsync(new ChromaRecords(["a", "b"]) + { + Embeddings = [Embedding1, Embedding2], + Metadatas = [new Dictionary { ["texts"] = new[] { "x", "y" }, ["ints"] = new[] { 1, 2 } }, new Dictionary { ["texts"] = new[] { "z" }, ["ints"] = new[] { 3 } }], + }); + Assert.That((await client.GetAsync(where: ChromaWhereOperator.Contains("texts", "x"))).Select(x => x.Id), Is.EqualTo(new[] { "a" })); + Assert.That((await client.GetAsync(where: ChromaWhereOperator.NotContains("texts", "x"))).Select(x => x.Id), Is.EqualTo(new[] { "b" })); + Assert.That((await client.GetAsync(where: ChromaWhereOperator.Contains("ints", 3))).Select(x => x.Id), Is.EqualTo(new[] { "b" })); + Assert.That((await client.QueryAsync(Embedding2, where: ChromaWhereOperator.Contains("texts", "y"))).Select(x => x.Id), Is.EqualTo(new[] { "a" })); + } + + // The 0.x servers know only $gt, $gte, $lt, $lte, $ne, $eq, $in and $nin in metadata filters. + [Test] + public async Task ContainsOnChroma0Throws() + { + Assume.That(IsChroma0, Is.True, "Chroma 1.x has $contains in metadata filters."); + var client = await Init(BaseConfigurationOptions); + await client.AddAsync(new ChromaRecords(["a"]) { Embeddings = [Embedding1], Metadatas = [new Dictionary { ["text"] = "x" }] }); + var ex = Assert.ThrowsAsync(() => client.GetAsync(where: ChromaWhereOperator.Contains("text", "x"))); + Assert.That(ex!.Message, Does.Contain("$contains")); + } + + // A null deletes a key and a null document with NullDocumentsDelete, from a client with a document copy key, the document, which + // comes back null; a key the metadata does not have stays, and a new record gets the nulls too, which Chroma stores without the keys. + [Test] + public async Task DeletionsInAnUpsert() + { + var client = (await Init(BaseConfigurationOptions.WithMetadataValues(ChromaMetadataValues.Exact))).WithDocumentCopyKey("text"); + var metadata = new Dictionary { ["k"] = 1L, ["keep"] = 2L }; + if (MetadataListsSupported) + { + metadata["tags"] = new[] { "x" }; + } + await client.AddAsync(new ChromaRecords(["a"]) { Embeddings = [Embedding1], Metadatas = [metadata], Documents = ["doc"] }); + var upsert = new Dictionary { ["k"] = null!, ["x"] = 3L }; + if (MetadataListsSupported) + { + upsert["tags"] = Array.Empty(); + } + await client.UpsertAsync(new ChromaRecords(["a", "b"]) + { + Embeddings = [Embedding1, Embedding2], + Metadatas = [upsert, new Dictionary { ["k"] = null! }], + Documents = [null!, null!], + NullDocumentsDelete = true, + }); + var records = (await client.GetAsync(["a", "b"], include: ChromaGetInclude.Metadatas | ChromaGetInclude.Documents)).ToDictionary(x => x.Id); + Assert.That(records["a"].Metadata, Is.EquivalentTo(new Dictionary { ["keep"] = 2L, ["x"] = 3L })); + Assert.That(records["a"].Document, Is.Null); + Assert.That(records["b"].Metadata, Is.Not.Null.And.Empty); + Assert.That(records["b"].Document, Is.Null); + } + + // Chroma Cloud counts a null against its quota of 32 keys: a record with 31 values, the null of a key it has and the null of one it + // does not have would take 33. By default the client throws before the write, without a read, and the record stays as it was; with + // ReadsBeyondKeyQuota it reads the record first and sends only the null of the key it has. + [TestCase(false)] + [TestCase(true)] + public async Task NullsBeyondTheQuotaOfKeysOfChromaCloud(bool readsBeyondKeyQuota) + { + Assume.That(ChromaCloud, Is.True, "Only Chroma Cloud has the quota of 32 keys."); + var options = BaseConfigurationOptions.WithMetadataValues(ChromaMetadataValues.Exact).WithReadsBeyondKeyQuota(readsBeyondKeyQuota); + var client = await Init(options); + var values = Enumerable.Range(0, 31).ToDictionary(i => $"k{i}", i => (object)(long)i); + var stored = new Dictionary(values) { ["gone"] = 1L }; + await client.AddAsync(new ChromaRecords(["a"]) { Embeddings = [Embedding1], Metadatas = [stored] }); + var requests = new Requests(); + using var httpClient = new HttpClient(requests); + var writer = new ChromaCollectionClient(client.Collection, options, httpClient); + var upsert = new ChromaRecords(["a"]) { Embeddings = [Embedding1], Metadatas = [new Dictionary(values) { ["gone"] = null!, ["missing"] = null! }] }; + if (readsBeyondKeyQuota) + { + await writer.UpsertAsync(upsert); + Assert.That(requests.Paths, Does.Contain("get").And.Contain("upsert")); + Assert.That((await client.GetAsync("a", include: ChromaGetInclude.Metadatas))!.Metadata, Is.EquivalentTo(values)); + } + else + { + Assert.That(() => writer.UpsertAsync(upsert), Throws.ArgumentException.With.Message.StartsWith("Record \"a\" would take 33 metadata keys to Chroma Cloud")); + Assert.That(requests.Paths, Is.Empty); + Assert.That((await client.GetAsync("a", include: ChromaGetInclude.Metadatas))!.Metadata, Is.EquivalentTo(stored)); + } + } + + // Exactly 32 keys with the nulls, which the client sends without a read: Chroma Cloud takes them. + [Test] + public async Task NullsWithinTheQuotaOfKeysOfChromaCloud() + { + Assume.That(ChromaCloud, Is.True, "Only Chroma Cloud has the quota of 32 keys."); + var client = await Init(BaseConfigurationOptions.WithMetadataValues(ChromaMetadataValues.Exact)); + var values = Enumerable.Range(0, 30).ToDictionary(i => $"k{i}", i => (object)(long)i); + await client.AddAsync(new ChromaRecords(["a"]) { Embeddings = [Embedding1], Metadatas = [new Dictionary(values) { ["gone"] = 1L }] }); + await client.UpsertAsync(new ChromaRecords(["a"]) { Embeddings = [Embedding1], Metadatas = [new Dictionary(values) { ["gone"] = null!, ["missing"] = null! }] }); + Assert.That((await client.GetAsync("a", include: ChromaGetInclude.Metadatas))!.Metadata, Is.EquivalentTo(values)); + } + + // A text that becomes empty takes the vector of the old text away on Chroma Cloud: its empty vector replaces it. + [Test] + public async Task AnEmptyTextReplacesItsVectorOnChromaCloud() + { + Assume.That(ChromaCloud, Is.True, "Only Chroma Cloud has sparse vector indexes."); + var options = BaseConfigurationOptions.WithMetadataValues(ChromaMetadataValues.Exact); + var collection = await new ChromaClient(options, HttpClient).CreateCollectionAsync(new ChromaCollectionDefinition($"collection{Random.Shared.Next()}") + { + Schema = new ChromaCollectionSchema().WithBm25Index("title"), + }); + var client = new ChromaCollectionClient(collection, options, HttpClient); + var key = collection.FindBm25Index("title")!.Key; + await client.AddAsync(new ChromaRecords(["a"]) { Embeddings = [Embedding1], Metadatas = [new Dictionary { ["title"] = "red apple" }] }); + Assert.That(((ChromaSparseVector)(await client.GetAsync("a", include: ChromaGetInclude.Metadatas))!.Metadata![key]).Indices, Is.Not.Empty); + await client.UpdateAsync(new ChromaRecords(["a"]) { Metadatas = [new Dictionary { ["title"] = "" }] }); + Assert.That(((ChromaSparseVector)(await client.GetAsync("a", include: ChromaGetInclude.Metadatas))!.Metadata![key]).Indices, Is.Empty); + } + + // A record with 33 values and no null goes, and Chroma Cloud rejects it with its own error, which counts the keys of the request. + [Test] + public async Task ValuesBeyondTheQuotaOfKeysOfChromaCloud() + { + Assume.That(ChromaCloud, Is.True, "Only Chroma Cloud has the quota of 32 keys."); + var client = await Init(BaseConfigurationOptions); + var metadata = Enumerable.Range(0, 33).ToDictionary(i => $"k{i}", i => (object)(long)i); + var ex = Assert.ThrowsAsync(() => client.UpsertAsync(new ChromaRecords(["a"]) { Embeddings = [Embedding1], Metadatas = [metadata] })); + Assert.That(ex!.StatusCode, Is.EqualTo(HttpStatusCode.UnprocessableEntity)); + Assert.That(ex.Message, Does.Contain("current usage of 33 exceeds limit of 32")); + } + + // Chroma Cloud counts the sparse vector that the client computes from a text against its 32 keys: 30 values, title, its title_bm25 and + // the null of gone are 33, and the write throws before it goes; the record stays as it was. + [Test] + public async Task ComputedSparseVectorsCountOnChromaCloud() + { + Assume.That(ChromaCloud, Is.True, "Only Chroma Cloud has the quota of 32 keys."); + var options = BaseConfigurationOptions.WithMetadataValues(ChromaMetadataValues.Exact); + var collection = await new ChromaClient(options, HttpClient).CreateCollectionAsync(new ChromaCollectionDefinition($"collection{Random.Shared.Next()}") + { + Schema = new ChromaCollectionSchema().WithSparseVectorIndex("title_bm25", "title", bm25: true, ChromaEmbeddingFunctionReference.ChromaBm25()), + }); + var client = new ChromaCollectionClient(collection, options, HttpClient); + var values = Enumerable.Range(0, 30).ToDictionary(i => $"k{i}", i => (object)(long)i); + await client.AddAsync(new ChromaRecords(["a"]) { Embeddings = [Embedding1], Metadatas = [new Dictionary(values) { ["title"] = "red apple" }] }); + var stored = (await client.GetAsync("a", include: ChromaGetInclude.Metadatas))!.Metadata!; + Assert.That(stored, Has.Count.EqualTo(32)); + var write = new ChromaRecords(["a"]) { Embeddings = [Embedding1], Metadatas = [new Dictionary(values) { ["title"] = "green apple", ["gone"] = null! }] }; + Assert.That(() => client.UpsertAsync(write), Throws.ArgumentException.With.Message.StartsWith("Record \"a\" would take 33 metadata keys to Chroma Cloud")); + Assert.That((await client.GetAsync("a", include: ChromaGetInclude.Metadatas))!.Metadata!["title"], Is.EqualTo("red apple")); + } + + // The copy of the document under the document copy key lets a where filter find the whole text. A document comes back as it was + // written: empty, or null when it was deleted, which takes its copy along; so does a record that had no document, and a new one. + [Test] + public async Task DocumentCopy() + { + var client = (await Init(BaseConfigurationOptions.WithMetadataValues(ChromaMetadataValues.Exact))).WithDocumentCopyKey("text"); + await client.AddAsync(new ChromaRecords(["a", "b", "c"]) { Embeddings = [Embedding1, Embedding2, Embedding1], Documents = ["apple pie", "banana split", null!] }); + var found = await client.GetAsync(where: ChromaWhereOperator.Equal("text", "apple pie"), include: ChromaGetInclude.None); + Assert.That(found.Select(x => x.Id), Is.EqualTo(new[] { "a" })); + await client.UpsertAsync(new ChromaRecords(["a", "b", "c", "d"]) { Embeddings = [Embedding1, Embedding2, Embedding1, Embedding2], Documents = [null!, "", null!, null!], NullDocumentsDelete = true }); + var records = (await client.GetAsync(include: ChromaGetInclude.Documents)).ToDictionary(x => x.Id); + Assert.That((records["a"].Document, records["b"].Document, records["c"].Document, records["d"].Document), Is.EqualTo(((string?)null, "", (string?)null, (string?)null))); + var record = (await client.GetAsync("a", include: ChromaGetInclude.Metadatas | ChromaGetInclude.Documents))!; + Assert.That(record.Metadata, Is.Not.Null.And.Empty); + Assert.That(record.Document, Is.Null); + } + + // With a document deletion key a deleted document comes back null, also of a record that had none and of a new one, and an empty one + // comes back empty; the mark stays in the metadata, and goes away when the record gets a document again. + [Test] + public async Task DocumentDeletionKey() + { + var client = (await Init(BaseConfigurationOptions.WithMetadataValues(ChromaMetadataValues.Exact))).WithDocumentDeletionKey("deleted"); + await client.AddAsync(new ChromaRecords(["a", "b", "c"]) { Embeddings = [Embedding1, Embedding2, Embedding1], Documents = ["apple pie", "banana split", null!] }); + await client.UpsertAsync(new ChromaRecords(["a", "b", "c", "d"]) { Embeddings = [Embedding1, Embedding2, Embedding1, Embedding2], Documents = [null!, "", null!, null!], NullDocumentsDelete = true }); + var records = (await client.GetAsync(include: ChromaGetInclude.Documents)).ToDictionary(x => x.Id); + Assert.That((records["a"].Document, records["b"].Document, records["c"].Document, records["d"].Document), Is.EqualTo(((string?)null, "", (string?)null, (string?)null))); + var found = (await client.QueryAsync(new ChromaQuery([Embedding1]) { NResults = 4, Include = ChromaQueryInclude.Documents }))[0].ToDictionary(x => x.Id); + Assert.That((found["a"].Document, found["b"].Document), Is.EqualTo(((string?)null, ""))); + Assert.That((await client.GetAsync("a", include: ChromaGetInclude.Metadatas))!.Metadata, Is.EquivalentTo(new Dictionary { ["deleted"] = true })); + await client.UpdateAsync(new ChromaRecords(["a"]) { Documents = ["again"] }); + var again = (await client.GetAsync("a", include: ChromaGetInclude.Metadatas | ChromaGetInclude.Documents))!; + Assert.That(again.Document, Is.EqualTo("again")); + Assert.That(again.Metadata, Is.Not.Null.And.Empty); + } + + // With empty document deletions a deleted document comes back empty, also of a record that had none and of a new one, and the + // records get no key. + [Test] + public async Task EmptyDocumentDeletions() + { + var client = (await Init(BaseConfigurationOptions.WithMetadataValues(ChromaMetadataValues.Exact))).WithEmptyDocumentDeletions(); + await client.AddAsync(new ChromaRecords(["a", "b"]) { Embeddings = [Embedding1, Embedding2], Documents = ["apple pie", null!] }); + await client.UpsertAsync(new ChromaRecords(["a", "b", "c"]) { Embeddings = [Embedding1, Embedding2, Embedding1], Documents = [null!, null!, null!], NullDocumentsDelete = true }); + var records = (await client.GetAsync(include: ChromaGetInclude.Documents | ChromaGetInclude.Metadatas)).ToDictionary(x => x.Id); + Assert.That((records["a"].Document, records["b"].Document, records["c"].Document), Is.EqualTo(("", "", ""))); + Assert.That(records.Values.Select(x => x.Metadata), Is.All.Not.Null.And.All.Empty); + } + + // The values of ChromaMetadataConvert come back as they were written, and a filter with a converted value finds the same instant + // at another offset. + [Test] + public async Task ConvertedValues() + { + var client = await Init(BaseConfigurationOptions.WithMetadataValues(ChromaMetadataValues.Exact)); + var opened = new DateTimeOffset(2026, 10, 5, 13, 0, 0, TimeSpan.FromHours(2)); + var updated = new DateTime(2026, 10, 5, 11, 0, 0, DateTimeKind.Local); + var metadata = new Dictionary + { + ["opened"] = ChromaMetadataConvert.ToMetadataValue(opened)!, + ["updated"] = ChromaMetadataConvert.ToMetadataValue(updated)!, + ["count"] = ChromaMetadataConvert.ToMetadataValue(3)!, + }; + if (MetadataListsSupported) + { + metadata["days"] = ChromaMetadataConvert.ToMetadataValue(new[] { opened, opened.AddDays(1) })!; + } + await client.AddAsync(new ChromaRecords(["a"]) { Embeddings = [Embedding1], Metadatas = [metadata] }); + var read = (await client.GetAsync("a", include: ChromaGetInclude.Metadatas))!.Metadata!; + Assert.That(ChromaMetadataConvert.FromMetadataValue(read["opened"], typeof(DateTimeOffset)), Is.EqualTo(opened)); + var readUpdated = (DateTime)ChromaMetadataConvert.FromMetadataValue(read["updated"], typeof(DateTime))!; + Assert.That((readUpdated, readUpdated.Kind), Is.EqualTo((updated, DateTimeKind.Local))); + Assert.That(ChromaMetadataConvert.FromMetadataValue(read["count"], typeof(int)), Is.EqualTo(3)); + var sameInstant = ChromaMetadataConvert.ToMetadataValue(opened.ToUniversalTime())!; + Assert.That((await client.GetAsync(where: ChromaWhereOperator.Equal("opened", sameInstant), include: ChromaGetInclude.None)).Select(x => x.Id), Is.EqualTo(new[] { "a" })); + if (MetadataListsSupported) + { + Assert.That(ChromaMetadataConvert.FromMetadataValue(read["days"], typeof(List)), Is.EqualTo(new List { opened, opened.AddDays(1) })); + Assert.That((await client.GetAsync(where: ChromaWhereOperator.Contains("days", sameInstant), include: ChromaGetInclude.None)).Select(x => x.Id), Is.EqualTo(new[] { "a" })); + } + } + + // Chroma 1.5 keeps the lists of the records of a deleted collection or database, and gives them to the next records it stores, in + // any collection: with deleteRecordsFirst the client deletes the records first. + [Test] + public async Task DeletedCollectionLeavesNoLists() + { + Assume.That(MetadataListsSupported, Is.True, "Chroma 1.4.1 and earlier do not store lists in metadata."); + var deleted = await AddRecordsWithLists(BaseConfigurationOptions); + await new ChromaClient(BaseConfigurationOptions, HttpClient).DeleteCollectionAsync(deleted.Collection.Name, deleteRecordsFirst: true); + await AssertNoListsInNewRecords(); + } + + [Test] + public async Task DeletedDatabaseLeavesNoLists() + { + Assume.That(MetadataListsSupported, Is.True, "Chroma 1.4.1 and earlier do not store lists in metadata."); + Assume.That(OtherTenantsAndDatabasesTested, Is.True, "A server already running may not let the tests create or look up other tenants and databases."); + var chroma = new ChromaClient(BaseConfigurationOptions, HttpClient); + var database = $"database{Random.Shared.Next()}"; + await chroma.CreateDatabaseAsync(database); + await AddRecordsWithLists(BaseConfigurationOptions.WithDatabase(database)); + await AddRecordsWithLists(BaseConfigurationOptions.WithDatabase(database)); + await chroma.DeleteDatabaseAsync(database, deleteRecordsFirst: true); + await AssertNoListsInNewRecords(); + } + + // The records of every collection go first, also of the collections beyond a page of the list: 120 collections, each with a record. + [Test] + public async Task DeletedDatabaseDeletesTheRecordsOfEveryCollection() + { + Assume.That(IsChroma1, Is.True, "Chroma 0.x stores no lists, so the records are not deleted first."); + Assume.That(OtherTenantsAndDatabasesTested, Is.True, "A server already running may not let the tests create or look up other tenants and databases."); + var deletes = new DeletesByCollection { InnerHandler = new HttpClientHandler() }; + using var httpClient = new HttpClient(deletes); + var chroma = new ChromaClient(BaseConfigurationOptions, httpClient); + var database = $"database{Random.Shared.Next()}"; + await chroma.CreateDatabaseAsync(database); + for (var i = 0; i < 120; i++) + { + var collection = await chroma.CreateCollectionAsync($"collection{i:000}", database: database); + await new ChromaCollectionClient(collection, BaseConfigurationOptions.WithDatabase(database), httpClient).AddAsync(["a"], [Embedding1]); + } + deletes.Collections.Clear(); + await chroma.DeleteDatabaseAsync(database, deleteRecordsFirst: true); + Assert.That(deletes.Collections, Has.Count.EqualTo(120)); + } + + // The collections whose records a request deletes. + sealed class DeletesByCollection : DelegatingHandler + { + public HashSet Collections { get; } = []; + + protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + var segments = request.RequestUri!.AbsolutePath.Split('/'); + if (segments[^1] == "delete") + { + lock (Collections) + { + Collections.Add(segments[^2]); + } + } + return base.SendAsync(request, cancellationToken); + } + } + + async Task AddRecordsWithLists(ChromaConfigurationOptions options) + { + var client = await Init(options); + await client.AddAsync(new ChromaRecords(["a", "b"]) + { + Embeddings = [Embedding1, Embedding2], + Metadatas = [new Dictionary { ["texts"] = new[] { "x", "y" } }, new Dictionary { ["ints"] = new[] { 1, 2 } }], + }); + return client; + } + + async Task AssertNoListsInNewRecords() + { + var client = await Init(BaseConfigurationOptions); + var ids = Enumerable.Range(0, 4).Select(i => $"new{i}").ToList(); + await client.AddAsync(new ChromaRecords(ids) + { + Embeddings = ids.Select(_ => Embedding1).ToList(), + Metadatas = ids.Select(_ => (IReadOnlyDictionary)new Dictionary { ["k"] = 1 }).ToList(), + }); + Assert.That((await client.GetAsync(include: ChromaGetInclude.Metadatas)).Select(x => string.Join(",", x.Metadata!.Keys)), Has.All.EqualTo("k")); + Assert.That(await client.GetAsync(where: ChromaWhereOperator.Contains("texts", "x"), include: ChromaGetInclude.None), Is.Empty); + } + + // Records the last segment of the path of each request it sends. + sealed class Requests() : DelegatingHandler(new HttpClientHandler()) + { + public List Paths { get; } = []; + + protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + var path = request.RequestUri!.AbsolutePath; + Paths.Add(path.Substring(path.LastIndexOf('/') + 1)); + return base.SendAsync(request, cancellationToken); + } + } + + async Task Init(ChromaConfigurationOptions options) + { + var collection = await new ChromaClient(options, HttpClient).CreateCollectionAsync($"collection{Random.Shared.Next()}"); + return new ChromaCollectionClient(collection, options, HttpClient); + } +} diff --git a/ChromaDB.Client.Tests/MissingEndpointTests.cs b/ChromaDB.Client.Tests/MissingEndpointTests.cs new file mode 100644 index 0000000..2e4d474 --- /dev/null +++ b/ChromaDB.Client.Tests/MissingEndpointTests.cs @@ -0,0 +1,27 @@ +using System.Net; +using NUnit.Framework; + +namespace ChromaDB.Client.Tests; + +// Older Chroma versions answer the endpoints they do not have with a bare 404 or 405: the message names the request. +[TestFixture] +public class MissingEndpointTests +{ + [TestCase(HttpStatusCode.NotFound, """{"detail":"Not Found"}""", "Not Found: POST /api/v2/tenants")] + [TestCase(HttpStatusCode.MethodNotAllowed, """{"detail":"Method Not Allowed"}""", "Method Not Allowed: POST /api/v2/tenants")] + [TestCase(HttpStatusCode.NotFound, "", "NotFound: POST /api/v2/tenants")] + [TestCase(HttpStatusCode.NotFound, """{"error":"NotFoundError","message":"Tenant [t] not found"}""", "Tenant [t] not found")] + [TestCase(HttpStatusCode.BadRequest, """{"detail":"Not Found"}""", "Not Found")] + public async Task Message(HttpStatusCode statusCode, string body, string expected) + { + using var httpClient = new HttpClient(new FixedResponseHandler(statusCode, body)); + var client = new ChromaClient(new ChromaConfigurationOptions("http://localhost:8000"), httpClient); + await Assert.ThatAsync(() => client.CreateTenantAsync("t"), Throws.InstanceOf().With.Message.EqualTo(expected)); + } + + sealed class FixedResponseHandler(HttpStatusCode statusCode, string body) : HttpMessageHandler + { + protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + => Task.FromResult(new HttpResponseMessage(statusCode) { Content = new StringContent(body) }); + } +} diff --git a/ChromaDB.Client.Tests/MockingTests.cs b/ChromaDB.Client.Tests/MockingTests.cs new file mode 100644 index 0000000..b146a7d --- /dev/null +++ b/ChromaDB.Client.Tests/MockingTests.cs @@ -0,0 +1,56 @@ +using System.Net; +using System.Text.Json; +using ChromaDB.Client.Models; +using NUnit.Framework; + +namespace ChromaDB.Client.Tests; + +// The clients can be mocked, as in the Azure SDKs: a subclass made with the protected constructor overrides what a test needs. +[TestFixture] +public class MockingTests +{ + [Test] + public async Task MockedClients() + { + ChromaClient client = new MockClient(); + var collection = await client.GetCollectionAsync("articles"); + var results = await client.GetCollectionClient(collection).QueryAsync(new ReadOnlyMemory([1f, 0f]), ids: ["a"]); + Assert.That((collection.Name, results.Single().Id), Is.EqualTo(("articles", "a"))); + } + + // The ids of QueryAsync with one or more embeddings go in the request, as with a ChromaQuery. + [Test] + public async Task QueryWithIds() + { + var bodies = new List(); + var handler = new Handler(bodies, """{"ids":[["a"]],"distances":[[0.1]]}"""); + var collectionClient = new ChromaCollectionClient(Guid.Empty, "c", new ChromaConfigurationOptions("http://localhost:8000"), new HttpClient(handler)); + await collectionClient.QueryAsync(new ReadOnlyMemory([1f, 0f]), ids: ["a", "b"]); + await collectionClient.QueryAsync([new ReadOnlyMemory([1f, 0f])], ids: ["a", "b"]); + Assert.That(bodies.Select(x => x.GetProperty("ids").GetRawText()), Is.EqualTo(new[] { """["a","b"]""", """["a","b"]""" })); + } + + sealed class MockClient : ChromaClient + { + public override Task GetCollectionAsync(string name, string? tenant = null, string? database = null, CancellationToken cancellationToken = default) + => Task.FromResult(new ChromaCollection(name)); + + public override ChromaCollectionClient GetCollectionClient(ChromaCollection collection) + => new MockCollectionClient(); + } + + sealed class MockCollectionClient : ChromaCollectionClient + { + public override Task> QueryAsync(ReadOnlyMemory queryEmbeddings, int nResults = 10, ChromaWhereOperator? where = null, ChromaWhereDocumentOperator? whereDocument = null, ChromaQueryInclude? include = null, IReadOnlyList? ids = null, CancellationToken cancellationToken = default) + => Task.FromResult>(ids!.Select(id => new ChromaCollectionQueryEntry(id)).ToList()); + } + + sealed class Handler(List bodies, string answer) : HttpMessageHandler + { + protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + bodies.Add(JsonDocument.Parse(await request.Content!.ReadAsStringAsync(cancellationToken)).RootElement.Clone()); + return new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent(answer) }; + } + } +} diff --git a/ChromaDB.Client.Tests/NumbersTests.cs b/ChromaDB.Client.Tests/NumbersTests.cs new file mode 100644 index 0000000..a3e778e --- /dev/null +++ b/ChromaDB.Client.Tests/NumbersTests.cs @@ -0,0 +1,244 @@ +using System.Globalization; +using System.Security.Cryptography; +using System.Text; +using ChromaDB.Client.Common; +using ChromaDB.Client.Models; +using NUnit.Framework; + +namespace ChromaDB.Client.Tests; + +// The text of the doubles and floats in the requests: the fewest digits that read back as the number, ".0" on a whole +// number, the same on every build and runtime. +[TestFixture] +public class NumbersTests +{ + [TestCase(100.0, "100.0")] + [TestCase(0.1, "0.1")] + [TestCase(-0.0, "-0.0")] + [TestCase(0.0, "0.0")] + [TestCase(2.5, "2.5")] + [TestCase(-1.5, "-1.5")] + [TestCase(1e15, "1E+15")] + [TestCase(123456789012345.0, "123456789012345.0")] + [TestCase(0.0001, "0.0001")] + [TestCase(1e-5, "1E-05")] + [TestCase(1.0 / 3, "0.3333333333333333")] + [TestCase(0.30000000000000004, "0.30000000000000004")] + [TestCase(9007199254740993.0, "9.007199254740992E+15")] + [TestCase(5e-324, "5E-324")] + [TestCase(double.MaxValue, "1.7976931348623157E+308")] + // 2^-25 and 2^-958: "R" of .NET 10 gives 16 digits that read back as the double below. + [TestCase(2.98023223876953125E-08, "2.9802322387695312E-08")] + public void Doubles(double value, string text) + { + Assert.That(ChromaNumbers.Format(value), Is.EqualTo(text)); + } + + [TestCase(100f, "100.0")] + [TestCase(0.1f, "0.1")] + [TestCase(-0f, "-0.0")] + [TestCase(16777216f, "16777216.0")] + [TestCase(1e-7f, "1E-07")] + [TestCase(0.31594023f, "0.31594023")] + [TestCase(float.MaxValue, "3.4028235E+38")] + [TestCase(float.Epsilon, "1E-45")] + public void Floats(float value, string text) + { + Assert.That(ChromaNumbers.Format(value), Is.EqualTo(text)); + } + + [Test] + public void PowersOfTwoReadBack() + { + for (var e = -1074; e <= 1023; e++) + { + var value = Math.Pow(2, e); + Assert.That(ReadBack(ChromaNumbers.Format(value)), Is.EqualTo(value), "2^" + e); + } + for (var e = -149; e <= 127; e++) + { + var value = (float)Math.Pow(2, e); + Assert.That(float.Parse(ChromaNumbers.Format(value), CultureInfo.InvariantCulture), Is.EqualTo(value), "2^" + e); + } + } + + // "R" writes the shortest digits on .NET Core 3.0 and later, not on .NET Framework. + static readonly bool RuntimeShortest = Environment.Version.Major is 3 or >= 5; + + static int Bits(float value) => BitConverter.ToInt32(BitConverter.GetBytes(value), 0); + + // A text read back as the client reads the answers: double.Parse of .NET Framework misreads some of the shortest. + static double ReadBack(string text) => RuntimeShortest ? double.Parse(text, CultureInfo.InvariantCulture) : ChromaNumbers.ParseDouble(Encoding.ASCII.GetBytes(text)); + + // The shortest texts that .NET Framework, and System.Text.Json on it, read as the double next to them; the bits + // are the ones of a parser that rounds right. The client reads them right on every runtime. + [TestCase("3.16E-322", 0x0000000000000040L)] + [TestCase("5.021723523791529E-285", 0x04E7E548B948ED8DL)] + [TestCase("3.915010753235773E-227", 0x10EDADA8022037F4L)] + [TestCase("9.002841324384607E-274", 0x073F2B7FA8C10637L)] + [TestCase("2.4703282292062328E-324", 0x0000000000000001L)] + [TestCase("1.7976931348623157E308", 0x7FEFFFFFFFFFFFFFL)] + [TestCase("2.2250738585072011E-308", 0x000FFFFFFFFFFFFFL)] + [TestCase("-0.0", unchecked((long)0x8000000000000000UL))] + public async Task NumbersInAnswers(string text, long bits) + { + Assert.That(BitConverter.DoubleToInt64Bits(ChromaNumbers.ParseDouble(Encoding.ASCII.GetBytes(text))), Is.EqualTo(bits)); + var answer = "{\"ids\":[\"a\"],\"embeddings\":null,\"metadatas\":[{\"v\":" + text + ",\"l\":[" + text + "]}],\"documents\":null,\"uris\":null,\"include\":[\"metadatas\"]}"; + using var http = new HttpClient(new Answer(answer)); + var collection = new ChromaCollectionClient(Guid.NewGuid(), "c", new ChromaConfigurationOptions("http://localhost:8000").WithBatchSplitting(false), http); + var metadata = (await collection.GetAsync(include: ChromaGetInclude.Metadatas)).Single().Metadata!; + Assert.That(BitConverter.DoubleToInt64Bits((double)metadata["v"]), Is.EqualTo(bits)); + Assert.That(BitConverter.DoubleToInt64Bits((double)((List)metadata["l"])[0]), Is.EqualTo(bits)); + } + + // The text of 80,000 doubles and floats from a generator that gives the same numbers on every runtime: the same on + // .NET 8, where "R" gives the digits, and on .NET Framework, where they come from the exact value and from doubles. + [Test] + public void SameTextOnEveryRuntime() + { + ulong state = 20261005; + ulong Next() + { + var z = state += 0x9E3779B97F4A7C15UL; + z = (z ^ (z >> 30)) * 0xBF58476D1CE4E5B9UL; + z = (z ^ (z >> 27)) * 0x94D049BB133111EBUL; + return z ^ (z >> 31); + } + var text = new StringBuilder(); + for (var i = 0; i < 20000; i++) + { + var d = BitConverter.Int64BitsToDouble((long)Next()); + if (!double.IsNaN(d) && !double.IsInfinity(d)) text.Append(ChromaNumbers.Format(d)).Append('\n'); + var f = BitConverter.ToSingle(BitConverter.GetBytes((uint)Next()), 0); + if (!float.IsNaN(f) && !float.IsInfinity(f)) text.Append(ChromaNumbers.Format(f)).Append('\n'); + text.Append(ChromaNumbers.Format((Next() % 2000001) / 1000.0 - 1000)).Append('\n'); + text.Append(ChromaNumbers.Format((float)((Next() % 2000001) / 997.0))).Append('\n'); + } + using var sha = SHA256.Create(); + var hash = BitConverter.ToString(sha.ComputeHash(Encoding.UTF8.GetBytes(text.ToString()))).Replace("-", ""); + Assert.That(hash, Is.EqualTo("926F425A9739B9FD95D2E357A4CD0301CDF31678407E31DAC9784A185F95D723")); + } + + // The digits computed from the exact value, as on .NET Framework, are the ones of "R" on .NET 8 and later where those + // read back, and every text reads back as the number. + [Test] + public void ExactDigitsAreTheShortest() + { + var random = new Random(20261005); + var bytes = new byte[8]; + for (var i = 0; i < 20000; i++) + { + random.NextBytes(bytes); + var d = Math.Abs(BitConverter.ToDouble(bytes, 0)); + if (double.IsNaN(d) || double.IsInfinity(d) || d == 0) continue; + ChromaNumbers.ExactDigits(BitConverter.DoubleToInt64Bits(d), 52, 1075, out var digits, out var exponent); + var r = d.ToString("R", CultureInfo.InvariantCulture); + if (RuntimeShortest && double.Parse(r, CultureInfo.InvariantCulture) == d) + { + ChromaNumbers.FromText(r, out var rDigits, out var rExponent); + Assert.That((digits, exponent), Is.EqualTo((rDigits, rExponent)), r); + } + Assert.That(ReadBack(ChromaNumbers.Format(d)), Is.EqualTo(d)); + + var f = Math.Abs(BitConverter.ToSingle(bytes, 0)); + if (float.IsNaN(f) || float.IsInfinity(f) || f == 0) continue; + ChromaNumbers.ExactDigits(BitConverter.ToInt32(BitConverter.GetBytes(f), 0), 23, 150, out digits, out exponent); + var fr = f.ToString("R", CultureInfo.InvariantCulture); + if (RuntimeShortest && float.Parse(fr, CultureInfo.InvariantCulture) == f) + { + ChromaNumbers.FromText(fr, out var fDigits, out var fExponent); + Assert.That((digits, exponent), Is.EqualTo((fDigits, fExponent)), fr); + } + Assert.That(float.Parse(ChromaNumbers.Format(f), CultureInfo.InvariantCulture), Is.EqualTo(f)); + } + } + + // The digits of a float with doubles, the path of .NET Framework, are the exact ones whenever they decide: floats + // across the whole range, the subnormals and the powers of two. + [Test] + public void FloatDigitsAreTheExactOnes() + { + var random = new Random(20261005); + var decided = 0; + var bits = Enumerable.Range(1, 5000) + .Concat(Enumerable.Range(0, 254).Select(e => (e + 1) << 23)) + .Concat(Enumerable.Range(0, 200000).Select(_ => random.Next(1, 0x7F800000))) + .Concat([0x7F7FFFFF, 0x00800000, 0x007FFFFF]); + foreach (var b in bits) + { + if (!ChromaNumbers.FloatDigits(b, out var digits, out var exponent)) continue; + decided++; + ChromaNumbers.ExactDigits(b, 23, 150, out var exactDigits, out var exactExponent); + Assert.That((digits, exponent), Is.EqualTo((exactDigits, exactExponent)), b.ToString("X8", CultureInfo.InvariantCulture)); + } + Assert.That(decided, Is.GreaterThan(190000)); + } + + // -0.0 in an answer keeps its sign, which .NET Framework drops when it parses it: in embeddings, metadata and + // sparse vectors. + [Test] + public async Task NegativeZeroInAnswers() + { + const string answer = """{"ids":["a"],"embeddings":[[-0.0,1.0]],"metadatas":[{"z":-0.0,"s":{"#type":"sparse_vector","indices":[1],"values":[-0.0]}}],"documents":null,"uris":null,"include":["embeddings","metadatas"]}"""; + using var http = new HttpClient(new Answer(answer)); + var options = new ChromaConfigurationOptions("http://localhost:8000").WithBatchSplitting(false); + var collection = new ChromaCollectionClient(Guid.NewGuid(), "c", options, http); + var entry = (await collection.GetAsync(include: ChromaGetInclude.Embeddings | ChromaGetInclude.Metadatas)).Single(); + Assert.That(Bits(entry.Embedding!.Value.Span[0]), Is.LessThan(0)); + Assert.That(BitConverter.DoubleToInt64Bits((double)entry.Metadata!["z"]), Is.LessThan(0)); + Assert.That(Bits(((ChromaSparseVector)entry.Metadata["s"]).Values[0]), Is.LessThan(0)); + } + + // Chroma 1.x sends null for a float it cannot write, NaN or infinite: in a cosine collection an embedding beyond the range of a + // float, in an l2 one the distance to it. Chroma 0.6.3 sends that distance as a double beyond the range of a float. The other + // records of the answer come back as they are. + [Test] + public async Task FloatsThatChromaCannotWrite() + { + const string get = """{"ids":["ok","big"],"embeddings":[[3.0,4.0],[null,null]],"metadatas":null,"documents":null,"uris":null,"include":["embeddings"]}"""; + using var getHttp = new HttpClient(new Answer(get)); + var options = new ChromaConfigurationOptions("http://localhost:8000").WithBatchSplitting(false); + var entries = await new ChromaCollectionClient(Guid.NewGuid(), "c", options, getHttp).GetAsync(include: ChromaGetInclude.Embeddings); + Assert.That(entries.Select(x => x.Embedding!.Value.ToArray()), Is.EqualTo(new[] { new[] { 3f, 4f }, new[] { float.NaN, float.NaN } })); + + const string query = """{"ids":[["ok","tiny","big","far","ip"]],"embeddings":[[[3.0,4.0],[0.0,0.0],[null,null],[1.0,1.0],[2.0,2.0]]],"metadatas":null,"documents":null,"uris":null,"distances":[[0.0,25.0,null,2.3158415086764783e77,-2.381976426469702e39]],"include":["embeddings","distances"]}"""; + using var queryHttp = new HttpClient(new Answer(query)); + var results = (await new ChromaCollectionClient(Guid.NewGuid(), "c", options, queryHttp).QueryAsync([new([3f, 4f])], include: ChromaQueryInclude.Embeddings | ChromaQueryInclude.Distances)).Single(); + Assert.That(results.Select(x => x.Distance), Is.EqualTo(new float?[] { 0f, 25f, float.NaN, float.PositiveInfinity, float.NegativeInfinity })); + Assert.That(results[2].Embedding!.Value.ToArray(), Is.EqualTo(new[] { float.NaN, float.NaN })); + } + + sealed class Answer(string body) : HttpMessageHandler + { + protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + => Task.FromResult(new HttpResponseMessage(System.Net.HttpStatusCode.OK) { Content = new StringContent(body) }); + } + + [TestCase(double.NaN)] + [TestCase(double.PositiveInfinity)] + [TestCase(double.NegativeInfinity)] + public void NotANumber(double value) + { + Assert.That(() => ChromaNumbers.Format(value), Throws.ArgumentException); + Assert.That(() => ChromaNumbers.Format((float)value), Throws.ArgumentException); + } + + // In a filter: a whole double stays a float, so a list of doubles is not a list of ints and floats, which Chroma rejects. + [Test] + public void Filters() + { + Assert.That(ChromaWhereOperator.Equal("k", 2.0).ToString(), Is.EqualTo("{\"k\":{\"$eq\":2.0}}")); + Assert.That(ChromaWhereOperator.In("k", 0.0, 2.25).ToString(), Is.EqualTo("{\"k\":{\"$in\":[0.0,2.25]}}")); + Assert.That(ChromaWhereOperator.Equal("k", 2L).ToString(), Is.EqualTo("{\"k\":{\"$eq\":2}}")); + Assert.That(ChromaWhereOperator.GreaterThan("k", 0.1f).ToString(), Is.EqualTo("{\"k\":{\"$gt\":0.1}}")); + Assert.That(ChromaWhereOperator.Equal("k", 3m).ToString(), Is.EqualTo("{\"k\":{\"$eq\":3.0}}")); + Assert.That(ChromaWhereOperator.Equal("k", 2.50m).ToString(), Is.EqualTo("{\"k\":{\"$eq\":2.50}}")); + Assert.That(ChromaWhereOperator.Equal("k", -0.0).ToString(), Is.EqualTo("{\"k\":{\"$eq\":-0.0}}")); + } + + [Test] + public void SparseVectors() + { + Assert.That(new ChromaSparseVector([1, 2], [1f, 0.5f]).ToString(), Does.Contain("\"values\":[1.0,0.5]")); + } +} diff --git a/ChromaDB.Client.Tests/PathNamesTests.cs b/ChromaDB.Client.Tests/PathNamesTests.cs new file mode 100644 index 0000000..06edbad --- /dev/null +++ b/ChromaDB.Client.Tests/PathNamesTests.cs @@ -0,0 +1,47 @@ +using NUnit.Framework; + +namespace ChromaDB.Client.Tests; + +// Names that the path of a URL drops: the request would go to the resource above, GetCollectionAsync("..") to the +// database. The client stops before sending it. +[TestFixture] +public class PathNamesTests +{ + [TestCase("..")] + [TestCase(".")] + [TestCase("")] + public void NamesThePathDrops(string name) + { + using var client = new ChromaClient(new ChromaConfigurationOptions("http://localhost:8000"), new HttpClient(new NoRequests())); + Assert.That(() => client.GetCollectionAsync(name), Throws.ArgumentException); + Assert.That(() => client.CollectionExistsAsync(name), Throws.ArgumentException); + Assert.That(() => client.DeleteCollectionAsync(name), Throws.ArgumentException); + Assert.That(() => client.GetDatabaseAsync(name), Throws.ArgumentException); + if (name != "") + { + // An empty database or tenant of a collection is the one of the options. + Assert.That(() => client.GetCollectionAsync("c", database: name), Throws.ArgumentException); + Assert.That(() => client.GetCollectionAsync("c", tenant: name), Throws.ArgumentException); + } + Assert.That(() => client.GetTenantAsync(name), Throws.ArgumentException); + } + + // Dots inside a name stay: "a.b" and "..x" are names like the others. + [TestCase("a.b")] + [TestCase("..x")] + public void DotsInAName(string name) + { + var handler = new NoRequests(); + using var client = new ChromaClient(new ChromaConfigurationOptions("http://localhost:8000"), new HttpClient(handler)); + Assert.That(() => client.GetCollectionAsync(name), Throws.Exception.With.Message.Contains("/collections/" + name)); + } + + // Only the version goes: on Chroma 1.x a delete of a collection gets it first, to delete the records before. + sealed class NoRequests : HttpMessageHandler + { + protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + => request.RequestUri!.AbsolutePath.EndsWith("/version") + ? Task.FromResult(new HttpResponseMessage(System.Net.HttpStatusCode.OK) { Content = new StringContent("\"1.0.0\"") }) + : throw new InvalidOperationException("Request to " + request.RequestUri!.AbsolutePath); + } +} diff --git a/ChromaDB.Client.Tests/QueryIdsTests.cs b/ChromaDB.Client.Tests/QueryIdsTests.cs new file mode 100644 index 0000000..9adc112 --- /dev/null +++ b/ChromaDB.Client.Tests/QueryIdsTests.cs @@ -0,0 +1,211 @@ +using System.Net; +using System.Text.Json; +using ChromaDB.Client.Models; +using NUnit.Framework; + +namespace ChromaDB.Client.Tests; + +[TestFixture] +public class QueryIdsTests +{ + static readonly ReadOnlyMemory Embedding = new([1f, 0f]); + + [Test] + public async Task QuerySendsIds() + { + var handler = new RecordingHandler("""{"ids":[["a"]]}"""); + await Client(handler).QueryAsync(new ChromaQuery([Embedding]) { Ids = ["a", "c"] }); + Assert.That(handler.Body.GetProperty("ids").EnumerateArray().Select(x => x.GetString()), Is.EqualTo(new[] { "a", "c" })); + } + + [Test] + public async Task QueryWithoutIdsDoesNotSendThem() + { + var handler = new RecordingHandler("""{"ids":[["a"]]}"""); + await Client(handler).QueryAsync(Embedding); + Assert.That(handler.Body.TryGetProperty("ids", out _), Is.False); + } + + [Test] + public async Task QuerySendsTheOtherFields() + { + var handler = new RecordingHandler("""{"ids":[["a"]]}"""); + await Client(handler).QueryAsync(new ChromaQuery([Embedding]) { NResults = 3, Where = ChromaWhereOperator.Equal("k", 1), WhereDocument = ChromaWhereDocumentOperator.Contains("x"), Include = ChromaQueryInclude.Documents }); + Assert.That(handler.Body.GetProperty("n_results").GetInt32(), Is.EqualTo(3)); + Assert.That(handler.Body.GetProperty("where").GetProperty("k").GetProperty("$eq").GetInt32(), Is.EqualTo(1)); + Assert.That(handler.Body.GetProperty("where_document").GetProperty("$contains").GetString(), Is.EqualTo("x")); + Assert.That(handler.Body.GetProperty("include").EnumerateArray().Select(x => x.GetString()), Is.EqualTo(new[] { "documents" })); + } + + // Chroma 0.x ignores the ids: a result outside them must not reach the caller. + [Test] + public async Task ResultOutsideTheIdsThrows() + { + var handler = new RecordingHandler("""{"ids":[["a"],["a","b"]]}"""); + await Assert.ThatAsync(() => Client(handler).QueryAsync(new ChromaQuery([Embedding, Embedding]) { Ids = ["a", "c"] }), Throws.InstanceOf().With.Message.Contains("outside the ids")); + } + + [Test] + public async Task ResultsAmongTheIdsAreReturned() + { + var handler = new RecordingHandler("""{"ids":[["c","a"]]}"""); + var result = await Client(handler).QueryAsync(new ChromaQuery([Embedding]) { Ids = ["a", "c"] }); + Assert.That(result.Single().Select(x => x.Id), Is.EqualTo(new[] { "c", "a" })); + } + + // An empty list keeps no record: any result is outside it. + [Test] + public async Task EmptyIdsWithResultsThrows() + { + var handler = new RecordingHandler("""{"ids":[["a"]]}"""); + await Assert.ThatAsync(() => Client(handler).QueryAsync(new ChromaQuery([Embedding]) { Ids = [] }), Throws.InstanceOf()); + } + + // Chroma has no offset in queries: the client asks for the skipped records too, and leaves them out. + [Test] + public async Task OffsetAsksForTheSkippedRecordsAndLeavesThemOut() + { + var handler = new RecordingHandler("""{"ids":[["a","b","c"],["d","e"]]}"""); + var result = await Client(handler).QueryAsync(new ChromaQuery([Embedding, Embedding]) { NResults = 2, Offset = 1 }); + Assert.That(handler.Body.GetProperty("n_results").GetInt32(), Is.EqualTo(3)); + Assert.That(result.Select(entries => entries.Select(x => x.Id)), Is.EqualTo(new[] { new[] { "b", "c" }, new[] { "e" } })); + } + + // The results and the offset go together in n_results, which cannot go beyond the largest int. + [Test] + public async Task OffsetBeyondTheLargestNumberOfResultsThrows() + { + var handler = new RecordingHandler("""{"ids":[["a"]]}"""); + await Assert.ThatAsync(() => Client(handler).QueryAsync(new ChromaQuery([Embedding]) { NResults = int.MaxValue, Offset = 1 }), Throws.InstanceOf()); + Assert.That(handler.Body.ValueKind, Is.EqualTo(System.Text.Json.JsonValueKind.Undefined)); + } + + [Test] + public void NegativeOffsetThrows() + => Assert.That(() => new ChromaQuery([Embedding]) { Offset = -1 }, Throws.InstanceOf()); + + const string FindingIdError = """{"error":"InternalError","message":"Error executing plan: Internal error: Error finding id"}"""; + + // Chroma 1.x fails on an id without a record: the query goes again with the ids the server has, in their order. + [Test] + public async Task MissingIdIsLeftOut() + { + var handler = new ScriptedHandler("""{"ids":["c","a"]}""", (HttpStatusCode.InternalServerError, FindingIdError), (HttpStatusCode.OK, """{"ids":[["c","a"]]}""")); + var result = await Client(handler).QueryAsync(new ChromaQuery([Embedding]) { Ids = ["a", "missing", "c"], NResults = 3, Where = ChromaWhereOperator.Equal("k", 1), Include = ChromaQueryInclude.Distances }); + Assert.That(result.Single().Select(x => x.Id), Is.EqualTo(new[] { "c", "a" })); + Assert.That(handler.Requests.Select(x => x.Path), Is.EqualTo(new[] { "query", "pre-flight-checks", "get", "query" })); + var get = handler.Requests[2].Body; + Assert.That(get.GetProperty("ids").EnumerateArray().Select(x => x.GetString()), Is.EqualTo(new[] { "a", "missing", "c" })); + Assert.That(get.GetProperty("include").EnumerateArray(), Is.Empty); + Assert.That(get.TryGetProperty("where", out var where) && where.ValueKind != JsonValueKind.Null, Is.False); + var retry = handler.Requests[3].Body; + Assert.That(retry.GetProperty("ids").EnumerateArray().Select(x => x.GetString()), Is.EqualTo(new[] { "a", "c" })); + Assert.That(retry.GetProperty("n_results").GetInt32(), Is.EqualTo(3)); + Assert.That(retry.GetProperty("where").GetProperty("k").GetProperty("$eq").GetInt32(), Is.EqualTo(1)); + Assert.That(retry.GetProperty("include").EnumerateArray().Select(x => x.GetString()), Is.EqualTo(new[] { "distances" })); + } + + [Test] + public async Task OnlyMissingIdsHaveNoResults() + { + var handler = new ScriptedHandler("""{"ids":[]}""", (HttpStatusCode.InternalServerError, FindingIdError)); + var result = await Client(handler).QueryAsync(new ChromaQuery([Embedding, Embedding]) { Ids = ["missing"] }); + Assert.That(result, Has.Count.EqualTo(2)); + Assert.That(result, Has.All.Empty); + Assert.That(handler.Requests.Select(x => x.Path), Is.EqualTo(new[] { "query", "pre-flight-checks", "get" })); + } + + // When every id has a record, the error has another cause, which can pass: the query goes again with ReadRetries, and without + // attempts the error stands at once. + [Test] + public async Task InternalErrorWithAllTheIdsIsThrown() + { + var handler = new ScriptedHandler("""{"ids":["a","c"]}""", (HttpStatusCode.InternalServerError, FindingIdError)); + await Assert.ThatAsync(() => Client(handler, ChromaReadRetries.None).QueryAsync(new ChromaQuery([Embedding]) { Ids = ["a", "c"] }), + Throws.InstanceOf().With.Message.Contains("Error finding id").And.Property(nameof(ChromaException.StatusCode)).EqualTo(HttpStatusCode.InternalServerError)); + Assert.That(handler.Requests.Select(x => x.Path), Is.EqualTo(new[] { "query", "pre-flight-checks", "get" })); + } + + [Test] + public async Task InternalErrorWithAllTheIdsGoesAgain() + { + var handler = new ScriptedHandler("""{"ids":["a","c"]}""", (HttpStatusCode.InternalServerError, FindingIdError), (HttpStatusCode.InternalServerError, FindingIdError), (HttpStatusCode.OK, """{"ids":[["c","a"]]}""")); + var result = await Client(handler, new ChromaReadRetries(4, TimeSpan.Zero, TimeSpan.Zero)).QueryAsync(new ChromaQuery([Embedding]) { Ids = ["a", "c"] }); + Assert.That(result.Single().Select(x => x.Id), Is.EqualTo(new[] { "c", "a" })); + Assert.That(handler.Requests.Select(x => x.Path), Is.EqualTo(new[] { "query", "pre-flight-checks", "get", "query", "query" })); + } + + // The attempts count from the first query: at most MaxAttempts queries, and then the error stands. + [Test] + public async Task InternalErrorWithAllTheIdsGoesAgainUpToTheAttempts() + { + var handler = new ScriptedHandler("""{"ids":["a","c"]}""", Enumerable.Repeat((HttpStatusCode.InternalServerError, FindingIdError), 5).ToArray()); + await Assert.ThatAsync(() => Client(handler, new ChromaReadRetries(4, TimeSpan.Zero, TimeSpan.Zero)).QueryAsync(new ChromaQuery([Embedding]) { Ids = ["a", "c"] }), + Throws.InstanceOf().With.Message.Contains("Error finding id")); + Assert.That(handler.Requests.Select(x => x.Path), Is.EqualTo(new[] { "query", "pre-flight-checks", "get", "query", "query", "query" })); + } + + // Without ids, the error goes again with ReadRetries (ReadRetriesTests), and without attempts it stands at once. + [Test] + public async Task InternalErrorWithoutIdsIsThrown() + { + var handler = new ScriptedHandler("""{"ids":[]}""", (HttpStatusCode.InternalServerError, FindingIdError)); + await Assert.ThatAsync(() => Client(handler, ChromaReadRetries.None).QueryAsync(Embedding), Throws.InstanceOf().With.Message.Contains("Error finding id")); + Assert.That(handler.Requests.Select(x => x.Path), Is.EqualTo(new[] { "query" })); + } + + [Test] + public async Task InternalErrorWithEmptyIdsIsThrown() + { + var handler = new ScriptedHandler("""{"ids":[]}""", (HttpStatusCode.InternalServerError, FindingIdError)); + await Assert.ThatAsync(() => Client(handler, ChromaReadRetries.None).QueryAsync(new ChromaQuery([Embedding]) { Ids = [] }), Throws.InstanceOf().With.Message.Contains("Error finding id")); + Assert.That(handler.Requests.Select(x => x.Path), Is.EqualTo(new[] { "query" })); + } + + [Test] + public async Task OtherErrorWithIdsIsThrown() + { + var handler = new ScriptedHandler("""{"ids":[]}""", (HttpStatusCode.BadRequest, """{"error":"InvalidArgumentError","message":"bad"}""")); + await Assert.ThatAsync(() => Client(handler).QueryAsync(new ChromaQuery([Embedding]) { Ids = ["missing"] }), Throws.InstanceOf().With.Message.EqualTo("bad")); + Assert.That(handler.Requests.Select(x => x.Path), Is.EqualTo(new[] { "query" })); + } + + static ChromaCollectionClient Client(HttpMessageHandler handler, ChromaReadRetries? retries = null) + { + var collection = new ChromaCollection("collection") { Id = Guid.NewGuid() }; + return new ChromaCollectionClient(collection, new ChromaConfigurationOptions("http://localhost:8000").WithReadRetries(retries ?? ChromaReadRetries.Default), new HttpClient(handler)); + } + + sealed class RecordingHandler(string response) : HttpMessageHandler + { + public JsonElement Body { get; private set; } + + protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + Body = JsonDocument.Parse(await request.Content!.ReadAsStringAsync(cancellationToken)).RootElement.Clone(); + return new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent(response) }; + } + } + + // Answers the queries in turn, the gets with getResponse and pre-flight-checks with a batch size, and records the requests. + sealed class ScriptedHandler(string getResponse, params (HttpStatusCode Status, string Body)[] queryResponses) : HttpMessageHandler + { + int _queries; + + public List<(string Path, JsonElement Body)> Requests { get; } = []; + + protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + var path = request.RequestUri!.AbsolutePath.Split('/').Last(); + var body = request.Content is null ? default : JsonDocument.Parse(await request.Content.ReadAsStringAsync(cancellationToken)).RootElement.Clone(); + Requests.Add((path, body)); + var (status, response) = path switch + { + "pre-flight-checks" => (HttpStatusCode.OK, """{"max_batch_size":100}"""), + "get" => (HttpStatusCode.OK, getResponse), + _ => queryResponses[_queries++], + }; + return new HttpResponseMessage(status) { Content = new StringContent(response) }; + } + } +} diff --git a/ChromaDB.Client.Tests/ReadRetriesTests.cs b/ChromaDB.Client.Tests/ReadRetriesTests.cs new file mode 100644 index 0000000..4ac7cc4 --- /dev/null +++ b/ChromaDB.Client.Tests/ReadRetriesTests.cs @@ -0,0 +1,328 @@ +using System.Net; +using ChromaDB.Client.Models; +using NUnit.Framework; + +namespace ChromaDB.Client.Tests; + +// A read changes nothing, so after an error that can pass it goes again, with the exponential backoff with full jitter of AWS: Chroma +// 1.5.9 answers 500 to a get with embeddings while other clients write and delete the same records. The errors that can pass are an +// answer 429, 500, 502, 503 or 504 and a request that fails on the network; a missing collection, an answer 4xx and a write never go +// again. ChromaConfigurationOptions.ReadRetries sets the attempts and the delays, and ChromaReadRetries.None turns them off. +[TestFixture] +public class ReadRetriesTests +{ + const string Cloud = "https://api.trychroma.com"; + const string Local = "http://localhost:8000"; + const string FindingId = """{"error":"InternalError","message":"Error executing plan: Internal error: Error finding id"}"""; + const string SegmentReader = """{"error":"InternalError","message":"Error executing plan: Internal error: Error creating hnsw segment reader: Nothing found on disk"}"""; + static readonly ChromaReadRetries NoDelay = new(4, TimeSpan.Zero, TimeSpan.Zero); + static readonly string[] Reads = ["get", "get with ids", "peek", "query", "search", "count", "count at a read level"]; + + [Test] + public async Task AReadGoesAgainAfterAnErrorOfTheServer([ValueSource(nameof(Reads))] string read, [Values(FindingId, SegmentReader)] string error) + { + var server = new Server(fail: 2, (HttpStatusCode.InternalServerError, error)); + await Run(Client(server, Cloud, NoDelay), read); + Assert.That(server.Reads, Is.EqualTo(3)); + } + + [TestCase(429)] + [TestCase(500)] + [TestCase(502)] + [TestCase(503)] + [TestCase(504)] + public async Task TheStatusesThatCanPass(int status) + { + var server = new Server(fail: 1, ((HttpStatusCode)status, "{}")); + await Run(Client(server, Local, NoDelay), "get"); + Assert.That(server.Reads, Is.EqualTo(2)); + } + + [Test] + public async Task ARequestThatFailsOnTheNetwork() + { + var server = new Server(fail: 1, default) { Throw = true }; + await Run(Client(server, Local, NoDelay), "get"); + Assert.That(server.Reads, Is.EqualTo(2)); + } + + // The error of the last attempt stands. + [Test] + public void AfterTheLastAttemptTheErrorStands([ValueSource(nameof(Reads))] string read) + { + var server = new Server(fail: int.MaxValue, (HttpStatusCode.InternalServerError, FindingId)); + var ex = Assert.ThrowsAsync(() => Run(Client(server, Cloud, NoDelay), read)); + Assert.That(ex!.Message, Does.Contain("Error finding id")); + Assert.That(ex.StatusCode, Is.EqualTo(HttpStatusCode.InternalServerError)); + Assert.That(server.Reads, Is.EqualTo(4)); + } + + [TestCase(400)] + [TestCase(404)] + [TestCase(422)] + public void AClientErrorDoesNotGoAgain(int status) + { + var server = new Server(fail: int.MaxValue, ((HttpStatusCode)status, """{"error":"InvalidArgumentError","message":"wrong"}""")); + Assert.ThrowsAsync(() => Run(Client(server, Local, NoDelay), "get")); + Assert.That(server.Reads, Is.EqualTo(1)); + } + + // Chroma 0.x answers 500 for a collection that does not exist. + [Test] + public void AMissingCollectionDoesNotGoAgain() + { + var server = new Server(fail: int.MaxValue, (HttpStatusCode.InternalServerError, """{"error":"InvalidCollection","message":"Collection c does not exist."}""")); + Assert.ThrowsAsync(() => Run(Client(server, Local, NoDelay), "get")); + Assert.That(server.Reads, Is.EqualTo(1)); + } + + // The get that a write makes first goes again, as the one of SkipUnchangedEmbeddings; the write goes once. + [Test] + public async Task TheReadOfAWriteGoesAgain() + { + var server = new Server(fail: 1, (HttpStatusCode.InternalServerError, FindingId)); + var options = new ChromaConfigurationOptions(Local).WithReadRetries(NoDelay).WithUpsertStrategy(ChromaUpsertStrategy.SkipUnchangedEmbeddings); + var client = new ChromaCollectionClient(new ChromaCollection("c") { Id = Guid.NewGuid() }, options, new HttpClient(server)); + await client.UpsertAsync(new ChromaRecords(["a"]) { Embeddings = [new([1f, 0f])] }); + Assert.That(server.Reads, Is.EqualTo(2)); + Assert.That(server.Paths.Count(x => x == "upsert"), Is.EqualTo(1)); + } + + // A timeout of the HttpClient does not go again. + [Test] + public void ATimeoutDoesNotGoAgain() + { + var server = new Server(fail: 0, default) { Hang = TimeSpan.FromSeconds(30) }; + var client = new ChromaCollectionClient(new ChromaCollection("c") { Id = Guid.NewGuid() }, new ChromaConfigurationOptions(Local).WithReadRetries(NoDelay), new HttpClient(server) { Timeout = TimeSpan.FromMilliseconds(200) }); + var ex = Assert.ThrowsAsync(() => client.GetAsync()); + Assert.That(ex!.InnerException, Is.InstanceOf()); + Assert.That(server.Reads, Is.EqualTo(1)); + } + + // A write that failed may have gone: it never goes again. + [TestCase("add")] + [TestCase("update")] + [TestCase("upsert")] + [TestCase("delete")] + public void AWriteDoesNotGoAgain(string write) + { + var server = new Server(fail: int.MaxValue, (HttpStatusCode.InternalServerError, FindingId)) { Failing = write }; + var client = Client(server, Local, NoDelay); + var records = new ChromaRecords(["a"]) { Embeddings = [new([1f, 0f])] }; + Assert.ThrowsAsync(() => write switch + { + "add" => client.AddAsync(records), + "update" => client.UpdateAsync(records), + "upsert" => client.UpsertAsync(records), + _ => client.DeleteAsync(["a"]), + }); + Assert.That(server.Writes, Is.EqualTo(1)); + } + + [Test] + public void NoneTurnsThemOff() + { + var server = new Server(fail: 1, (HttpStatusCode.InternalServerError, FindingId)); + Assert.ThrowsAsync(() => Run(Client(server, Local, ChromaReadRetries.None), "get")); + Assert.That(server.Reads, Is.EqualTo(1)); + } + + // The default goes again too, with its delays. + [Test] + public async Task TheDefault() + { + var server = new Server(fail: 1, (HttpStatusCode.InternalServerError, FindingId)); + await new ChromaCollectionClient(new ChromaCollection("c") { Id = Guid.NewGuid() }, new ChromaConfigurationOptions(Local), new HttpClient(server)).GetAsync(); + Assert.That(server.Reads, Is.EqualTo(2)); + Assert.That(new ChromaConfigurationOptions(Local).ReadRetries, Is.SameAs(ChromaReadRetries.Default)); + Assert.That((ChromaReadRetries.Default.MaxAttempts, ChromaReadRetries.Default.BaseDelay, ChromaReadRetries.Default.MaxDelay), Is.EqualTo((4, TimeSpan.FromMilliseconds(50), TimeSpan.FromSeconds(1)))); + Assert.That((ChromaReadRetries.None.MaxAttempts, ChromaReadRetries.None.BaseDelay, ChromaReadRetries.None.MaxDelay), Is.EqualTo((1, TimeSpan.Zero, TimeSpan.Zero))); + Assert.That(ChromaReadRetries.Default.MinDelay, Is.EqualTo(TimeSpan.FromMilliseconds(40))); + Assert.That(ChromaReadRetries.None.MinDelay, Is.EqualTo(TimeSpan.Zero)); + } + + // The setting stays through the other options. + [Test] + public void TheSettingStays() + => Assert.That(new ChromaConfigurationOptions(Local).WithReadRetries(ChromaReadRetries.None).WithTenant("t").WithBatchSplitting(false).ReadRetries, Is.SameAs(ChromaReadRetries.None)); + + // And through WithMetadataValues of a client made by name, as a provider of Microsoft.Extensions.VectorData makes it. + [Test] + public void TheSettingStaysThroughWithMetadataValues() + { + var server = new Server(fail: int.MaxValue, (HttpStatusCode.InternalServerError, FindingId)); + var client = new ChromaClient(new ChromaConfigurationOptions(Local).WithReadRetries(ChromaReadRetries.None), new HttpClient(server)); + var collection = client.GetCollectionClient("c").WithMetadataValues(ChromaMetadataValues.Exact); + Assert.ThrowsAsync(() => collection.GetAsync()); + Assert.That(server.Reads, Is.EqualTo(1)); + } + + // A random time between the smallest delay and the smaller of the largest delay and the base times 2 to the attempts before. Without + // a smallest delay, it is 0.8 times the base, here 80 ms. + [TestCase(0, 0.0, 80)] + [TestCase(0, 0.5, 90)] + [TestCase(0, 1.0, 100)] + [TestCase(1, 0.0, 80)] + [TestCase(1, 1.0, 200)] + [TestCase(3, 1.0, 800)] + [TestCase(4, 1.0, 1000)] + [TestCase(4, 0.5, 540)] + [TestCase(62, 1.0, 1000)] + [TestCase(1000, 0.0, 80)] + [TestCase(1000, 1.0, 1000)] + public void TheDelay(int retry, double random, int milliseconds) + => Assert.That(new ChromaReadRetries(5, TimeSpan.FromMilliseconds(100), TimeSpan.FromSeconds(1)).Delay(retry, random), Is.EqualTo(TimeSpan.FromMilliseconds(milliseconds))); + + [TestCase(0, 0.0, 20)] + [TestCase(0, 0.5, 60)] + [TestCase(0, 1.0, 100)] + [TestCase(2, 0.5, 210)] + [TestCase(4, 0.0, 20)] + public void TheDelayWithASmallestDelay(int retry, double random, int milliseconds) + => Assert.That(new ChromaReadRetries(5, TimeSpan.FromMilliseconds(20), TimeSpan.FromMilliseconds(100), TimeSpan.FromSeconds(1)).Delay(retry, random), Is.EqualTo(TimeSpan.FromMilliseconds(milliseconds))); + + // The constructor without a smallest delay takes 0.8 times the base, the lower bound of the jitter of Azure.Core: never above the + // base, and zero with a base of zero. + [TestCase(0, 0)] + [TestCase(1, 0.8)] + [TestCase(50, 40)] + [TestCase(100, 80)] + public void TheSmallestDelayOfTheBase(int baseDelay, double minDelay) + => Assert.That(new ChromaReadRetries(2, TimeSpan.FromMilliseconds(baseDelay), TimeSpan.FromSeconds(1)).MinDelay, Is.EqualTo(TimeSpan.FromMilliseconds(minDelay))); + + [TestCase(0, 0, 0)] + [TestCase(1, -1, 0)] + [TestCase(1, 0, -1)] + [TestCase(1, 10, 5)] + public void WrongSettings(int attempts, int baseDelay, int maxDelay) + => Assert.That(() => new ChromaReadRetries(attempts, TimeSpan.FromMilliseconds(baseDelay), TimeSpan.FromMilliseconds(maxDelay)), Throws.InstanceOf()); + + // 0 <= minDelay <= baseDelay <= maxDelay, each error named by its parameter. + [TestCase(1, -1, 0, 0, "minDelay")] + [TestCase(1, 10, 5, 20, "minDelay")] + [TestCase(1, 0, -1, 0, "baseDelay")] + [TestCase(1, 0, 10, 5, "maxDelay")] + [TestCase(0, 0, 0, 0, "maxAttempts")] + public void WrongSmallestDelays(int attempts, int minDelay, int baseDelay, int maxDelay, string parameter) + => Assert.That(() => new ChromaReadRetries(attempts, TimeSpan.FromMilliseconds(minDelay), TimeSpan.FromMilliseconds(baseDelay), TimeSpan.FromMilliseconds(maxDelay)), + Throws.InstanceOf().With.Property("ParamName").EqualTo(parameter)); + + [TestCase(0, 0, 0)] + [TestCase(5, 5, 5)] + [TestCase(0, 10, 20)] + public void SmallestDelaysThatGo(int minDelay, int baseDelay, int maxDelay) + => Assert.That(new ChromaReadRetries(2, TimeSpan.FromMilliseconds(minDelay), TimeSpan.FromMilliseconds(baseDelay), TimeSpan.FromMilliseconds(maxDelay)).MinDelay, Is.EqualTo(TimeSpan.FromMilliseconds(minDelay))); + + // The constructor without a smallest delay names a negative base, as before. + [Test] + public void ANegativeBaseWithoutASmallestDelay() + => Assert.That(() => new ChromaReadRetries(2, TimeSpan.FromMilliseconds(-1), TimeSpan.Zero), Throws.InstanceOf().With.Property("ParamName").EqualTo("baseDelay")); + + // The longest delay of Task.Delay on every platform of the client, Int32.MaxValue milliseconds, is the largest one: beyond it Task.Delay + // would throw in the middle of a read. + [Test] + public void TheLongestDelay() + { + var longest = TimeSpan.FromMilliseconds(int.MaxValue); + Assert.That(new ChromaReadRetries(2, longest, longest).Delay(0, 1.0), Is.EqualTo(longest)); + Assert.That(() => new ChromaReadRetries(2, TimeSpan.Zero, longest + TimeSpan.FromMilliseconds(1)), Throws.InstanceOf().With.Property("ParamName").EqualTo("maxDelay")); + Assert.That(() => new ChromaReadRetries(2, TimeSpan.Zero, TimeSpan.MaxValue), Throws.InstanceOf()); + } + + // ReadRetries is never null, also when set directly. + [Test] + public void NoNull() + { + Assert.That(() => new ChromaConfigurationOptions(Local) { ReadRetries = null! }, Throws.ArgumentNullException); + Assert.That(() => new ChromaConfigurationOptions(Local).WithReadRetries(null!), Throws.ArgumentNullException.With.Property("ParamName").EqualTo("readRetries")); + } + + // A cancellation during the delay after a failed attempt stops the read at once: no other attempt goes. The jitter is 1, so the delay + // is 10 minutes: with a random one, a delay under the 200 ms of the cancellation would let the second attempt go first. + [Test] + public void ACancellationBetweenTheAttempts() + { + using var cancellation = new CancellationTokenSource(); + var server = new Server(fail: 1, (HttpStatusCode.InternalServerError, FindingId)) { OnFail = () => cancellation.CancelAfter(TimeSpan.FromMilliseconds(200)) }; + var client = Client(server, Local, new ChromaReadRetries(2, TimeSpan.FromMinutes(10), TimeSpan.FromMinutes(10)) { Jitter = () => 1 }); + var watch = System.Diagnostics.Stopwatch.StartNew(); + Assert.That(() => client.GetAsync(cancellationToken: cancellation.Token), Throws.InstanceOf()); + Assert.That(watch.Elapsed, Is.LessThan(TimeSpan.FromSeconds(5)), "the delay stops at the cancellation"); + Assert.That(server.Reads, Is.EqualTo(1)); + } + + // A short delay, the smallest one of 20 ms: the second attempt goes before the cancellation and the read succeeds. + [Test] + public async Task AShortDelayGoesAgain() + { + using var cancellation = new CancellationTokenSource(); + var server = new Server(fail: 1, (HttpStatusCode.InternalServerError, FindingId)) { OnFail = () => cancellation.CancelAfter(TimeSpan.FromSeconds(10)) }; + var client = Client(server, Local, new ChromaReadRetries(2, TimeSpan.FromMilliseconds(20), TimeSpan.FromMinutes(10), TimeSpan.FromMinutes(10)) { Jitter = () => 0 }); + var watch = System.Diagnostics.Stopwatch.StartNew(); + await client.GetAsync(cancellationToken: cancellation.Token); + Assert.That(watch.Elapsed, Is.LessThan(TimeSpan.FromSeconds(5)), "the delay is the smallest one"); + Assert.That(server.Reads, Is.EqualTo(2)); + } + + static Task Run(ChromaCollectionClient client, string read) => read switch + { + "get" => client.GetAsync(), + "get with ids" => client.GetAsync(["a"], include: ChromaGetInclude.Embeddings), + "peek" => client.PeekAsync(), + "query" => client.QueryAsync(new ChromaQuery([new([1f, 0f])])), + "search" => client.SearchAsync(new ChromaSearch { Limit = 5 }), + "count" => client.CountAsync(), + _ => client.CountAsync(ChromaReadLevel.IndexOnly), + }; + + static ChromaCollectionClient Client(HttpMessageHandler handler, string uri, ChromaReadRetries retries) + => new(new ChromaCollection("c") { Id = Guid.NewGuid() }, new ChromaConfigurationOptions(uri).WithReadRetries(retries), new HttpClient(handler)); + + // Answers the first requests of the reads, or of the write named in Failing, with the error, or throws, and then with an empty result. + sealed class Server(int fail, (HttpStatusCode Status, string Body) error) : HttpMessageHandler + { + static readonly string[] ReadPaths = ["get", "query", "search", "count"]; + public string? Failing { get; init; } + public bool Throw { get; init; } + public Action? OnFail { get; init; } + public TimeSpan Hang { get; init; } + public List Paths { get; } = []; + public int Reads { get; private set; } + public int Writes { get; private set; } + + protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + var path = request.RequestUri!.AbsolutePath; + var last = path[(path.LastIndexOf('/') + 1)..]; + Paths.Add(last); + var counted = Failing is null ? ReadPaths.Contains(last) : last == Failing; + var count = counted ? Failing is null ? ++Reads : ++Writes : 0; + if (counted && Hang > TimeSpan.Zero) + { + await Task.Delay(Hang, cancellationToken); + } + if (counted && count <= fail) + { + OnFail?.Invoke(); + if (Throw) + { + throw new HttpRequestException("The connection was reset."); + } + return new HttpResponseMessage(error.Status) { Content = new StringContent(error.Body) }; + } + var answer = last switch + { + "version" => "\"1.5.9\"", + "pre-flight-checks" => """{"max_batch_size":100,"supports_base64_encoding":false}""", + "get" => """{"ids":[],"include":[]}""", + "query" => """{"ids":[[]],"include":[]}""", + "search" => """{"ids":[[]]}""", + "count" => "0", + "delete" => """{"deleted":0}""", + "c" => """{"id":"11111111-2222-3333-4444-555555555555","name":"c"}""", + _ => "{}", + }; + return new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent(answer) }; + } + } +} diff --git a/ChromaDB.Client.Tests/RecordUrisTests.cs b/ChromaDB.Client.Tests/RecordUrisTests.cs new file mode 100644 index 0000000..0b78e0b --- /dev/null +++ b/ChromaDB.Client.Tests/RecordUrisTests.cs @@ -0,0 +1,79 @@ +using System.Net; +using System.Text.Json; +using ChromaDB.Client.Models; +using NUnit.Framework; + +namespace ChromaDB.Client.Tests; + +[TestFixture] +public class RecordUrisTests +{ + static readonly ReadOnlyMemory Embedding = new([1f, 0f]); + + [Test] + public async Task AddSendsUris() + { + var handler = new RecordingHandler("true"); + await Client(handler).AddAsync(new ChromaRecords(["a", "b"]) { Embeddings = [Embedding, Embedding], Uris = ["file://a", null] }); + Assert.That(handler.Body.GetProperty("uris").EnumerateArray().Select(x => x.GetString()), Is.EqualTo(new[] { "file://a", null })); + } + + [Test] + public async Task AddWithoutUrisDoesNotSendThem() + { + var handler = new RecordingHandler("true"); + await Client(handler).AddAsync(["a"], embeddings: [Embedding]); + Assert.That(handler.Body.TryGetProperty("uris", out _), Is.False); + } + + [Test] + public async Task UpdateAndUpsertSendUris() + { + var handler = new RecordingHandler("null"); + var client = Client(handler); + await client.UpdateAsync(new ChromaRecords(["a"]) { Uris = ["file://b"] }); + Assert.That(handler.Body.GetProperty("uris")[0].GetString(), Is.EqualTo("file://b")); + await client.UpsertAsync(new ChromaRecords(["a"]) { Embeddings = [Embedding], Uris = ["file://c"] }); + Assert.That(handler.Body.GetProperty("uris")[0].GetString(), Is.EqualTo("file://c")); + } + + [Test] + public async Task GetIncludesAndReadsUris() + { + var handler = new RecordingHandler("""{"ids":["a","b"],"uris":["file://a",null]}"""); + var result = await Client(handler).GetAsync(include: ChromaGetInclude.Uris); + Assert.That(handler.Body.GetProperty("include").EnumerateArray().Select(x => x.GetString()), Is.EqualTo(new[] { "uris" })); + Assert.That(result.Select(x => x.Uri), Is.EqualTo(new[] { "file://a", null })); + } + + [Test] + public async Task QueryIncludesAndReadsUris() + { + var handler = new RecordingHandler("""{"ids":[["a"]],"uris":[["file://a"]]}"""); + var result = await Client(handler).QueryAsync(Embedding, include: ChromaQueryInclude.Uris); + Assert.That(handler.Body.GetProperty("include").EnumerateArray().Select(x => x.GetString()), Is.EqualTo(new[] { "uris" })); + Assert.That(result.Single().Uri, Is.EqualTo("file://a")); + } + + static ChromaCollectionClient Client(HttpMessageHandler handler) + { + var collection = new ChromaCollection("collection") { Id = Guid.NewGuid() }; + return new ChromaCollectionClient(collection, new ChromaConfigurationOptions("http://localhost:8000"), new HttpClient(handler)); + } + + sealed class RecordingHandler(string response) : HttpMessageHandler + { + public JsonElement Body { get; private set; } + + // pre-flight-checks, which batch splitting asks before the writes, has no body. + protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + if (request.Content is null) + { + return new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent("""{"max_batch_size":1000}""") }; + } + Body = JsonDocument.Parse(await request.Content.ReadAsStringAsync(cancellationToken)).RootElement.Clone(); + return new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent(response) }; + } + } +} diff --git a/ChromaDB.Client.Tests/RecordsQuotaTests.cs b/ChromaDB.Client.Tests/RecordsQuotaTests.cs new file mode 100644 index 0000000..42f59ae --- /dev/null +++ b/ChromaDB.Client.Tests/RecordsQuotaTests.cs @@ -0,0 +1,97 @@ +using System.Net; +using System.Text.Json; +using ChromaDB.Client.Models; +using NUnit.Framework; + +namespace ChromaDB.Client.Tests; + +// The quota of records per request of Chroma Cloud: it declares a max_batch_size of 1000, takes 300 records per write, and rejects +// more with 422 before writing any. Against a fake server with a quota of its own. +[TestFixture] +public class RecordsQuotaTests +{ + // The rejected batch and the rest go in batches of the quota, and the next writes start from it. + [Test] + public async Task BatchesOfTheQuotaAfterARejection() + { + var server = new Server(quota: 3); + var client = Client(server, "http://localhost:8000"); + await client.AddAsync(Records(0, 7)); + await client.UpsertAsync(Records(7, 5)); + Assert.That(server.Requests, Is.EqualTo(new[] { ("add", 7), ("add", 3), ("add", 3), ("add", 1), ("upsert", 3), ("upsert", 2) })); + Assert.That(server.Written, Is.EqualTo(Ids(0, 12))); + } + + [Test] + public async Task DeleteInBatchesOfTheQuota() + { + var server = new Server(quota: 3); + await Client(server, "http://localhost:8000").DeleteAsync(Ids(0, 7)); + Assert.That(server.Requests, Is.EqualTo(new[] { ("delete", 7), ("delete", 3), ("delete", 3), ("delete", 1) })); + } + + // One request, as asked: the rejection is the error. + [Test] + public async Task NoBatchesWithoutBatchSplitting() + { + var server = new Server(quota: 3); + var client = new ChromaCollectionClient(Guid.Empty, "c", new ChromaConfigurationOptions("http://localhost:8000").WithBatchSplitting(false), new HttpClient(server)); + await Assert.ThatAsync(() => client.AddAsync(Records(0, 7)), Throws.InstanceOf().With.Message.Contains("exceeds limit of 3")); + Assert.That(server.Requests, Is.EqualTo(new[] { ("add", 7) })); + } + + // Another quota, like the keys of the metadata, is not about the number of records. + [Test] + public async Task OtherQuotasAreErrors() + { + var server = new Server(quota: 3, quotaName: "Number of metadata keys"); + await Assert.ThatAsync(() => Client(server, "http://localhost:8000").AddAsync(Records(0, 7)), Throws.InstanceOf()); + Assert.That(server.Requests, Is.EqualTo(new[] { ("add", 7) })); + } + + // On Chroma Cloud the batches are of 300 records from the start, its quota by default. + [Test] + public async Task BatchesOf300OnChromaCloud() + { + var server = new Server(quota: 300); + await Client(server, "https://api.trychroma.com").AddAsync(Records(0, 301)); + Assert.That(server.Requests, Is.EqualTo(new[] { ("add", 300), ("add", 1) })); + } + + static List Ids(int start, int count) => Enumerable.Range(start, count).Select(i => $"r{i}").ToList(); + + static ChromaRecords Records(int start, int count) + => new(Ids(start, count)) { Embeddings = Enumerable.Repeat(new ReadOnlyMemory([1f, 0f]), count).ToList() }; + + static ChromaCollectionClient Client(HttpMessageHandler handler, string uri) + => new(Guid.Empty, "c", new ChromaConfigurationOptions(uri), new HttpClient(handler)); + + // Declares a max_batch_size of 1000 and rejects a write of more records than its quota, with the message of Chroma Cloud. + sealed class Server(int quota, string quotaName = "Number of records") : HttpMessageHandler + { + public List<(string Action, int Records)> Requests { get; } = []; + public List Written { get; } = []; + + protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + var path = request.RequestUri!.AbsolutePath; + if (path.EndsWith("/pre-flight-checks")) + { + return Answer(HttpStatusCode.OK, """{"max_batch_size":1000,"supports_base64_encoding":false}"""); + } + using var body = JsonDocument.Parse(await request.Content!.ReadAsStringAsync(cancellationToken)); + var ids = body.RootElement.GetProperty("ids").EnumerateArray().Select(x => x.GetString()!).ToList(); + var action = path[(path.LastIndexOf('/') + 1)..]; + Requests.Add((action, ids.Count)); + if (ids.Count > quota) + { + var name = char.ToUpperInvariant(action[0]) + action[1..]; + return Answer((HttpStatusCode)422, $$"""{"error":"ChromaError","message":"Quota exceeded: '{{quotaName}}' exceeded quota limit for action '{{name}}': current usage of {{ids.Count}} exceeds limit of {{quota}}"}"""); + } + Written.AddRange(ids); + return Answer(HttpStatusCode.OK, "{}"); + } + + static HttpResponseMessage Answer(HttpStatusCode status, string body) => new(status) { Content = new StringContent(body) }; + } +} diff --git a/ChromaDB.Client.Tests/RegexMinimumLengthTests.cs b/ChromaDB.Client.Tests/RegexMinimumLengthTests.cs new file mode 100644 index 0000000..7a796b5 --- /dev/null +++ b/ChromaDB.Client.Tests/RegexMinimumLengthTests.cs @@ -0,0 +1,199 @@ +using ChromaDB.Client.Common; +using NUnit.Framework; + +namespace ChromaDB.Client.Tests; + +// Every element of the syntax of the Rust crate regex 1.12, the one of Chroma (https://docs.rs/regex/1.12.3/regex/#syntax), with the +// minimum length of a match in characters: 0 for anchors, boundaries and repetitions that may take nothing, null for what the client does +// not read. On Chroma Cloud a pattern of minimum length 0 finds every record (KD-56). Each value is the one of regex-syntax 0.8.10, the +// parser of Chroma, whose minimum length is in bytes: it is 0 for the same patterns. +[TestFixture] +public class RegexMinimumLengthTests +{ + // Matching one character. + [TestCase(".", 1)] + [TestCase("[0-9]", 1)] + [TestCase("\\d", 1)] + [TestCase("\\D", 1)] + [TestCase("\\pL", 1)] + [TestCase("\\p{Greek}", 1)] + [TestCase("\\PL", 1)] + [TestCase("\\P{Greek}", 1)] + // Character classes. + [TestCase("[xyz]", 1)] + [TestCase("[^xyz]", 1)] + [TestCase("[a-z]", 1)] + [TestCase("[[:alpha:]]", 1)] + [TestCase("[[:^alpha:]]", 1)] + [TestCase("[x[^xyz]]", 1)] + [TestCase("[a-y&&xyz]", 1)] + [TestCase("[0-9&&[^4]]", 1)] + [TestCase("[0-9--4]", 1)] + [TestCase("[a-g~~b-h]", 1)] + [TestCase("[\\[\\]]", 1)] + // An empty class: no character matches it, and Chroma rejects it. + [TestCase("[a&&b]", 1)] + // A ']' right after '[' or '[^' is a character; after a '-' it closes the class. + [TestCase("[]a]", 1)] + [TestCase("[^]a]", 1)] + [TestCase("[]a[:alpha:]]*", 0)] + [TestCase("[-]]", 2)] + // A '[' that ends a range is a character, and [:x:] with a name that is not of an ASCII class is a nested class. + [TestCase("[!-[]", 1)] + [TestCase("[!-[]|", 0)] + [TestCase("[[:x]]", 1)] + [TestCase("[[:x]a:]]", 2)] + // Composites. + [TestCase("xy", 2)] + [TestCase("x|y", 1)] + [TestCase("x|", 0)] + [TestCase("apple|pie", 3)] + // Repetitions, greedy and lazy. + [TestCase("x*", 0)] + [TestCase("x+", 1)] + [TestCase("x?", 0)] + [TestCase("x*?", 0)] + [TestCase("x+?", 1)] + [TestCase("x??", 0)] + [TestCase("x{2,3}", 2)] + [TestCase("x{0,3}", 0)] + [TestCase("x{2,}", 2)] + [TestCase("x{0,}", 0)] + [TestCase("x{2}", 2)] + [TestCase("x{0}", 0)] + [TestCase("x{2,3}?", 2)] + [TestCase("x{2,}?", 2)] + [TestCase("x{2}?", 2)] + [TestCase("ax*", 1)] + [TestCase("[a-z]+", 1)] + // Empty matches: anchors and boundaries take no character. + [TestCase("^", 0)] + [TestCase("$", 0)] + [TestCase("^$", 0)] + [TestCase("^a$", 1)] + [TestCase("\\A", 0)] + [TestCase("\\z", 0)] + [TestCase("\\b", 0)] + [TestCase("\\bapple\\b", 5)] + [TestCase("\\B", 0)] + [TestCase("\\b{start}", 0)] + [TestCase("\\<", 0)] + [TestCase("\\b{end}", 0)] + [TestCase("\\>", 0)] + [TestCase("\\b{start-half}", 0)] + [TestCase("\\b{end-half}x", 1)] + // Grouping and flags. + [TestCase("(apple)", 5)] + [TestCase("()", 0)] + [TestCase("(?Papple)", 5)] + [TestCase("(?Px*)", 0)] + [TestCase("(?apple)", 5)] + [TestCase("(?:apple)", 5)] + [TestCase("(?:)", 0)] + [TestCase("(?i)apple", 5)] + [TestCase("(?i)", 0)] + [TestCase("(?i:apple)", 5)] + [TestCase("(?m)^$", 0)] + [TestCase("(?s).", 1)] + [TestCase("(?mR)^x", 1)] + [TestCase("(?U)a+", 1)] + [TestCase("(?U)a*", 0)] + [TestCase("(?-u:a)", 1)] + // The flag x: blanks and # comments do not count, until the end of the group that sets it; an escaped blank does. After *, + and ? the + // lazy ? comes right after them, so x+ ? is (x+)?, while after a count it may come after blanks. + [TestCase("(?x)apple", 5)] + [TestCase("(?x) a p p l e # comment", 5)] + [TestCase("(?x)a\\ b", 3)] + [TestCase("(?x)a\\#b", 3)] + [TestCase("(?x)[ a ]", 1)] + [TestCase("(?x) a* ", 0)] + [TestCase("(?x)a+ ?", 0)] + [TestCase("(?x)a+?", 1)] + [TestCase("(?x)a{ 2 } ?", 2)] + [TestCase("(?x)a #c\n+", 1)] + [TestCase("(?ix:a)", 1)] + [TestCase("(?x:a b)c d", 5)] + [TestCase("(?x)(?-x) a", 2)] + [TestCase("(a(?x) b) c", 4)] + // Escape sequences. + [TestCase("\\*", 1)] + [TestCase("\\a", 1)] + [TestCase("\\f", 1)] + [TestCase("\\t", 1)] + [TestCase("\\n", 1)] + [TestCase("\\r", 1)] + [TestCase("\\v", 1)] + [TestCase("\\x7F", 1)] + [TestCase("\\x{10FFFF}", 1)] + [TestCase("\\u007F", 1)] + [TestCase("\\u{7F}", 1)] + [TestCase("\\U0000007F", 1)] + [TestCase("\\U{7F}", 1)] + [TestCase("\\p{Letter}", 1)] + [TestCase("\\P{Letter}", 1)] + // Perl character classes. + [TestCase("\\s", 1)] + [TestCase("\\S", 1)] + [TestCase("\\w", 1)] + [TestCase("\\W", 1)] + // ASCII character classes. + [TestCase("[[:alnum:]]", 1)] + [TestCase("[[:ascii:]]", 1)] + [TestCase("[[:blank:]]", 1)] + [TestCase("[[:cntrl:]]", 1)] + [TestCase("[[:digit:]]", 1)] + [TestCase("[[:graph:]]", 1)] + [TestCase("[[:lower:]]", 1)] + [TestCase("[[:print:]]", 1)] + [TestCase("[[:punct:]]", 1)] + [TestCase("[[:space:]]", 1)] + [TestCase("[[:upper:]]", 1)] + [TestCase("[[:word:]]", 1)] + [TestCase("[[:xdigit:]]", 1)] + // A character outside the BMP is one character. + [TestCase("😀", 1)] + // The empty pattern. + [TestCase("", 0)] + // A count of \b, not a special boundary; blanks around a count, also out of verbose mode. + [TestCase("\\b{2}", 0)] + [TestCase("x{ 2 }", 2)] + [TestCase("x{ 2 , 3 }", 2)] + // What Chroma rejects, and the client does not read: octal escapes and backreferences, look-arounds, \Z, a repetition without its + // minimum, an unclosed group or class, a repetition of nothing, flags with no flag or a negation at the end, an escape that is not a + // code point, an assertion in a class, and a count lower than its minimum. + [TestCase("\\1", null)] + [TestCase("(?=x)", null)] + [TestCase("(?!x)", null)] + [TestCase("(?<=x)", null)] + [TestCase("(?a)", null)] + [TestCase("(?P<1>a)", null)] + [TestCase("\\x{D800}", null)] + [TestCase("\\u{110000}", null)] + [TestCase("[\\b]", null)] + [TestCase("[\\A]", null)] + [TestCase("\\b{x}", null)] + [TestCase("\\p\\", null)] + [TestCase("\\é", null)] + public void MinimumLength(string pattern, int? expected) + => Assert.That(RegexMinimumLength.Of(pattern), Is.EqualTo(expected)); + + // Nesting deeper than regex-syntax accepts, which the client does not read either, and without running out of stack. + [Test] + public void DeepNesting() + { + Assert.That(RegexMinimumLength.Of(new string('(', 200) + "a" + new string(')', 200)), Is.EqualTo(1)); + Assert.That(RegexMinimumLength.Of(new string('(', 100_000) + "a" + new string(')', 100_000)), Is.Null); + Assert.That(RegexMinimumLength.Of(new string('[', 100_000) + "a" + new string(']', 100_000)), Is.Null); + } +} diff --git a/ChromaDB.Client.Tests/RequestValuesTests.cs b/ChromaDB.Client.Tests/RequestValuesTests.cs new file mode 100644 index 0000000..c041ee4 --- /dev/null +++ b/ChromaDB.Client.Tests/RequestValuesTests.cs @@ -0,0 +1,179 @@ +using ChromaDB.Client.Models; +using NUnit.Framework; + +namespace ChromaDB.Client.Tests; + +// Values that a server would store changed, drop, or fail on: the client stops them before the request. +[TestFixture] +public class RequestValuesTests +{ + static readonly ReadOnlyMemory Embedding = new([1f, 0f]); + + // UTF-8 has no form for a lone half of a surrogate pair: System.Text.Json would send U+FFFD in its place. + [Test] + public void LoneSurrogates() + { + Assert.That(() => ChromaWhereOperator.Equal("k", "a\uD800b").ToString(), Throws.ArgumentException.With.Message.Contains("U+D800")); + Assert.That(() => ChromaWhereOperator.Equal("k\uDC00", "x").ToString(), Throws.ArgumentException.With.Message.Contains("U+DC00")); + Assert.That(() => ChromaWhereDocumentOperator.Contains("\uD83D").ToString(), Throws.ArgumentException); + Assert.That(ChromaWhereOperator.Equal("k", "\U0001F600").ToString(), Does.Contain("$eq")); + } + + [Test] + public void LoneSurrogatesInRecords() + { + var collection = Collection(new PreFlightOnly()); + Assert.That(() => collection.AddAsync(["a\uD800"], [Embedding]), Throws.ArgumentException); + Assert.That(() => collection.AddAsync(["a"], [Embedding], documents: ["\uDFFF"]), Throws.ArgumentException); + Assert.That(() => collection.AddAsync(["a"], [Embedding], [new Dictionary { ["k\uD800"] = 1L }]), Throws.ArgumentException); + Assert.That(() => collection.GetAsync(ids: ["a\uD800"]), Throws.ArgumentException); + } + + [Test] + public void LoneSurrogatesInNames() + { + using var client = new ChromaClient(new ChromaConfigurationOptions("http://localhost:8000"), new HttpClient(new PreFlightOnly())); + Assert.That(() => client.GetCollectionAsync("c\uD800"), Throws.ArgumentException); + } + + // Chroma stores integers as 64-bit signed numbers: a larger ulong would become a float. + [Test] + public void ULongs() + { + Assert.That(() => ChromaWhereOperator.Equal("k", ulong.MaxValue).ToString(), Throws.InstanceOf()); + Assert.That(ChromaWhereOperator.Equal("k", (ulong)long.MaxValue).ToString(), Is.EqualTo("{\"k\":{\"$eq\":9223372036854775807}}")); + var collection = Collection(new PreFlightOnly()); + Assert.That(() => collection.AddAsync(["a"], [Embedding], [new Dictionary { ["k"] = (ulong)long.MaxValue + 1 }]), Throws.InstanceOf()); + } + + // An empty list in the metadata of a record: Chroma 0.6.3 and 1.5.9 drop the key without an error, 1.0 to 1.4 + // reject it, Chroma Cloud stores it; the Python client rejects it. In an update or an upsert it deletes the key, as a null does: the + // write goes without a read. + [Test] + public void EmptyListsInRecords() + { + var empty = new Dictionary { ["tags"] = new List(), ["x"] = 1L }; + Assert.That(() => Collection(new NoRequests()).AddAsync(["a"], [Embedding], [empty]), Throws.ArgumentException.With.Message.Contains("\"tags\"")); + var collection = Collection(new PreFlightOnly()); + Assert.That(() => collection.UpsertAsync(["a"], [Embedding], [empty]), Throws.InvalidOperationException.With.Message.Contains("/upsert")); + Assert.That(() => collection.UpdateAsync(["a"], metadatas: [new Dictionary { ["n"] = Array.Empty() }]), Throws.InvalidOperationException.With.Message.Contains("/update")); + } + + // A null value deletes the key in an update or an upsert, on every tested Chroma, and the write goes without a read. An add has none to + // delete: Chroma 0.x drops the key and 1.x rejects the request, so AddAsync rejects it before. + [Test] + public void NullValuesInRecords() + { + var withNull = new Dictionary { ["a"] = null!, ["b"] = 1L }; + Assert.That(() => Collection(new NoRequests()).AddAsync(["a"], [Embedding], [withNull]), Throws.ArgumentException.With.Message.Contains("\"a\"")); + var collection = Collection(new PreFlightOnly()); + Assert.That(() => collection.UpdateAsync(["a"], metadatas: [withNull]), Throws.InvalidOperationException.With.Message.Contains("/update")); + Assert.That(() => collection.UpsertAsync(["a"], [Embedding], [withNull]), Throws.InvalidOperationException.With.Message.Contains("/upsert")); + } + + // No Chroma stores a list in the metadata of a collection: 0.x and 1.0 to 1.4 reject it, Chroma Cloud answers 500 + // and 1.5.9 closes the connection. + [Test] + public void ListsInCollectionMetadata() + { + using var client = new ChromaClient(new ChromaConfigurationOptions("http://localhost:8000"), new HttpClient(new NoRequests())); + var metadata = new Dictionary { ["tags"] = new[] { "a" } }; + Assert.That(() => client.CreateCollectionAsync("c", metadata), Throws.ArgumentException.With.Message.Contains("\"tags\"")); + Assert.That(() => client.GetOrCreateCollectionAsync("c", metadata), Throws.ArgumentException); + Assert.That(() => client.GetOrCreateCollectionAsync(new ChromaCollectionDefinition("c") { Metadata = new Dictionary { ["e"] = new List() } }), Throws.ArgumentException); + Assert.That(() => Collection(new NoRequests()).ModifyAsync(metadata: metadata), Throws.ArgumentException); + } + + // A byte[] goes as a base64 string, not as a list; a list that can be read once is not read by the checks, so it + // goes whole. The write reaches its request, which the handler stops. + [Test] + public void ValuesThatAreNotLists() + { + var collection = Collection(new PreFlightOnly()); + Assert.That(() => collection.AddAsync(["a"], [Embedding], [new Dictionary { ["b"] = Array.Empty(), ["c"] = new byte[] { 1, 2 } }]), Throws.InvalidOperationException.With.Message.Contains("/add")); + var once = new ReadOnce(["x", "y"]); + Assert.That(() => collection.AddAsync(["a"], [Embedding], [new Dictionary { ["tags"] = once }]), Throws.InvalidOperationException.With.Message.Contains("/add")); + Assert.That(once.Reads, Is.EqualTo(1)); + using var client = new ChromaClient(new ChromaConfigurationOptions("http://localhost:8000"), new HttpClient(new NoRequests())); + Assert.That(() => client.CreateCollectionAsync("c", new Dictionary { ["b"] = new byte[] { 1 } }), Throws.InvalidOperationException.With.Message.Contains("/collections")); + } + + // A delete with an empty list of ids and no filter would go with no condition: rejected, as DeleteAsync(ChromaDelete) does. + [Test] + public void DeleteWithoutIds() + { + Assert.That(() => Collection(new NoRequests()).DeleteAsync(new List()), Throws.ArgumentException); + } + + // Lists of another length than the ids would be cut differently in each batch: rejected before any request. + [Test] + public void ListsOfAnotherLength() + { + var collection = Collection(new NoRequests()); + Assert.That(() => collection.AddAsync(["a", "b"], [Embedding]), Throws.ArgumentException.With.Message.Contains("embeddings")); + Assert.That(() => collection.UpdateAsync(["a"], documents: ["x", "y"]), Throws.ArgumentException.With.Message.Contains("documents")); + Assert.That(() => collection.UpsertAsync(["a", "b"], [Embedding, Embedding], [new Dictionary { ["k"] = 1L }]), Throws.ArgumentException.With.Message.Contains("metadatas")); + Assert.That(() => collection.AddAsync(new ChromaRecords(["a"]) { Embeddings = [Embedding], Uris = [] }), Throws.ArgumentException.With.Message.Contains("uris")); + } + + // A client made by name reads the collection on the first call: the checks come before, without any request. + [Test] + public void ChecksBeforeTheCollectionIsRead() + { + using var client = new ChromaClient(new ChromaConfigurationOptions("http://localhost:8000"), new HttpClient(new NoRequests())); + var collection = client.GetCollectionClient("c"); + Assert.That(() => collection.AddAsync(["a", "b"], [Embedding]), Throws.ArgumentException); + Assert.That(() => collection.UpdateAsync(["a"], documents: ["x", "y"]), Throws.ArgumentException); + Assert.That(() => collection.UpsertAsync(["a"], [new ReadOnlyMemory([float.NaN])]), Throws.ArgumentException); + Assert.That(() => collection.DeleteAsync(new List()), Throws.ArgumentException); + } + + // NaN and infinity: JSON numbers cannot carry them, and base64 embeddings get the same check. + [Test] + public void NonFiniteEmbeddingsInBase64() + { + var collection = Collection(new PreFlightOnly(base64: true)); + Assert.That(() => collection.AddAsync(["a"], [new ReadOnlyMemory([1f, float.NaN])]), Throws.ArgumentException); + Assert.That(() => collection.UpsertAsync(["a"], [new ReadOnlyMemory([float.PositiveInfinity, 0f])]), Throws.ArgumentException); + } + + sealed class ReadOnce(IEnumerable items) : IEnumerable + { + public int Reads { get; private set; } + + public IEnumerator GetEnumerator() + { + if (++Reads > 1) throw new InvalidOperationException("Read twice."); + return items.GetEnumerator(); + } + + System.Collections.IEnumerator System.Collections.IEnumerable.GetEnumerator() => GetEnumerator(); + } + + static ChromaCollectionClient Collection(HttpMessageHandler handler) + => new(Guid.NewGuid(), "c", new ChromaConfigurationOptions("http://localhost:8000"), new HttpClient(handler)); + + sealed class NoRequests : HttpMessageHandler + { + protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + => throw new InvalidOperationException("Request to " + request.RequestUri!.AbsolutePath); + } + + // Answers the questions about the server, so that a write gets as far as its request. + sealed class PreFlightOnly(bool base64 = false) : HttpMessageHandler + { + protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + var path = request.RequestUri!.AbsolutePath; + if (path.EndsWith("/pre-flight-checks", StringComparison.Ordinal)) + { + return Task.FromResult(new HttpResponseMessage(System.Net.HttpStatusCode.OK) { Content = new StringContent("{\"max_batch_size\":100,\"supports_base64_encoding\":" + (base64 ? "true" : "false") + "}") }); + } + if (path.EndsWith("/version", StringComparison.Ordinal)) + { + return Task.FromResult(new HttpResponseMessage(System.Net.HttpStatusCode.OK) { Content = new StringContent("\"1.0.0\"") }); + } + throw new InvalidOperationException("Request to " + path); + } + } +} diff --git a/ChromaDB.Client.Tests/ResponseCompatibilityTests.cs b/ChromaDB.Client.Tests/ResponseCompatibilityTests.cs new file mode 100644 index 0000000..5e17a82 --- /dev/null +++ b/ChromaDB.Client.Tests/ResponseCompatibilityTests.cs @@ -0,0 +1,59 @@ +using System.Net; +using ChromaDB.Client.Models; +using NUnit.Framework; + +namespace ChromaDB.Client.Tests; + +// Chroma 0.4.10 to 0.4.15 answer get and query without "uris", "data" and "included". +[TestFixture] +public class ResponseCompatibilityTests +{ + [Test] + public async Task GetWithoutUris() + { + var client = Client("""{"ids":["a"],"embeddings":null,"metadatas":[null],"documents":["first"]}"""); + var result = await client.GetAsync(["a"]); + Assert.That(result.Single().Id, Is.EqualTo("a")); + Assert.That(result.Single().Document, Is.EqualTo("first")); + } + + [Test] + public async Task QueryWithoutUris() + { + var client = Client("""{"ids":[["a"]],"embeddings":null,"metadatas":[[null]],"documents":[["first"]],"distances":[[0.5]]}"""); + var result = await client.QueryAsync(new ReadOnlyMemory([1f, 0f]), nResults: 1); + Assert.That(result.Single().Id, Is.EqualTo("a")); + Assert.That(result.Single().Document, Is.EqualTo("first")); + Assert.That(result.Single().Distance, Is.EqualTo(0.5f)); + } + + [Test] + public async Task GetWithIdsOnly() + { + var client = Client("""{"ids":["a","b"]}"""); + var result = await client.GetAsync(); + Assert.That(result.Select(x => x.Id), Is.EqualTo(new[] { "a", "b" })); + } + + [Test] + public async Task QueryWithIdsOnly() + { + var client = Client("""{"ids":[["a","b"]]}"""); + var result = await client.QueryAsync(new ReadOnlyMemory([1f, 0f]), nResults: 2); + Assert.That(result.Select(x => x.Id), Is.EqualTo(new[] { "a", "b" })); + Assert.That(result.Select(x => x.Distance), Has.All.Null); + } + + static ChromaCollectionClient Client(string response) + { + var httpClient = new HttpClient(new FixedResponseHandler(response)); + var collection = new ChromaCollection("collection") { Id = Guid.NewGuid() }; + return new ChromaCollectionClient(collection, new ChromaConfigurationOptions("http://localhost:8000"), httpClient); + } + + sealed class FixedResponseHandler(string response) : HttpMessageHandler + { + protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + => Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent(response) }); + } +} diff --git a/ChromaDB.Client.Tests/SearchRequestsTests.cs b/ChromaDB.Client.Tests/SearchRequestsTests.cs new file mode 100644 index 0000000..e129203 --- /dev/null +++ b/ChromaDB.Client.Tests/SearchRequestsTests.cs @@ -0,0 +1,198 @@ +using System.Net; +using System.Text.Json; +using ChromaDB.Client.Models; +using NUnit.Framework; + +namespace ChromaDB.Client.Tests; + +// The requests of the Search API as the Python client of Chroma 1.5.9 sends them, and the answers of Chroma Cloud, against a fake server. +[TestFixture] +public class SearchRequestsTests +{ + const string CollectionPath = "/api/v2/tenants/default_tenant/databases/default_database/collections/11111111-2222-3333-4444-555555555555"; + const string Empty = """{"ids":[[]],"documents":[null],"embeddings":[null],"metadatas":[null],"scores":[null],"select":[[]]}"""; + + [Test] + public async Task SearchWithEverything() + { + var server = new FakeServer(_ => (HttpStatusCode.OK, Empty)); + await Client(server).SearchAsync(new ChromaSearch + { + Where = ChromaWhereOperator.GreaterThanOrEqual("year", 2021), + WhereDocument = ChromaWhereDocumentOperator.Contains("apple"), + Ids = ["a", "b"], + Rank = ChromaRank.Knn(new([1f, 0f])), + Limit = 3, + Offset = 1, + Select = [ChromaSearchKeys.Document, ChromaSearchKeys.Score, "category"], + GroupBy = new ChromaSearchGroupBy(ChromaSearchAggregate.MinK(1, ChromaSearchKeys.Score), "category"), + }, ChromaReadLevel.IndexOnly); + Assert.That(server.Requests.Single().Line, Is.EqualTo($"POST {CollectionPath}/search")); + Assert.That(server.Requests.Single().Body.GetRawText(), Is.EqualTo(""" + {"searches":[{"filter":{"$and":[{"year":{"$gte":2021}},{"#document":{"$contains":"apple"}},{"#id":{"$in":["a","b"]}}]}, + "rank":{"$knn":{"query":[1.0,0.0],"key":"#embedding","limit":16}}, + "group_by":{"keys":["category"],"aggregate":{"$min_k":{"keys":["#score"],"k":1}}}, + "limit":{"offset":1,"limit":3},"select":{"keys":["#document","#score","category"]}}],"read_level":"index_only"} + """.Replace("\r", "").Replace("\n", "").Replace("\t", ""))); + } + + // Like the Python client: no filter and no rank are null, no grouping is {}, the offset is always sent. + [Test] + public async Task SearchWithNothing() + { + var server = new FakeServer(_ => (HttpStatusCode.OK, Empty)); + await Client(server).SearchAsync(new ChromaSearch()); + Assert.That(server.Requests.Single().Body.GetRawText(), Is.EqualTo("""{"searches":[{"filter":null,"rank":null,"group_by":{},"limit":{"offset":0},"select":{"keys":[]}}]}""")); + } + + // A single filter goes as it is; a document filter with & and | stays a tree on the #document key. + [Test] + public async Task SearchWithOneFilter() + { + var server = new FakeServer(_ => (HttpStatusCode.OK, Empty)); + await Client(server).SearchAsync(new ChromaSearch { WhereDocument = ChromaWhereDocumentOperator.Contains("a") | ChromaWhereDocumentOperator.Regex("^b") }); + Assert.That(server.Requests.Single().Body.GetProperty("searches")[0].GetProperty("filter").GetRawText(), + Is.EqualTo("""{"$or":[{"#document":{"$contains":"a"}},{"#document":{"$regex":"^b"}}]}""")); + } + + // A chain of the same operator goes as one list here too. + [Test] + public async Task SearchWithAChainOfDocumentFilters() + { + var server = new FakeServer(_ => (HttpStatusCode.OK, Empty)); + await Client(server).SearchAsync(new ChromaSearch { WhereDocument = ChromaWhereDocumentOperator.Contains("a") | ChromaWhereDocumentOperator.Contains("b") | ChromaWhereDocumentOperator.Regex("^c") }); + Assert.That(server.Requests.Single().Body.GetProperty("searches")[0].GetProperty("filter").GetRawText(), + Is.EqualTo("""{"$or":[{"#document":{"$contains":"a"}},{"#document":{"$contains":"b"}},{"#document":{"$regex":"^c"}}]}""")); + } + + // The JSON the Python client of Chroma 1.5.9 builds for Rrf: -(1 / (60 + rank1) + 1 / (60 + rank2)), which Chroma Cloud accepts. + [Test] + public void Rrf() + { + var rrf = ChromaRank.Rrf([ChromaRank.Knn(new([1f, 0f]), returnRank: true), ChromaRank.Knn(new([0f, 1f]), returnRank: true)]); + Assert.That(rrf.ToString(), Is.EqualTo(""" + {"$mul":[{"$val":-1.0},{"$sum":[ + {"$div":{"left":{"$val":1.0},"right":{"$sum":[{"$val":60.0},{"$knn":{"query":[1.0,0.0],"key":"#embedding","limit":16,"return_rank":true}}]}}}, + {"$div":{"left":{"$val":1.0},"right":{"$sum":[{"$val":60.0},{"$knn":{"query":[0.0,1.0],"key":"#embedding","limit":16,"return_rank":true}}]}}}]}]} + """.Replace("\r", "").Replace("\n", "").Replace("\t", ""))); + } + + // Only the records with a term of the text get points from it: 1 above a dot product of one millionth, 0 at the score 1 of the others, + // and never less than 0. + [Test] + public void HybridRrf() + { + var rrf = ChromaRank.HybridRrf(new([1f, 0f]), "apples", "doc_bm25", limit: 4); + Assert.That(rrf.ToString(), Is.EqualTo(""" + {"$mul":[{"$val":-1.0},{"$sum":[ + {"$div":{"left":{"$val":1.0},"right":{"$sum":[{"$val":60.0},{"$knn":{"query":[1.0,0.0],"key":"#embedding","limit":4,"default":4.0,"return_rank":true}}]}}}, + {"$div":{"left":{"$max":[{"$val":0.0},{"$min":[{"$val":1.0},{"$mul":[{"$sub":{"left":{"$val":1.0},"right":{"$knn":{"query":"apples","key":"doc_bm25","limit":4,"default":1.0}}}},{"$val":1000000.0}]}]}]}, + "right":{"$sum":[{"$val":60.0},{"$knn":{"query":"apples","key":"doc_bm25","limit":4,"default":4.0,"return_rank":true}}]}}}]}]} + """.Replace("\r", "").Replace("\n", "").Replace("\t", ""))); + } + + [TestCase(0, 60)] + [TestCase(4, 0)] + public void HybridRrfWithoutAPositiveLimitOrK(int limit, double k) + => Assert.That(() => ChromaRank.HybridRrf(new([1f, 0f]), "apples", "doc_bm25", limit, k), Throws.InstanceOf()); + + [Test] + public void RrfOfOneRankWithWeights() + { + var rrf = ChromaRank.Rrf([ChromaRank.Value(5)], k: 10, weights: [4], normalize: true); + Assert.That(rrf.ToString(), Is.EqualTo("""{"$mul":[{"$val":-1.0},{"$div":{"left":{"$val":1.0},"right":{"$sum":[{"$val":10.0},{"$val":5.0}]}}}]}""")); + } + + [Test] + public void RrfThatChromaRejects() + { + Assert.That(() => ChromaRank.Rrf([]), Throws.ArgumentException); + Assert.That(() => ChromaRank.Rrf([1], k: 0), Throws.InstanceOf()); + Assert.That(() => ChromaRank.Rrf([1, 2], weights: [1]), Throws.ArgumentException); + Assert.That(() => ChromaRank.Rrf([1], weights: [-1]), Throws.ArgumentException); + Assert.That(() => ChromaRank.Rrf([1], weights: [0], normalize: true), Throws.ArgumentException); + } + + // Sums and products are flattened as in the Python client; the other operators keep their two sides. + [Test] + public void RankOperators() + { + ChromaRank a = 1, b = 2, c = 3; + Assert.That((a + b + c).ToString(), Is.EqualTo("""{"$sum":[{"$val":1.0},{"$val":2.0},{"$val":3.0}]}""")); + Assert.That((a * (b * c)).ToString(), Is.EqualTo("""{"$mul":[{"$val":1.0},{"$val":2.0},{"$val":3.0}]}""")); + Assert.That((a - b).ToString(), Is.EqualTo("""{"$sub":{"left":{"$val":1.0},"right":{"$val":2.0}}}""")); + Assert.That((a / b).ToString(), Is.EqualTo("""{"$div":{"left":{"$val":1.0},"right":{"$val":2.0}}}""")); + Assert.That((-a).ToString(), Is.EqualTo("""{"$mul":[{"$val":-1.0},{"$val":1.0}]}""")); + Assert.That(ChromaRank.Max(a, b).ToString(), Is.EqualTo("""{"$max":[{"$val":1.0},{"$val":2.0}]}""")); + Assert.That(ChromaRank.Min(a, b).ToString(), Is.EqualTo("""{"$min":[{"$val":1.0},{"$val":2.0}]}""")); + Assert.That(ChromaRank.Abs(ChromaRank.Exp(ChromaRank.Log(a))).ToString(), Is.EqualTo("""{"$abs":{"$exp":{"$log":{"$val":1.0}}}}""")); + Assert.That(ChromaRank.Knn(new([1f]), "sparse_key", limit: 5, defaultScore: 10).ToString(), Is.EqualTo("""{"$knn":{"query":[1.0],"key":"sparse_key","limit":5,"default":10.0}}""")); + } + + // An answer of Chroma Cloud to two searches: the first selected document, score and one metadata field, the second only the ids. + [Test] + public async Task SearchResults() + { + var server = new FakeServer(_ => (HttpStatusCode.OK, """ + {"ids":[["a","d"],["c"]],"documents":[["apple pie","apple juice"],null],"embeddings":[[[1.0,0.0],null],null], + "metadatas":[[{"category":"dessert"},{"category":"drink"}],null],"scores":[[0.0,0.5],null],"select":[["#document","#score","category"],[]]} + """)); + var results = await Client(server).SearchAsync([new ChromaSearch(), new ChromaSearch()]); + Assert.That(results.Select(x => x.Select(e => e.Id)), Is.EqualTo(new[] { new[] { "a", "d" }, new[] { "c" } })); + var first = results[0][0]; + Assert.That((first.Document, first.Score, first.Metadata!["category"]), Is.EqualTo(("apple pie", 0f, (object)"dessert"))); + Assert.That(first.Embedding!.Value.ToArray(), Is.EqualTo(new[] { 1f, 0f })); + Assert.That(results[0][1].Embedding, Is.Null); + var second = results[1][0]; + Assert.That((second.Document, second.Embedding, second.Metadata, second.Score), Is.EqualTo(((string?)null, (ReadOnlyMemory?)null, (Dictionary?)null, (float?)null))); + } + + // A search ranked by HybridRrf comes back with the fused score, the highest first; another one with the score of Chroma. + [Test] + public async Task ScoresOfHybridRrf() + { + var server = new FakeServer(_ => (HttpStatusCode.OK, """{"ids":[["a"],["b"]],"scores":[[-0.032],[0.5]],"select":[["#score"],["#score"]]}""")); + // The BM25 index of the schema computes the vector of the text. + var schema = JsonDocument.Parse("""{"keys":{"doc_bm25":{"sparse_vector":{"sparse_vector_index":{"enabled":true,"config":{"source_key":"#document","bm25":true,"embedding_function":{"type":"known","name":"chroma_bm25","config":{}}}}}}}}""").RootElement.Clone(); + var client = new ChromaCollectionClient(new ChromaCollection("c") { Id = Guid.Parse("11111111-2222-3333-4444-555555555555"), SchemaJson = schema }, + new ChromaConfigurationOptions("http://localhost:8000"), new HttpClient(server)); + var hybrid = new ChromaSearch { Rank = ChromaRank.HybridRrf(new([1f, 0f]), "apples", "doc_bm25", limit: 4), Select = [ChromaSearchKeys.Score] }; + var results = await client.SearchAsync([hybrid, new ChromaSearch { Rank = ChromaRank.Knn(new([1f, 0f])), Select = [ChromaSearchKeys.Score] }]); + Assert.That((results[0][0].Score, results[1][0].Score), Is.EqualTo(((float?)0.032f, (float?)0.5f))); + } + + // What the Python client rejects, or what would ask the server for nothing: no request is sent. + [Test] + public async Task SearchThatIsRejected() + { + var server = new FakeServer(_ => (HttpStatusCode.OK, Empty)); + var client = Client(server); + await Assert.ThatAsync(() => client.SearchAsync([]), Throws.ArgumentException); + await Assert.ThatAsync(() => client.SearchAsync(new ChromaSearch { Offset = -1 }), Throws.InstanceOf()); + await Assert.ThatAsync(() => client.SearchAsync(new ChromaSearch { Limit = 0 }), Throws.InstanceOf()); + await Assert.ThatAsync(() => client.SearchAsync(new ChromaSearch { Ids = [] }), Throws.ArgumentException); + Assert.That(server.Requests, Is.Empty); + Assert.That(() => new ChromaSearchGroupBy(ChromaSearchAggregate.MinK(1, ChromaSearchKeys.Score)), Throws.ArgumentException); + Assert.That(() => ChromaSearchAggregate.MaxK(0, ChromaSearchKeys.Score), Throws.InstanceOf()); + Assert.That(() => ChromaSearchAggregate.MinK(1), Throws.ArgumentException); + } + + static ChromaCollectionClient Client(HttpMessageHandler handler) + => new(new ChromaCollection("c") { Id = Guid.Parse("11111111-2222-3333-4444-555555555555") }, new ChromaConfigurationOptions("http://localhost:8000"), new HttpClient(handler)); + + sealed record Request(string Line, JsonElement Body); + + sealed class FakeServer(Func answer) : HttpMessageHandler + { + public List Requests { get; } = []; + + protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + var text = request.Content is null ? null : await request.Content.ReadAsStringAsync(cancellationToken); + var recorded = new Request($"{request.Method.Method} {request.RequestUri!.PathAndQuery}", text is { Length: > 0 } ? JsonDocument.Parse(text).RootElement.Clone() : default); + Requests.Add(recorded); + var (status, response) = answer(recorded); + return new HttpResponseMessage(status) { Content = new StringContent(response) }; + } + } +} diff --git a/ChromaDB.Client.Tests/SnowballEnglishStemmerTests.cs b/ChromaDB.Client.Tests/SnowballEnglishStemmerTests.cs new file mode 100644 index 0000000..b3bbb2f --- /dev/null +++ b/ChromaDB.Client.Tests/SnowballEnglishStemmerTests.cs @@ -0,0 +1,389 @@ +using ChromaDB.Client.Common; +using NUnit.Framework; + +namespace ChromaDB.Client.Tests; + +// The stems are those of the Python package snowballstemmer 3.1.1: snowballstemmer.stemmer("english").stemWord(word). +[TestFixture] +public class SnowballEnglishStemmerTests +{ + static readonly SnowballEnglishStemmer Stemmer = new(); + + // Exceptional forms, and words near them. + [TestCase("andes", "andes")] + [TestCase("atlas", "atlas")] + [TestCase("bias", "bias")] + [TestCase("cosmos", "cosmos")] + [TestCase("early", "earli")] + [TestCase("gently", "gentl")] + [TestCase("howe", "howe")] + [TestCase("idly", "idl")] + [TestCase("news", "news")] + [TestCase("only", "onli")] + [TestCase("singly", "singl")] + [TestCase("skies", "sky")] + [TestCase("skis", "ski")] + [TestCase("sky", "sky")] + [TestCase("ugly", "ugli")] + [TestCase("atlases", "atlas")] + [TestCase("biases", "bias")] + [TestCase("earlier", "earlier")] + [TestCase("gentle", "gentl")] + [TestCase("howes", "how")] + [TestCase("idle", "idl")] + [TestCase("newsy", "newsi")] + [TestCase("skier", "skier")] + [TestCase("skiing", "ski")] + [TestCase("skied", "ski")] + [TestCase("ugliness", "ugli")] + // Prefixes that set R1 (arsen, commun, emerg, gener, inter, later, organ, past, univers). + [TestCase("arsenal", "arsenal")] + [TestCase("arsenic", "arsenic")] + [TestCase("communism", "communism")] + [TestCase("community", "communiti")] + [TestCase("communication", "communic")] + [TestCase("emergency", "emergenc")] + [TestCase("emerging", "emerg")] + [TestCase("general", "general")] + [TestCase("generally", "general")] + [TestCase("generate", "generat")] + [TestCase("generation", "generat")] + [TestCase("generous", "generous")] + [TestCase("interest", "interest")] + [TestCase("international", "internat")] + [TestCase("later", "later")] + [TestCase("laterally", "lateral")] + [TestCase("organ", "organ")] + [TestCase("organization", "organiz")] + [TestCase("organs", "organ")] + [TestCase("past", "past")] + [TestCase("pastry", "pastri")] + [TestCase("universal", "universal")] + [TestCase("universe", "univers")] + [TestCase("university", "universiti")] + // Words shorter than 3 characters stay as they are; 3 is enough. + [TestCase("a", "a")] + [TestCase("i", "i")] + [TestCase("ab", "ab")] + [TestCase("by", "by")] + [TestCase("is", "is")] + [TestCase("my", "my")] + [TestCase("us", "us")] + [TestCase("''", "''")] + [TestCase("'a", "'a")] + [TestCase("y", "y")] + [TestCase("Y", "Y")] + [TestCase("abs", "ab")] + [TestCase("bus", "bus")] + [TestCase("gas", "gas")] + [TestCase("yes", "yes")] + [TestCase("dye", "dye")] + [TestCase("eye", "eye")] + [TestCase("spy", "spi")] + [TestCase("ski", "ski")] + [TestCase("its", "it")] + [TestCase("cry", "cri")] + [TestCase("fly", "fli")] + [TestCase("aye", "aye")] + [TestCase("ies", "ie")] + // Apostrophes: one at the start, and ', 's or 's' at the end. + [TestCase("'tis", "tis")] + [TestCase("boy's", "boy")] + [TestCase("boys'", "boy")] + [TestCase("boys's", "boy")] + [TestCase("dog's'", "dog")] + [TestCase("o'clock", "o'clock")] + [TestCase("don't", "don't")] + [TestCase("rock'n'roll", "rock'n'rol")] + [TestCase("''yes", "'ye")] + [TestCase("''''", "''")] + [TestCase("'y", "'y")] + [TestCase("it's", "it")] + [TestCase("cats''", "cats'")] + [TestCase("james'", "jame")] + [TestCase("james's", "jame")] + [TestCase("'ss", "ss")] + [TestCase("'cats'", "cat")] + [TestCase("''s", "")] + // y at the start or after a vowel is a consonant. + [TestCase("yellow", "yellow")] + [TestCase("youth", "youth")] + [TestCase("yay", "yay")] + [TestCase("yoyo", "yoyo")] + [TestCase("kayak", "kayak")] + [TestCase("kayaking", "kayak")] + [TestCase("say", "say")] + [TestCase("says", "say")] + [TestCase("saying", "say")] + [TestCase("boy", "boy")] + [TestCase("boyish", "boyish")] + [TestCase("toying", "toy")] + [TestCase("employ", "employ")] + [TestCase("employment", "employ")] + [TestCase("playing", "play")] + [TestCase("buyer", "buyer")] + [TestCase("obeying", "obey")] + [TestCase("conveyance", "convey")] + [TestCase("annoyance", "annoy")] + [TestCase("rhythm", "rhythm")] + [TestCase("yyy", "yyy")] + [TestCase("eyeing", "eye")] + [TestCase("ayy", "ayi")] + // Step 1a. + [TestCase("caresses", "caress")] + [TestCase("ponies", "poni")] + [TestCase("ties", "tie")] + [TestCase("cries", "cri")] + [TestCase("tied", "tie")] + [TestCase("cried", "cri")] + [TestCase("spied", "spi")] + [TestCase("died", "die")] + [TestCase("caress", "caress")] + [TestCase("kisses", "kiss")] + [TestCase("buses", "buse")] + [TestCase("census", "census")] + [TestCase("gaps", "gap")] + [TestCase("kiwis", "kiwi")] + [TestCase("cats", "cat")] + [TestCase("this", "this")] + [TestCase("abyss", "abyss")] + [TestCase("viruses", "virus")] + [TestCase("status", "status")] + [TestCase("chaos", "chao")] + [TestCase("species", "speci")] + [TestCase("s's", "s")] + [TestCase("'by'", "by")] + // Step 1b: eed, ed, ing, edly, ingly. + [TestCase("feed", "feed")] + [TestCase("agreed", "agre")] + [TestCase("freed", "freed")] + [TestCase("proceed", "proceed")] + [TestCase("proceeding", "proceed")] + [TestCase("exceeded", "exceed")] + [TestCase("succeed", "succeed")] + [TestCase("plastered", "plaster")] + [TestCase("bled", "bled")] + [TestCase("motoring", "motor")] + [TestCase("sing", "sing")] + [TestCase("hoped", "hope")] + [TestCase("hoping", "hope")] + [TestCase("hopping", "hop")] + [TestCase("conflated", "conflat")] + [TestCase("troubled", "troubl")] + [TestCase("sized", "size")] + [TestCase("tanned", "tan")] + [TestCase("hissing", "hiss")] + [TestCase("fizzed", "fizz")] + [TestCase("failing", "fail")] + [TestCase("filing", "file")] + [TestCase("added", "add")] + [TestCase("ebbed", "ebb")] + [TestCase("egged", "egg")] + [TestCase("offed", "off")] + [TestCase("planned", "plan")] + [TestCase("stopping", "stop")] + [TestCase("stuffed", "stuf")] + [TestCase("rated", "rate")] + [TestCase("tabled", "tabl")] + [TestCase("liking", "like")] + [TestCase("luxuriated", "luxuri")] + [TestCase("opened", "open")] + [TestCase("aped", "ape")] + [TestCase("pasted", "paste")] + [TestCase("pasting", "paste")] + [TestCase("reproceed", "reproce")] + [TestCase("byed", "by")] + // Step 1b: ing after y, even, cann, inn, earr, herr and out. + [TestCase("dying", "die")] + [TestCase("lying", "lie")] + [TestCase("tying", "tie")] + [TestCase("vying", "vie")] + [TestCase("eying", "eye")] + [TestCase("inning", "inning")] + [TestCase("innings", "inning")] + [TestCase("outing", "outing")] + [TestCase("outings", "outing")] + [TestCase("canning", "canning")] + [TestCase("evening", "evening")] + [TestCase("evenings", "evening")] + [TestCase("earring", "earring")] + [TestCase("herring", "herring")] + [TestCase("herrings", "herring")] + [TestCase("sprouting", "sprout")] + [TestCase("spying", "spi")] + [TestCase("flying", "fli")] + [TestCase("buying", "buy")] + // Step 1b: edly and ingly. + [TestCase("markedly", "mark")] + [TestCase("excitedly", "excit")] + [TestCase("knowingly", "know")] + [TestCase("seemingly", "seem")] + [TestCase("hurriedly", "hurri")] + [TestCase("exceedingly", "exceed")] + [TestCase("supposedly", "suppos")] + [TestCase("amazingly", "amaz")] + [TestCase("agreedly", "agre")] + // Step 1c. + [TestCase("happy", "happi")] + [TestCase("ploy", "ploy")] + [TestCase("fry", "fri")] + [TestCase("sly", "sli")] + [TestCase("apology", "apolog")] + [TestCase("enjoy", "enjoy")] + [TestCase("flatly", "flat")] + [TestCase("obey", "obey")] + [TestCase("by's", "by")] + // Step 2. + [TestCase("hesitancy", "hesit")] + [TestCase("valency", "valenc")] + [TestCase("geology", "geolog")] + [TestCase("pedagogy", "pedagogi")] + [TestCase("analogy", "analog")] + [TestCase("quickly", "quick")] + [TestCase("warmly", "warm")] + [TestCase("deeply", "deepli")] + [TestCase("nicely", "nice")] + [TestCase("badly", "bad")] + [TestCase("mostly", "most")] + [TestCase("highly", "high")] + [TestCase("openly", "open")] + [TestCase("fairly", "fair")] + [TestCase("slowly", "slowli")] + [TestCase("conformably", "conform")] + [TestCase("radically", "radic")] + [TestCase("carefully", "care")] + [TestCase("beautifully", "beauti")] + [TestCase("carelessly", "careless")] + [TestCase("analogously", "analog")] + [TestCase("differently", "differ")] + [TestCase("formality", "formal")] + [TestCase("sensibility", "sensibl")] + [TestCase("sensitivity", "sensit")] + [TestCase("conditional", "condit")] + [TestCase("relational", "relat")] + [TestCase("feudalism", "feudal")] + [TestCase("predication", "predic")] + [TestCase("vietnamization", "vietnam")] + [TestCase("digitizer", "digit")] + [TestCase("operator", "oper")] + [TestCase("decisiveness", "decis")] + [TestCase("hopefulness", "hope")] + [TestCase("callousness", "callous")] + [TestCase("geologist", "geolog")] + [TestCase("biologist", "biolog")] + [TestCase("rely", "reli")] + // Step 3. + [TestCase("triplicate", "triplic")] + [TestCase("formative", "format")] + [TestCase("native", "nativ")] + [TestCase("formalize", "formal")] + [TestCase("electricity", "electr")] + [TestCase("electrical", "electr")] + [TestCase("additional", "addit")] + [TestCase("sensational", "sensat")] + [TestCase("hopeful", "hope")] + [TestCase("goodness", "good")] + [TestCase("demonstrative", "demonstr")] + [TestCase("argumentative", "argument")] + [TestCase("conditionally", "condit")] + [TestCase("sensationally", "sensat")] + // Step 4. + [TestCase("gyroscopic", "gyroscop")] + [TestCase("allowance", "allow")] + [TestCase("inference", "infer")] + [TestCase("adjustable", "adjust")] + [TestCase("defensible", "defens")] + [TestCase("activate", "activ")] + [TestCase("effective", "effect")] + [TestCase("bowdlerize", "bowdler")] + [TestCase("angularity", "angular")] + [TestCase("revival", "reviv")] + [TestCase("nationalism", "nation")] + [TestCase("adoption", "adopt")] + [TestCase("erosion", "eros")] + [TestCase("onion", "onion")] + [TestCase("airliner", "airlin")] + [TestCase("homologous", "homolog")] + [TestCase("irritant", "irrit")] + [TestCase("dependent", "depend")] + [TestCase("adjustment", "adjust")] + [TestCase("replacement", "replac")] + [TestCase("opinion", "opinion")] + [TestCase("champion", "champion")] + // Step 5. + [TestCase("probate", "probat")] + [TestCase("rate", "rate")] + [TestCase("cease", "ceas")] + [TestCase("controlling", "control")] + [TestCase("roll", "roll")] + [TestCase("fall", "fall")] + [TestCase("skill", "skill")] + [TestCase("parallel", "parallel")] + // Other words. + [TestCase("consign", "consign")] + [TestCase("consigned", "consign")] + [TestCase("consigning", "consign")] + [TestCase("consignment", "consign")] + [TestCase("consist", "consist")] + [TestCase("consistency", "consist")] + [TestCase("knightly", "knight")] + [TestCase("knives", "knive")] + // Digits. + [TestCase("1990s", "1990s")] + [TestCase("3rd", "3rd")] + [TestCase("x86", "x86")] + [TestCase("abc123", "abc123")] + [TestCase("1st", "1st")] + [TestCase("h2o", "h2o")] + [TestCase("mp3s", "mp3s")] + [TestCase("covid19", "covid19")] + [TestCase("24hours", "24hour")] + // Uppercase letters are not vowels, as in Python. + [TestCase("Running", "Run")] + [TestCase("RUNNING", "RUNNING")] + [TestCase("Cats", "Cat")] + [TestCase("CATS", "CATS")] + [TestCase("Yellow", "Yellow")] + [TestCase("YES", "YES")] + [TestCase("Happy", "Happi")] + // Letters outside ASCII are not vowels, as in Python. + [TestCase("caf\u00E9", "caf\u00E9")] + [TestCase("caf\u00E9s", "caf\u00E9")] + [TestCase("na\u00EFve", "na\u00EFv")] + [TestCase("na\u00EFvely", "na\u00EFv")] + [TestCase("r\u00E9sum\u00E9s", "r\u00E9sum\u00E9")] + [TestCase("\u00FCber", "\u00FCber")] + [TestCase("jalape\u00F1os", "jalape\u00F1o")] + [TestCase("fa\u00E7ades", "fa\u00E7ad")] + [TestCase("fianc\u00E9es", "fianc\u00E9")] + [TestCase("\u0395\u03BB\u03BB\u03B7\u03BD\u03B9\u03BA\u03AC", "\u0395\u03BB\u03BB\u03B7\u03BD\u03B9\u03BA\u03AC")] + [TestCase("\u03BB\u03CC\u03B3\u03BF\u03B9", "\u03BB\u03CC\u03B3\u03BF\u03B9")] + [TestCase("\u65E5\u672C\u8A9E", "\u65E5\u672C\u8A9E")] + [TestCase("\u0440\u0443\u0441\u0441\u043A\u0438\u0439", "\u0440\u0443\u0441\u0441\u043A\u0438\u0439")] + // A character outside the BMP is one character, as in Python. + [TestCase("\U0001F600ies", "\U0001F600ie")] + [TestCase("a\U0001F600ed", "a\U0001F600e")] + [TestCase("rocket\U0001F680s", "rocket\U0001F680")] + [TestCase("\U0001F600", "\U0001F600")] + [TestCase("\U0001F600\U0001F600", "\U0001F600\U0001F600")] + [TestCase("\U0001F600s", "\U0001F600s")] + [TestCase("\U0001F600\U0001F600s", "\U0001F600\U0001F600s")] + [TestCase("\U0001D44Eing", "\U0001D44Eing")] + public void Stem(string word, string stem) + { + Assert.That(Stemmer.Stem(word), Is.EqualTo(stem)); + } + + // One instance stems from several threads at once. + [Test] + public void SameStemsFromSeveralThreads() + { + var words = Enumerable.Repeat(new[] { "generously", "caresses", "dying", "skies", "\U0001F600ies", "hopping", "conditionally", "yellow" }, 1000) + .SelectMany(x => x) + .ToArray(); + var expected = words.Select(Stemmer.Stem).ToArray(); + var stems = new string[words.Length]; + Parallel.For(0, words.Length, i => stems[i] = Stemmer.Stem(words[i])); + Assert.That(stems, Is.EqualTo(expected)); + } +} diff --git a/ChromaDB.Client.Tests/SparseVectorsAndSchemaTests.cs b/ChromaDB.Client.Tests/SparseVectorsAndSchemaTests.cs new file mode 100644 index 0000000..f373a7a --- /dev/null +++ b/ChromaDB.Client.Tests/SparseVectorsAndSchemaTests.cs @@ -0,0 +1,419 @@ +using System.Net; +using System.Text.Json; +using ChromaDB.Client.Models; +using NUnit.Framework; + +namespace ChromaDB.Client.Tests; + +// Sparse vectors and the schema of a collection, as the Python client of Chroma 1.5.9 writes them and Chroma Cloud answers, against a fake server. +[TestFixture] +public class SparseVectorsAndSchemaTests +{ + const string CollectionsPath = "/api/v2/tenants/default_tenant/databases/default_database/collections"; + const string Bm25Schema = """{"defaults":{},"keys":{"doc_bm25":{"sparse_vector":{"sparse_vector_index":{"enabled":true,"config":{"embedding_function":{"type":"known","name":"chroma_bm25","config":{"k":1.2,"b":0.75,"avg_doc_length":256.0,"token_max_length":40,"include_tokens":false}},"source_key":"#document","bm25":true}}}}}}"""; + + [Test] + public void SparseVectorJson() + { + Assert.That(new ChromaSparseVector([1, 5], [0.5f, 0.7f]).ToString(), Is.EqualTo("""{"#type":"sparse_vector","indices":[1,5],"values":[0.5,0.7]}""")); + Assert.That(new ChromaSparseVector([2], [1f], ["apple"]).ToString(), Is.EqualTo("""{"#type":"sparse_vector","indices":[2],"values":[1.0],"tokens":["apple"]}""")); + Assert.That(new ChromaSparseVector([2147483648, uint.MaxValue], [1f, 2f]).ToString(), Is.EqualTo("""{"#type":"sparse_vector","indices":[2147483648,4294967295],"values":[1.0,2.0]}""")); + } + + // The rules of Chroma and of its Python client: Chroma Cloud rejects indices out of order with 400. + [Test] + public void SparseVectorThatChromaRejects() + { + Assert.That(() => new ChromaSparseVector([1, 2], [1f]), Throws.ArgumentException); + Assert.That(() => new ChromaSparseVector([3, 1], [1f, 2f]), Throws.ArgumentException); + Assert.That(() => new ChromaSparseVector([1, 1], [1f, 2f]), Throws.ArgumentException); + Assert.That(() => new ChromaSparseVector([1], [1f], ["a", "b"]), Throws.ArgumentException); + } + + // The checked values cannot change afterwards, not even through a cast. + [Test] + public void SparseVectorIsReadOnly() + { + var vector = new ChromaSparseVector([1, 5], [0.5f, 0.7f], ["a", "b"]); + Assert.That(vector.Indices, Is.Not.InstanceOf()); + Assert.That(vector.Values, Is.Not.InstanceOf()); + Assert.That(vector.Tokens, Is.Not.InstanceOf()); + } + + [Test] + public void KnnWithASparseVector() + { + Assert.That(ChromaRank.SparseKnn(new ChromaSparseVector([1, 5], [1f, 1f]), "doc_bm25", returnRank: true).ToString(), + Is.EqualTo("""{"$knn":{"query":{"#type":"sparse_vector","indices":[1,5],"values":[1.0,1.0]},"key":"doc_bm25","limit":16,"return_rank":true}}""")); + } + + [Test] + public async Task AddSparseVectorsInMetadata() + { + var server = new FakeServer(r => r.Path switch + { + var path when path.EndsWith("/version") => (HttpStatusCode.OK, "\"1.0.0\""), + var path when path.EndsWith("/add") => (HttpStatusCode.Created, "{}"), + _ => (HttpStatusCode.NotFound, ""), + }); + await CollectionClient(server).AddAsync(new ChromaRecords(["a"]) { Metadatas = [new Dictionary { ["doc_bm25"] = new ChromaSparseVector([1], [0.5f]), ["x"] = 1 }] }); + var add = server.Requests.Single(x => x.Path.EndsWith("/add")); + Assert.That(add.Body.GetProperty("metadatas")[0].GetRawText(), Is.EqualTo("""{"doc_bm25":{"#type":"sparse_vector","indices":[1],"values":[0.5]},"x":1}""")); + } + + // Chroma 0.6.3 accepts sparse vectors in metadata and stores the metadata as null: nothing is sent, also for a sparse vector read + // with the default metadata values, which is a JsonElement. + [TestCase(false)] + [TestCase(true)] + public async Task SparseVectorsInMetadataOnChroma0(bool asJsonElement) + { + var server = new FakeServer(r => r.Path.EndsWith("/version") ? (HttpStatusCode.OK, "\"0.6.3\"") : (HttpStatusCode.OK, "true")); + object vector = asJsonElement + ? JsonDocument.Parse("""{"#type":"sparse_vector","indices":[1],"values":[0.5],"tokens":null}""").RootElement.Clone() + : new ChromaSparseVector([1], [0.5f]); + await Assert.ThatAsync(() => CollectionClient(server).AddAsync(new ChromaRecords(["a"]) { Embeddings = [new([1f])], Metadatas = [new Dictionary { ["v"] = vector }] }), + Throws.InstanceOf().With.Message.Contains("sparse vectors")); + Assert.That(server.Requests.Any(x => x.Path.EndsWith("/add")), Is.False); + } + + // Chroma Cloud sends "#type" and "tokens": null. Exact metadata values give a ChromaSparseVector, the default ones a JsonElement, as before. + [TestCase(ChromaMetadataValues.Exact)] + [TestCase(ChromaMetadataValues.Inferred)] + public async Task ReadSparseVectorsInMetadata(ChromaMetadataValues metadataValues) + { + var server = new FakeServer(_ => (HttpStatusCode.OK, """{"ids":["a"],"metadatas":[{"doc_bm25":{"#type":"sparse_vector","indices":[1,4294967295],"values":[0.5,0.7],"tokens":null},"x":1}]}""")); + var options = new ChromaConfigurationOptions("http://localhost:8000").WithMetadataValues(metadataValues); + var value = (await CollectionClient(server, options).GetAsync("a", include: ChromaGetInclude.Metadatas))!.Metadata!["doc_bm25"]; + if (metadataValues == ChromaMetadataValues.Exact) + { + var vector = (ChromaSparseVector)value; + Assert.That((vector.Indices, vector.Values, vector.Tokens), Is.EqualTo(((IReadOnlyList)[1, uint.MaxValue], (IReadOnlyList)[0.5f, 0.7f], (IReadOnlyList?)null))); + } + else + { + Assert.That(((JsonElement)value).GetProperty("#type").GetString(), Is.EqualTo("sparse_vector")); + } + } + + [Test] + public async Task CreateCollectionWithASchema() + { + var server = new FakeServer(_ => (HttpStatusCode.OK, $$$"""{"id":"11111111-2222-3333-4444-555555555555","name":"c","schema":{{{Bm25Schema}}}}""")); + var definition = new ChromaCollectionDefinition("c") { Schema = new ChromaCollectionSchema().WithSparseVectorIndex("doc_bm25", ChromaSearchKeys.Document, bm25: true, ChromaEmbeddingFunctionReference.ChromaBm25()) }; + var collection = await Client(server).CreateCollectionAsync(definition); + Assert.That(server.Requests.Single().Body.GetProperty("schema").GetRawText(), Is.EqualTo(Bm25Schema)); + var index = collection.SparseVectorIndexes.Single(); + Assert.That((index.Key, index.SourceKey, index.Bm25, index.EmbeddingFunction), Is.EqualTo(("doc_bm25", "#document", true, "chroma_bm25"))); + Assert.That(definition.Schema.ToString(), Is.EqualTo(Bm25Schema)); + } + + // With a schema the space goes in it, as create_index(VectorIndexConfig(space=...)) of the Python client writes it: Chroma Cloud + // rejects the hnsw:space metadata together with a schema, "Cannot set both collection config and schema simultaneously". + [TestCase("create")] + [TestCase("get_or_create")] + public async Task SpaceInTheSchema(string operation) + { + var server = new FakeServer(r => r.Path.EndsWith("/version") ? (HttpStatusCode.OK, "\"1.0.0\"") + : (HttpStatusCode.OK, $$$"""{"id":"11111111-2222-3333-4444-555555555555","name":"c","configuration_json":{"hnsw":null,"spann":{"space":"cosine"}},"schema":{{{Bm25Schema}}}}""")); + var definition = new ChromaCollectionDefinition("c") + { + Metadata = new Dictionary { ["x"] = 1 }, + Configuration = new() { Space = ChromaSpace.Cosine }, + Schema = new ChromaCollectionSchema().WithSparseVectorIndex("doc_bm25", ChromaSearchKeys.Document, bm25: true, ChromaEmbeddingFunctionReference.ChromaBm25()), + }; + var collection = operation == "create" ? await Client(server).CreateCollectionAsync(definition) : await Client(server).GetOrCreateCollectionAsync(definition); + Assert.That(collection.Space, Is.EqualTo(ChromaSpace.Cosine)); + var body = server.Requests.Single(x => x.Method == "POST").Body; + Assert.That(body.GetProperty("metadata").GetRawText(), Is.EqualTo("""{"x":1}""")); + Assert.That(body.GetProperty("schema").GetRawText(), Is.EqualTo(""" + {"defaults":{"float_list":{"vector_index":{"enabled":false,"config":{"space":"cosine"}}}}, + "keys":{"doc_bm25":{"sparse_vector":{"sparse_vector_index":{"enabled":true,"config":{"embedding_function":{"type":"known","name":"chroma_bm25","config":{"k":1.2,"b":0.75,"avg_doc_length":256.0,"token_max_length":40,"include_tokens":false}},"source_key":"#document","bm25":true}}}}, + "#embedding":{"float_list":{"vector_index":{"enabled":true,"config":{"space":"cosine"}}}}}} + """.Replace("\r", "").Replace("\n", "").Replace("\t", ""))); + } + + // Chroma 1.3.0 creates the collection with the space of a schema that turns no index on or off ignored, l2: CreateCollection deletes + // it and throws, GetOrCreateCollection throws and keeps it, since it may have existed before. + [TestCase("create", true)] + [TestCase("get_or_create", false)] + public async Task SpaceIgnoredByTheServer(string operation, bool deleted) + { + var server = new FakeServer(r => r.Method == "DELETE" ? (HttpStatusCode.OK, "{}") + : r.Path.EndsWith("/version") ? (HttpStatusCode.OK, "\"1.0.0\"") + : (HttpStatusCode.OK, """{"id":"11111111-2222-3333-4444-555555555555","name":"c","configuration_json":{"hnsw":{"space":"l2"}},"schema":{"defaults":{},"keys":{}}}""")); + var definition = new ChromaCollectionDefinition("c") { Configuration = new() { Space = ChromaSpace.Cosine }, Schema = new ChromaCollectionSchema() }; + await Assert.ThatAsync(() => operation == "create" ? Client(server).CreateCollectionAsync(definition) : Client(server).GetOrCreateCollectionAsync(definition), + Throws.InstanceOf().With.Message.Contains("space l2, not cosine").And.Message.Contains("Chroma 1.3.0 ignores the space of a schema that turns no index on or off")); + Assert.That(server.Requests.Any(x => x.Method == "DELETE"), Is.EqualTo(deleted)); + } + + // Without a schema the request stays as before. + [Test] + public async Task CreateCollectionWithoutASchema() + { + var server = new FakeServer(_ => (HttpStatusCode.OK, """{"id":"11111111-2222-3333-4444-555555555555","name":"c"}""")); + var collection = await Client(server).CreateCollectionAsync("c"); + Assert.That(server.Requests.Single().Body.TryGetProperty("schema", out _), Is.False); + Assert.That(collection.SparseVectorIndexes, Is.Empty); + } + + // Chroma 1.0.0 to 1.2.2 create the collection without the schema: CreateCollection deletes it, GetOrCreateCollection keeps it. + [TestCase(false)] + [TestCase(true)] + public async Task SchemaThatTheServerIgnores(bool getOrCreate) + { + var server = new FakeServer(r => r.Method == "DELETE" ? (HttpStatusCode.OK, "{}") : (HttpStatusCode.OK, """{"id":"11111111-2222-3333-4444-555555555555","name":"c","schema":null}""")); + var definition = new ChromaCollectionDefinition("c") { Schema = new ChromaCollectionSchema().WithSparseVectorIndex("v") }; + var client = Client(server); + await Assert.ThatAsync(() => getOrCreate ? client.GetOrCreateCollectionAsync(definition) : client.CreateCollectionAsync(definition), + Throws.InstanceOf().With.Message.Contains("Chroma 1.3.0")); + Assert.That(server.Requests.Select(x => x.Line), Is.EqualTo(getOrCreate + ? new[] { $"POST {CollectionsPath}" } + : new[] { $"POST {CollectionsPath}", $"DELETE {CollectionsPath}/c" })); + } + + // Chroma Cloud answers 400 "If source_key is provided then embedding_function must also be provided". + [Test] + public void SourceKeyWithoutEmbeddingFunction() + { + Assert.That(() => new ChromaCollectionSchema().WithSparseVectorIndex("doc_bm25", ChromaSearchKeys.Document), Throws.ArgumentException); + Assert.That(() => new ChromaCollectionSchema().WithSparseVectorIndex("doc_bm25", bm25: true), Throws.Nothing); + } + + // The call is canceled once the answer to the creation has arrived: the collection is deleted all the same. + [Test] + public async Task SchemaThatTheServerIgnoresWhenTheCallIsCanceledAfterTheAnswer() + { + using var cancellation = new CancellationTokenSource(); + var requests = new List(); + var handler = new CancelAfterAnswer(cancellation, requests); + var definition = new ChromaCollectionDefinition("c") { Schema = new ChromaCollectionSchema().WithSparseVectorIndex("v") }; + var client = new ChromaClient(new ChromaConfigurationOptions("http://localhost:8000"), new HttpClient(handler)); + await Assert.ThatAsync(() => client.CreateCollectionAsync(definition, cancellationToken: cancellation.Token), Throws.InstanceOf()); + Assert.That(requests, Is.EqualTo(new[] { "POST", "DELETE" })); + } + + // Answers the creation with a collection without a schema, and cancels the token when the body of that answer has been read. + sealed class CancelAfterAnswer(CancellationTokenSource cancellation, List requests) : HttpMessageHandler + { + protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + requests.Add(request.Method.Method); + HttpContent content = request.Method == HttpMethod.Post + ? new CancelWhenRead("""{"id":"11111111-2222-3333-4444-555555555555","name":"c","schema":null}""", cancellation) + : new StringContent("{}"); + return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK) { Content = content }); + } + } + + sealed class CancelWhenRead(string body, CancellationTokenSource cancellation) : HttpContent + { + protected override async Task SerializeToStreamAsync(Stream stream, System.Net.TransportContext? context) + { + await stream.WriteAsync(System.Text.Encoding.UTF8.GetBytes(body)); + cancellation.Cancel(); + } + + protected override bool TryComputeLength(out long length) + { + length = -1; + return false; + } + } + + // The reference keeps a copy of the settings: changing them afterwards does not change the schema. + [Test] + public async Task EmbeddingFunctionSettingsAreCopied() + { + var config = new Dictionary { ["k"] = 1 }; + var schema = new ChromaCollectionSchema().WithSparseVectorIndex("v", embeddingFunction: ChromaEmbeddingFunctionReference.Known("f", config)); + config["k"] = 2; + var server = new FakeServer(_ => (HttpStatusCode.OK, """{"id":"11111111-2222-3333-4444-555555555555","name":"c","schema":{"defaults":{},"keys":{}}}""")); + await Client(server).CreateCollectionAsync(new ChromaCollectionDefinition("c") { Schema = schema }); + Assert.That(server.Requests.Single().Body.GetProperty("schema").GetProperty("keys").GetProperty("v").GetRawText(), Does.Contain("""{"k":1}""")); + } + + // The settings of chroma_bm25 in the schema, as build_from_config of the Python client reads them: the missing ones get the default values. + [Test] + public void Bm25FunctionFromTheSchema() + { + var index = Index("""{"type":"known","name":"chroma_bm25","config":{"k":1.5,"b":0.6,"avg_doc_length":100,"token_max_length":10,"include_tokens":true,"stopwords":["quick","fox"]}}"""); + var function = index.Bm25Function!; + Assert.That((function.K, function.B, function.AvgDocLength, function.TokenMaxLength, function.IncludeTokens, function.Stopwords), Is.EqualTo((1.5, 0.6, 100.0, 10, true, (IReadOnlyList?)["quick", "fox"]))); + Assert.That(index.EmbeddingFunctionConfig!.Value.GetProperty("k").GetDouble(), Is.EqualTo(1.5)); + var text = "The quick brown fox jumps over the lazy dog. Foxes!"; + Assert.That(function.Embed(text).ToString(), Is.EqualTo(new ChromaBm25(1.5, 0.6, 100, 10, ["quick", "fox"], true).Embed(text).ToString())); + + var defaults = Index("""{"type":"known","name":"chroma_bm25","config":{"stopwords":null,"include_tokens":null}}""").Bm25Function!; + Assert.That((defaults.K, defaults.B, defaults.AvgDocLength, defaults.TokenMaxLength, defaults.IncludeTokens, defaults.Stopwords), Is.EqualTo((1.2, 0.75, 256.0, 40, false, (IReadOnlyList?)null))); + Assert.That(Index("""{"type":"known","name":"chroma_bm25","config":{"token_max_length":12.7}}""").Bm25Function!.TokenMaxLength, Is.EqualTo(12)); + } + + // The index whose vectors come from BM25 on the text of the key; not one with another function, nor one without a source key. + [Test] + public void FindBm25Index() + { + const string Bm25 = """{"type":"known","name":"chroma_bm25","config":{}}"""; + const string Splade = """{"type":"known","name":"splade","config":{}}"""; + static string Key(string name, string function, string? source) + => "\"" + name + "\":" + """{"sparse_vector":{"sparse_vector_index":{"enabled":true,"config":{"embedding_function":""" + function + + (source is null ? "" : ",\"source_key\":\"" + source + "\"") + "}}}}"; + var keys = string.Join(",", Key("a", Bm25, "#document"), Key("b", Splade, "title"), Key("c", Bm25, "title"), Key("d", Bm25, null)); + var collection = new ChromaCollection("c") { SchemaJson = JsonDocument.Parse("""{"defaults":{},"keys":{""" + keys + "}}").RootElement }; + Assert.That(collection.FindBm25Index("#document")?.Key, Is.EqualTo("a")); + Assert.That(collection.FindBm25Index("title")?.Key, Is.EqualTo("c")); + Assert.That(collection.FindBm25Index("body"), Is.Null); + Assert.That(new ChromaCollection("c").FindBm25Index("#document"), Is.Null); + } + + // Where the Python client keeps no function: another function, no config, or a setting of the wrong type. + [TestCase("""{"type":"known","name":"splade","config":{}}""")] + [TestCase("""{"type":"known","name":"chroma_bm25"}""")] + [TestCase("""{"type":"known","name":"chroma_bm25","config":null}""")] + [TestCase("""{"type":"known","name":"chroma_bm25","config":{"k":"1.2"}}""")] + [TestCase("""{"type":"known","name":"chroma_bm25","config":{"b":null}}""")] + [TestCase("""{"type":"known","name":"chroma_bm25","config":{"token_max_length":1e10}}""")] + [TestCase("""{"type":"known","name":"chroma_bm25","config":{"include_tokens":1}}""")] + [TestCase("""{"type":"known","name":"chroma_bm25","config":{"stopwords":"the"}}""")] + [TestCase("""{"type":"known","name":"chroma_bm25","config":{"stopwords":["the",1]}}""")] + public void NoBm25Function(string function) + { + Assert.That(Index(function).Bm25Function, Is.Null); + } + + // As the Python client: the vectors of the indexes with a source key, for the records whose metadata does not have them. + [TestCase("add")] + [TestCase("upsert")] + [TestCase("update")] + public async Task SparseVectorsComputedOnWrite(string operation) + { + var server = new FakeServer(r => r.Path.EndsWith("/version") ? (HttpStatusCode.OK, "\"1.5.9\"") : (HttpStatusCode.OK, "true")); + var given = new ChromaSparseVector([1], [0.5f]); + var metadatas = new List> + { + new() { ["title"] = "Red apples" }, + new() { ["doc_bm25"] = given }, + null!, + new() { ["title"] = 5 }, + }; + var records = new ChromaRecords(["a", "b", "c", "d"]) { Documents = ["apple pie", "banana split", null!, "cherry tart"], Metadatas = metadatas }; + var client = CollectionClient(server, TwoSourcesSchema); + await (operation switch { "add" => client.AddAsync(records), "upsert" => client.UpsertAsync(records), _ => client.UpdateAsync(records) }); + + var sent = server.Requests.Single(x => x.Path.EndsWith("/" + operation)).Body.GetProperty("metadatas"); + var bm25 = new ChromaBm25(); + Assert.That(sent.GetRawText(), Is.EqualTo($$""" + [{"title":"Red apples","doc_bm25":{{bm25.Embed("apple pie")}},"title_bm25":{{new ChromaBm25(k: 1.5).Embed("Red apples")}}}, + {"doc_bm25":{{given}}}, + null, + {"title":5,"doc_bm25":{{bm25.Embed("cherry tart")}}}] + """.Replace("\r", "").Replace("\n", "").Replace("\t", ""))); + Assert.That(metadatas[0].Keys, Is.EqualTo(new[] { "title" })); + Assert.That(records.Metadatas, Is.SameAs(metadatas)); + } + + // Without metadata, a record with a document gets one with its vector. + [Test] + public async Task SparseVectorsWithoutMetadata() + { + var server = new FakeServer(r => r.Path.EndsWith("/version") ? (HttpStatusCode.OK, "\"1.5.9\"") : (HttpStatusCode.OK, "true")); + await CollectionClient(server, TwoSourcesSchema).AddAsync(new ChromaRecords(["a", "b"]) { Documents = ["apple pie", null!] }); + Assert.That(server.Requests.Single(x => x.Path.EndsWith("/add")).Body.GetProperty("metadatas").GetRawText(), + Is.EqualTo("""[{"doc_bm25":""" + new ChromaBm25().Embed("apple pie") + "},null]")); + } + + // Without a schema, or without a source key in it, the records go as they are. + [TestCase(null)] + [TestCase("""{"defaults":{},"keys":{"doc_bm25":{"sparse_vector":{"sparse_vector_index":{"enabled":true,"config":{"bm25":true}}}}}}""")] + public async Task NoSparseVectorsToCompute(string? schema) + { + var server = new FakeServer(_ => (HttpStatusCode.OK, "true")); + await CollectionClient(server, schema).AddAsync(new ChromaRecords(["a"]) { Documents = ["apple pie"] }); + Assert.That(server.Requests.Single(x => x.Path.EndsWith("/add")).Body.TryGetProperty("metadatas", out var metadatas) ? metadatas.ValueKind : JsonValueKind.Undefined, Is.AnyOf(JsonValueKind.Null, JsonValueKind.Undefined)); + } + + // Another function: the client cannot compute its vectors, so it throws before sending, unless the metadata has them. + [Test] + public async Task SparseVectorsOfAnotherFunction() + { + var server = new FakeServer(r => r.Path.EndsWith("/version") ? (HttpStatusCode.OK, "\"1.5.9\"") : (HttpStatusCode.OK, "true")); + var client = CollectionClient(server, """{"defaults":{},"keys":{"doc_splade":{"sparse_vector":{"sparse_vector_index":{"enabled":true,"config":{"embedding_function":{"type":"known","name":"splade","config":{}},"source_key":"#document"}}}}}}"""); + await Assert.ThatAsync(() => client.AddAsync(new ChromaRecords(["a"]) { Documents = ["apple pie"] }), + Throws.InstanceOf().With.Message.Contains("\"doc_splade\"").And.Message.Contains("\"splade\"")); + Assert.That(server.Requests, Is.Empty); + await client.AddAsync(new ChromaRecords(["a"]) { Documents = ["apple pie"], Metadatas = [new Dictionary { ["doc_splade"] = new ChromaSparseVector([1], [0.5f]) }] }); + Assert.That(server.Requests.Count(x => x.Path.EndsWith("/add")), Is.EqualTo(1)); + } + + // A text query of SparseKnn becomes the vector of the function of the index, also inside other expressions. + [Test] + public async Task SearchWithAText() + { + var server = new FakeServer(_ => (HttpStatusCode.OK, """{"ids":[[]],"documents":[null],"embeddings":[null],"metadatas":[null],"scores":[null],"select":[[]]}""")); + var rank = ChromaRank.Rrf([ChromaRank.Knn(new([1f, 0f]), returnRank: true), ChromaRank.SparseKnn("Red apples", "title_bm25", returnRank: true)]); + Assert.That(rank.ToString(), Does.Contain("""{"$knn":{"query":"Red apples","key":"title_bm25","limit":16,"return_rank":true}}""")); + await CollectionClient(server, TwoSourcesSchema).SearchAsync(new ChromaSearch { Rank = rank }); + Assert.That(server.Requests.Single().Body.GetProperty("searches")[0].GetProperty("rank").GetRawText(), + Does.Contain($$$"""{"$knn":{"query":{{{new ChromaBm25(k: 1.5).Embed("Red apples")}}},"key":"title_bm25","limit":16,"return_rank":true}}""")); + } + + // As the Python client: a text query needs a key with an index and a function the client knows. + [TestCase(null, "doc_bm25")] + [TestCase(TwoSourcesSchema, "other")] + [TestCase("""{"defaults":{},"keys":{"doc_splade":{"sparse_vector":{"sparse_vector_index":{"enabled":true,"config":{"embedding_function":{"type":"known","name":"splade","config":{}},"source_key":"#document"}}}}}}""", "doc_splade")] + public async Task SearchWithATextItCannotEmbed(string? schema, string key) + { + var server = new FakeServer(_ => (HttpStatusCode.OK, "{}")); + await Assert.ThatAsync(() => CollectionClient(server, schema).SearchAsync(new ChromaSearch { Rank = ChromaRank.SparseKnn("apple", key) }), + Throws.InstanceOf().With.Message.Contains($"\"{key}\"")); + Assert.That(server.Requests, Is.Empty); + } + + // doc_bm25 from the documents with the default settings, title_bm25 from the title in the metadata with k 1.5, and an index without a source. + const string TwoSourcesSchema = """ + {"defaults":{},"keys":{ + "doc_bm25":{"sparse_vector":{"sparse_vector_index":{"enabled":true,"config":{"embedding_function":{"type":"known","name":"chroma_bm25","config":{"k":1.2,"b":0.75,"avg_doc_length":256.0,"token_max_length":40,"include_tokens":false}},"source_key":"#document","bm25":true}}}}, + "title_bm25":{"sparse_vector":{"sparse_vector_index":{"enabled":true,"config":{"embedding_function":{"type":"known","name":"chroma_bm25","config":{"k":1.5}},"source_key":"title","bm25":true}}}}, + "v":{"sparse_vector":{"sparse_vector_index":{"enabled":true,"config":{"bm25":false}}}}}} + """; + + static ChromaSparseVectorIndex Index(string function) + => new ChromaCollection("c") { SchemaJson = JsonDocument.Parse("""{"defaults":{},"keys":{"v":{"sparse_vector":{"sparse_vector_index":{"enabled":true,"config":{"embedding_function":""" + function + ""","source_key":"#document"}}}}}}""").RootElement } + .SparseVectorIndexes.Single(); + + static ChromaCollectionClient CollectionClient(HttpMessageHandler handler, string? schema) + => new(new ChromaCollection("c") { Id = Guid.Parse("11111111-2222-3333-4444-555555555555"), SchemaJson = schema is null ? null : JsonDocument.Parse(schema).RootElement }, + new ChromaConfigurationOptions("http://localhost:8000"), new HttpClient(handler)); + + static ChromaClient Client(HttpMessageHandler handler) + => new(new ChromaConfigurationOptions("http://localhost:8000"), new HttpClient(handler)); + + static ChromaCollectionClient CollectionClient(HttpMessageHandler handler, ChromaConfigurationOptions? options = null) + => new(new ChromaCollection("c") { Id = Guid.Parse("11111111-2222-3333-4444-555555555555") }, options ?? new ChromaConfigurationOptions("http://localhost:8000"), new HttpClient(handler)); + + sealed record Request(string Method, string Path, string Line, JsonElement Body); + + sealed class FakeServer(Func answer) : HttpMessageHandler + { + public List Requests { get; } = []; + + protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + var text = request.Content is null ? null : await request.Content.ReadAsStringAsync(cancellationToken); + var recorded = new Request(request.Method.Method, request.RequestUri!.AbsolutePath, $"{request.Method.Method} {request.RequestUri.PathAndQuery}", + text is { Length: > 0 } ? JsonDocument.Parse(text).RootElement.Clone() : default); + var (status, response) = answer(recorded); + // The version, which the client also asks for a collection without a space: 1.0.0 unless the answer is a version, and + // then not recorded, as the tests that do not answer it do not look for it. + if (recorded.Path.EndsWith("/version") && !response.StartsWith("\"")) + { + (status, response) = (HttpStatusCode.OK, "\"1.0.0\""); + } + else + { + Requests.Add(recorded); + } + return new HttpResponseMessage(status) { Content = new StringContent(response) }; + } + } +} diff --git a/ChromaDB.Client.Tests/TelemetryTests.cs b/ChromaDB.Client.Tests/TelemetryTests.cs new file mode 100644 index 0000000..cb5dc9f --- /dev/null +++ b/ChromaDB.Client.Tests/TelemetryTests.cs @@ -0,0 +1,224 @@ +using System.Diagnostics; +using System.Diagnostics.Metrics; +using System.Net; +using ChromaDB.Client.Models; +using NUnit.Framework; +using OpenTelemetry.Metrics; + +namespace ChromaDB.Client.Tests; + +// The spans and the durations of the operations, with the attributes of the OpenTelemetry semantic conventions for database clients, +// against a fake server. Each test uses a port of its own, so that it records only its own operations. +[TestFixture] +public class TelemetryTests +{ + [Test] + public async Task SpanAndDurationOfAnOperation() + { + var port = Port(); + using var recorder = new Recorder(port); + var server = new FakeServer(_ => (HttpStatusCode.OK, "3")); + Assert.That(await CollectionClient(server, port, "articles").CountAsync(), Is.EqualTo(3)); + + var span = recorder.Spans.Single(); + Assert.That((span.DisplayName, span.Kind, span.Status), Is.EqualTo(("count articles", ActivityKind.Client, ActivityStatusCode.Unset))); + var expected = new Dictionary + { + ["db.system.name"] = "chroma", + ["db.operation.name"] = "count", + ["db.collection.name"] = "articles", + ["db.namespace"] = "default_tenant|default_database", + ["server.address"] = "localhost", + ["server.port"] = port, + }; + Assert.That(span.TagObjects.ToDictionary(x => x.Key, x => x.Value), Is.EquivalentTo(expected)); + var duration = recorder.Durations.Single(); + Assert.That(duration.Tags, Is.EquivalentTo(expected)); + Assert.That(duration.Value, Is.GreaterThanOrEqualTo(0)); + } + + // An error of the server: the HTTP status is error.type and db.response.status_code. + [Test] + public async Task ErrorOfTheServer() + { + var port = Port(); + using var recorder = new Recorder(port); + var server = new FakeServer(_ => (HttpStatusCode.NotFound, """{"error":"NotFoundError","message":"Collection [articles] does not exist"}""")); + await Assert.ThatAsync(() => CollectionClient(server, port, "articles").CountAsync(), Throws.InstanceOf()); + + var span = recorder.Spans.Single(); + Assert.That(span.Status, Is.EqualTo(ActivityStatusCode.Error)); + Assert.That((span.GetTagItem("error.type"), span.GetTagItem("db.response.status_code")), Is.EqualTo(("404", "404"))); + var tags = recorder.Durations.Single().Tags; + Assert.That((tags["error.type"], tags["db.response.status_code"]), Is.EqualTo(("404", "404"))); + } + + // Without an answer, error.type is the exception the client wraps. + [Test] + public async Task ErrorWithoutAnAnswer() + { + var port = Port(); + using var recorder = new Recorder(port); + var server = new FakeServer(_ => throw new HttpRequestException("Connection refused")); + await Assert.ThatAsync(() => CollectionClient(server, port, "articles").CountAsync(), Throws.InstanceOf()); + + var span = recorder.Spans.Single(); + Assert.That(span.Status, Is.EqualTo(ActivityStatusCode.Error)); + Assert.That((span.GetTagItem("error.type"), span.GetTagItem("db.response.status_code")), Is.EqualTo(("System.Net.Http.HttpRequestException", (object?)null))); + } + + // A missing collection is the answer of CollectionExists, not an error, and the request has no span of its own. + [Test] + public async Task CollectionExistsWithoutError() + { + var port = Port(); + using var recorder = new Recorder(port); + var server = new FakeServer(_ => (HttpStatusCode.NotFound, """{"error":"NotFoundError","message":"Collection [articles] does not exist"}""")); + Assert.That(await Client(server, port).CollectionExistsAsync("articles"), Is.False); + + var span = recorder.Spans.Single(); + Assert.That((span.DisplayName, span.Status, span.GetTagItem("error.type")), Is.EqualTo(("collection_exists articles", ActivityStatusCode.Unset, (object?)null))); + } + + // The operations on the server have no namespace, the ones on a tenant the tenant, the ones in a database tenant|database. The + // records that a delete of a collection deletes first make no spans of their own. + [Test] + public async Task NamespaceOfEachOperation() + { + var port = Port(); + using var recorder = new Recorder(port); + var server = new FakeServer(r => r.EndsWith("/heartbeat") ? (HttpStatusCode.OK, """{"nanosecond heartbeat":1}""") + : r.EndsWith("/databases") ? (HttpStatusCode.OK, "[]") + : r.EndsWith("/version") ? (HttpStatusCode.OK, "\"1.0.0\"") + : r.EndsWith("/collections/articles") ? (HttpStatusCode.OK, """{"id":"11111111-2222-3333-4444-555555555555","name":"articles"}""") + : r.EndsWith("/get") ? (HttpStatusCode.OK, """{"ids":[]}""") + : (HttpStatusCode.OK, "{}")); + var client = Client(server, port); + await client.HeartbeatAsync(); + await client.ListDatabasesAsync("tenant1"); + await client.CreateDatabaseAsync("database1", "tenant1"); + await client.DeleteCollectionAsync("articles"); + + Assert.That(recorder.Spans.Select(x => (x.DisplayName, x.GetTagItem("db.namespace"))), Is.EqualTo(new (string, object?)[] + { + ("heartbeat", null), + ("list_databases", "tenant1"), + ("create_database", "tenant1|database1"), + ("delete_collection articles", "default_tenant|default_database"), + })); + } + + // With the OpenTelemetry SDK, as an application exports it: the bucket boundaries that the semantic conventions advise, in seconds, + // and not the default ones of OpenTelemetry, made for milliseconds, where every operation under 5 s falls in the same bucket. + [Test] + public async Task BucketsOfTheDuration() + { + var port = Port(); + var metrics = new List(); + var builder = OpenTelemetry.Sdk.CreateMeterProviderBuilder().AddMeter(ChromaTelemetry.MeterName).AddInMemoryExporter(metrics); +#if CHROMA_CLIENT_NETSTANDARD2_0 + // The netstandard2.0 build cannot advise them: the view of the README sets them. + builder.AddView("db.client.operation.duration", new ExplicitBucketHistogramConfiguration { Boundaries = [0.001, 0.005, 0.01, 0.05, 0.1, 0.5, 1, 5, 10] }); +#endif + using var provider = builder.Build(); + await CollectionClient(new FakeServer(_ => (HttpStatusCode.OK, "3")), port, "articles").CountAsync(); + provider.ForceFlush(); + + var buckets = new List<(double Bound, long Count)>(); + foreach (var point in metrics.Single(x => x.Name == "db.client.operation.duration").GetMetricPoints()) + { + var ours = false; + foreach (var tag in point.Tags) + { + ours |= tag.Key == "server.port" && Equals(tag.Value, port); + } + if (ours) + { + foreach (var bucket in point.GetHistogramBuckets()) + { + buckets.Add((bucket.ExplicitBound, bucket.BucketCount)); + } + } + } + Assert.That(buckets.Select(x => x.Bound), Is.EqualTo(new[] { 0.001, 0.005, 0.01, 0.05, 0.1, 0.5, 1, 5, 10, double.PositiveInfinity })); + Assert.That(buckets.Where(x => x.Bound <= 1).Sum(x => x.Count), Is.EqualTo(1)); + } + + static int Port() => Random.Shared.Next(20000, 60000); + + static ChromaClient Client(HttpMessageHandler handler, int port) + => new(new ChromaConfigurationOptions($"http://localhost:{port}"), new HttpClient(handler)); + + static ChromaCollectionClient CollectionClient(HttpMessageHandler handler, int port, string name) + => new(new ChromaCollection(name) { Id = Guid.Parse("11111111-2222-3333-4444-555555555555") }, new ChromaConfigurationOptions($"http://localhost:{port}"), new HttpClient(handler)); + + sealed class Recorder : IDisposable + { + private readonly ActivityListener _activities; + private readonly MeterListener _meters; + + public List Spans { get; } = []; + public List<(double Value, Dictionary Tags)> Durations { get; } = []; + + public Recorder(int port) + { + _activities = new ActivityListener + { + ShouldListenTo = source => source.Name == ChromaTelemetry.ActivitySourceName, + Sample = (ref ActivityCreationOptions _) => ActivitySamplingResult.AllDataAndRecorded, + ActivityStopped = activity => + { + if (Equals(activity.GetTagItem("server.port"), port)) + { + lock (Spans) + { + Spans.Add(activity); + } + } + }, + }; + ActivitySource.AddActivityListener(_activities); + _meters = new MeterListener + { + InstrumentPublished = (instrument, listener) => + { + if (instrument.Meter.Name == ChromaTelemetry.MeterName) + { + listener.EnableMeasurementEvents(instrument); + } + }, + }; + _meters.SetMeasurementEventCallback((instrument, value, tags, _) => + { + var all = new Dictionary(); + foreach (var tag in tags) + { + all[tag.Key] = tag.Value; + } + if (instrument.Name == "db.client.operation.duration" && Equals(all["server.port"], port)) + { + lock (Durations) + { + Durations.Add((value, all)); + } + } + }); + _meters.Start(); + } + + public void Dispose() + { + _activities.Dispose(); + _meters.Dispose(); + } + } + + sealed class FakeServer(Func answer) : HttpMessageHandler + { + protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + var (status, body) = answer(request.RequestUri!.AbsolutePath); + return Task.FromResult(new HttpResponseMessage(status) { Content = new StringContent(body) }); + } + } +} diff --git a/ChromaDB.Client.Tests/TenantDatabaseTests.cs b/ChromaDB.Client.Tests/TenantDatabaseTests.cs new file mode 100644 index 0000000..0c12b32 --- /dev/null +++ b/ChromaDB.Client.Tests/TenantDatabaseTests.cs @@ -0,0 +1,206 @@ +using NUnit.Framework; + +namespace ChromaDB.Client.Tests; + +[TestFixture] +public class TenantDatabaseTests : ChromaTestsBase +{ + [SetUp] + public void SetUp() + => Assume.That(TenantsSupported, Is.True, "Chroma 0.4.14 and earlier have no tenants and databases."); + + [Test] + public async Task CreateTenant() + { + Assume.That(OtherTenantsAndDatabasesTested, Is.True, "A server already running may not let the tests create or look up other tenants and databases."); + var name = $"tenant{Random.Shared.Next()}"; + + var client = new ChromaClient(BaseConfigurationOptions, HttpClient); + await client.CreateTenantAsync(name); + var result = await client.GetTenantAsync(name); + Assert.That(result.Name, Is.EqualTo(name)); + } + + [Test] + public async Task CreateTenantAlreadyExists() + { + Assume.That(OtherTenantsAndDatabasesTested, Is.True, "A server already running may not let the tests create or look up other tenants and databases."); + var name = $"tenant{Random.Shared.Next()}"; + + var client = new ChromaClient(BaseConfigurationOptions, HttpClient); + await client.CreateTenantAsync(name); + await Assert.ThatAsync(() => client.CreateTenantAsync(name), Throws.InstanceOf().With.Message.Contains("already exists")); + } + + [Test] + public async Task GetTenantNotExists() + { + Assume.That(OtherTenantsAndDatabasesTested, Is.True, "A server already running may not let the tests create or look up other tenants and databases."); + var client = new ChromaClient(BaseConfigurationOptions, HttpClient); + await Assert.ThatAsync(() => client.GetTenantAsync($"tenant{Random.Shared.Next()}"), Throws.InstanceOf().With.Message.Contains("not found")); + } + + [Test] + public async Task CreateDatabase() + { + Assume.That(OtherTenantsAndDatabasesTested, Is.True, "A server already running may not let the tests create or look up other tenants and databases."); + var tenant = $"tenant{Random.Shared.Next()}"; + var name = $"database{Random.Shared.Next()}"; + + var client = new ChromaClient(BaseConfigurationOptions, HttpClient); + await client.CreateTenantAsync(tenant); + await client.CreateDatabaseAsync(name, tenant: tenant); + var result = await client.GetDatabaseAsync(name, tenant: tenant); + Assert.That(result.Name, Is.EqualTo(name)); + Assert.That(result.Tenant, Is.EqualTo(tenant)); + } + + [Test] + public async Task CreateDatabaseInTenantOfOptions() + { + Assume.That(OtherTenantsAndDatabasesTested, Is.True, "A server already running may not let the tests create or look up other tenants and databases."); + var name = $"database{Random.Shared.Next()}"; + + var client = new ChromaClient(BaseConfigurationOptions, HttpClient); + await client.CreateDatabaseAsync(name); + var result = await client.GetDatabaseAsync(name); + Assert.That(result.Name, Is.EqualTo(name)); + Assert.That(result.Tenant, Is.EqualTo(BaseConfigurationOptions.Tenant ?? "default_tenant")); + } + + [Test] + public async Task CreateDatabaseAlreadyExists() + { + Assume.That(OtherTenantsAndDatabasesTested, Is.True, "A server already running may not let the tests create or look up other tenants and databases."); + var name = $"database{Random.Shared.Next()}"; + + var client = new ChromaClient(BaseConfigurationOptions, HttpClient); + await client.CreateDatabaseAsync(name); + await Assert.ThatAsync(() => client.CreateDatabaseAsync(name), Throws.InstanceOf().With.Message.Contains("already exists")); + } + + [Test] + public async Task GetDatabaseNotExists() + { + Assume.That(OtherTenantsAndDatabasesTested, Is.True, "A server already running may not let the tests create or look up other tenants and databases."); + var client = new ChromaClient(BaseConfigurationOptions, HttpClient); + await Assert.ThatAsync(() => client.GetDatabaseAsync($"database{Random.Shared.Next()}"), Throws.InstanceOf().With.Message.Contains("not found")); + } + + [Test] + public async Task ListDatabases() + { + Assume.That(OtherTenantsAndDatabasesTested, Is.True, "A server already running may not let the tests create or look up other tenants and databases."); + Assume.That(DatabaseListingSupported, Is.True, "Only the v2 API of Chroma 0.6.3 and later lists databases."); + var tenant = $"tenant{Random.Shared.Next()}"; + + var client = new ChromaClient(BaseConfigurationOptions, HttpClient); + await client.CreateTenantAsync(tenant); + foreach (var name in new[] { "db_b", "db_a", "db_c" }) + { + await client.CreateDatabaseAsync(name, tenant: tenant); + } + var result = await client.ListDatabasesAsync(tenant: tenant); + Assert.That(result.Select(x => x.Name), Is.EqualTo(new[] { "db_a", "db_b", "db_c" })); + Assert.That(result.Select(x => x.Tenant), Is.All.EqualTo(tenant)); + } + + [Test] + public async Task ListDatabasesInTenantOfOptions() + { + Assume.That(OtherTenantsAndDatabasesTested, Is.True, "A server already running may not let the tests create or look up other tenants and databases."); + Assume.That(DatabaseListingSupported, Is.True, "Only the v2 API of Chroma 0.6.3 and later lists databases."); + var name = $"database{Random.Shared.Next()}"; + + var client = new ChromaClient(BaseConfigurationOptions, HttpClient); + await client.CreateDatabaseAsync(name); + var result = await client.ListDatabasesAsync(); + Assert.That(result.Select(x => x.Name), Contains.Item(name)); + Assert.That(result.Select(x => x.Tenant), Is.All.EqualTo(BaseConfigurationOptions.Tenant ?? "default_tenant")); + } + + [Test] + public async Task ListDatabasesPage() + { + Assume.That(OtherTenantsAndDatabasesTested, Is.True, "A server already running may not let the tests create or look up other tenants and databases."); + Assume.That(DatabaseListingSupported, Is.True, "Only the v2 API of Chroma 0.6.3 and later lists databases."); + var tenant = $"tenant{Random.Shared.Next()}"; + + var client = new ChromaClient(BaseConfigurationOptions, HttpClient); + await client.CreateTenantAsync(tenant); + foreach (var name in new[] { "db_a", "db_b", "db_c" }) + { + await client.CreateDatabaseAsync(name, tenant: tenant); + } + Assert.That((await client.ListDatabasesAsync(limit: 2, tenant: tenant)).Select(x => x.Name), Is.EqualTo(new[] { "db_a", "db_b" })); + Assert.That((await client.ListDatabasesAsync(limit: 2, offset: 2, tenant: tenant)).Select(x => x.Name), Is.EqualTo(new[] { "db_c" })); + } + + [Test] + public async Task DeleteDatabase() + { + Assume.That(OtherTenantsAndDatabasesTested, Is.True, "A server already running may not let the tests create or look up other tenants and databases."); + Assume.That(DatabaseListingSupported, Is.True, "Only the v2 API of Chroma 0.6.3 and later deletes databases."); + var tenant = $"tenant{Random.Shared.Next()}"; + + var client = new ChromaClient(BaseConfigurationOptions, HttpClient); + await client.CreateTenantAsync(tenant); + await client.CreateDatabaseAsync("db_a", tenant: tenant); + await client.CreateDatabaseAsync("db_b", tenant: tenant); + await client.DeleteDatabaseAsync("db_a", tenant: tenant); + Assert.That((await client.ListDatabasesAsync(tenant: tenant)).Select(x => x.Name), Is.EqualTo(new[] { "db_b" })); + await Assert.ThatAsync(() => client.GetDatabaseAsync("db_a", tenant: tenant), Throws.InstanceOf().With.Message.Contains("not found")); + } + + [Test] + public async Task DeleteDatabaseNotExists() + { + Assume.That(OtherTenantsAndDatabasesTested, Is.True, "A server already running may not let the tests create or look up other tenants and databases."); + Assume.That(DatabaseListingSupported, Is.True, "Only the v2 API of Chroma 0.6.3 and later deletes databases."); + + var client = new ChromaClient(BaseConfigurationOptions, HttpClient); + await Assert.ThatAsync(() => client.DeleteDatabaseAsync($"database{Random.Shared.Next()}"), Throws.InstanceOf().With.Message.Contains("not found")); + } + + // The older servers answer 405 Method Not Allowed: the client reports it with a ChromaException. + [Test] + public async Task ListAndDeleteDatabasesNotSupported() + { + Assume.That(DatabaseListingSupported, Is.False, "This server lists and deletes databases."); + var name = $"database{Random.Shared.Next()}"; + + var client = new ChromaClient(BaseConfigurationOptions, HttpClient); + await client.CreateDatabaseAsync(name); + await Assert.ThatAsync(() => client.ListDatabasesAsync(), Throws.InstanceOf().With.Message.StartsWith("Method Not Allowed: GET ")); + await Assert.ThatAsync(() => client.DeleteDatabaseAsync(name), Throws.InstanceOf().With.Message.StartsWith("Method Not Allowed: DELETE ")); + Assert.That((await client.GetDatabaseAsync(name)).Name, Is.EqualTo(name)); + } + + [Test] + public async Task CollectionInTenantAndDatabase() + { + Assume.That(OtherTenantsAndDatabasesTested, Is.True, "A server already running may not let the tests create or look up other tenants and databases."); + Assume.That(RecordsInOtherTenantsSupported, Is.True, "Chroma 0.4.15 does not add records to the collections of other tenants and databases."); + var tenant = $"tenant{Random.Shared.Next()}"; + var database = $"database{Random.Shared.Next()}"; + var name = $"collection{Random.Shared.Next()}"; + + var client = new ChromaClient(BaseConfigurationOptions, HttpClient); + await client.CreateTenantAsync(tenant); + await client.CreateDatabaseAsync(database, tenant: tenant); + var options = BaseConfigurationOptions.WithTenant(tenant).WithDatabase(database); + var collection = await new ChromaClient(options, HttpClient).CreateCollectionAsync(name); + Assert.That(collection.Tenant, Is.EqualTo(tenant)); + Assert.That(collection.Database, Is.EqualTo(database)); + + var collectionClient = new ChromaCollectionClient(collection, options, HttpClient); + await collectionClient.AddAsync(["a", "b"], embeddings: [new([1f, 0f]), new([0f, 1f])], documents: ["first", "second"]); + Assert.That((await collectionClient.GetAsync("a"))?.Document, Is.EqualTo("first")); + Assert.That((await collectionClient.QueryAsync(new ReadOnlyMemory([1f, 0.1f]), nResults: 1)).Single().Id, Is.EqualTo("a")); + await collectionClient.DeleteAsync(["a"]); + Assert.That(await collectionClient.CountAsync(), Is.EqualTo(1)); + + Assert.That((await client.ListCollectionsAsync()).Select(x => x.Name), Does.Not.Contain(name)); + Assert.That((await client.ListCollectionsAsync(tenant: tenant, database: database)).Select(x => x.Name), Contains.Item(name)); + } +} diff --git a/ChromaDB.Client.Tests/TestContainer/ChromaDBBuilder.cs b/ChromaDB.Client.Tests/TestContainer/ChromaDBBuilder.cs deleted file mode 100644 index 2c2e7d7..0000000 --- a/ChromaDB.Client.Tests/TestContainer/ChromaDBBuilder.cs +++ /dev/null @@ -1,54 +0,0 @@ -using Docker.DotNet.Models; -using DotNet.Testcontainers.Builders; -using DotNet.Testcontainers.Configurations; - -namespace ChromaDB.Client.Tests.TestContainer; - -public class ChromaDBBuilder : ContainerBuilder -{ - public const string ChromaDBImage = "chromadb/chroma:latest"; - public const int ChromaDBPort = 8000; - - protected override ChromaDBConfiguration DockerResourceConfiguration { get; } - - public ChromaDBBuilder() - : this(new ChromaDBConfiguration()) - { - DockerResourceConfiguration = Init().DockerResourceConfiguration; - } - - private ChromaDBBuilder(ChromaDBConfiguration dockerResourceConfiguration) - : base(dockerResourceConfiguration) - { - DockerResourceConfiguration = dockerResourceConfiguration; - } - - public override ChromaDBContainer Build() - { - Validate(); - return new ChromaDBContainer(DockerResourceConfiguration); - } - - protected override ChromaDBBuilder Init() - { - return base.Init() - .WithImage(ChromaDBImage) - .WithPortBinding(ChromaDBPort) - .WithWaitStrategy(Wait.ForUnixContainer().UntilPortIsAvailable(ChromaDBPort)); - } - - protected override ChromaDBBuilder Clone(IResourceConfiguration resourceConfiguration) - { - return Merge(DockerResourceConfiguration, new ChromaDBConfiguration(resourceConfiguration)); - } - - protected override ChromaDBBuilder Merge(ChromaDBConfiguration oldValue, ChromaDBConfiguration newValue) - { - return new ChromaDBBuilder(new ChromaDBConfiguration(oldValue, newValue)); - } - - protected override ChromaDBBuilder Clone(IContainerConfiguration resourceConfiguration) - { - return Merge(DockerResourceConfiguration, new ChromaDBConfiguration(resourceConfiguration)); - } -} \ No newline at end of file diff --git a/ChromaDB.Client.Tests/TestContainer/ChromaDBConfiguration.cs b/ChromaDB.Client.Tests/TestContainer/ChromaDBConfiguration.cs deleted file mode 100644 index 17b2ea8..0000000 --- a/ChromaDB.Client.Tests/TestContainer/ChromaDBConfiguration.cs +++ /dev/null @@ -1,26 +0,0 @@ -using Docker.DotNet.Models; -using DotNet.Testcontainers.Configurations; - -namespace ChromaDB.Client.Tests.TestContainer; - -public class ChromaDBConfiguration : ContainerConfiguration -{ - public ChromaDBConfiguration() - { } - - public ChromaDBConfiguration(IResourceConfiguration resourceConfiguration) - : base(resourceConfiguration) - { } - - public ChromaDBConfiguration(IContainerConfiguration resourceConfiguration) - : base(resourceConfiguration) - { } - - public ChromaDBConfiguration(ChromaDBConfiguration resourceConfiguration) - : this(new ChromaDBConfiguration(), resourceConfiguration) - { } - - public ChromaDBConfiguration(ChromaDBConfiguration oldValue, ChromaDBConfiguration newValue) - : base(oldValue, newValue) - { } -} \ No newline at end of file diff --git a/ChromaDB.Client.Tests/TestContainer/ChromaDBContainer.cs b/ChromaDB.Client.Tests/TestContainer/ChromaDBContainer.cs deleted file mode 100644 index 8a007f7..0000000 --- a/ChromaDB.Client.Tests/TestContainer/ChromaDBContainer.cs +++ /dev/null @@ -1,11 +0,0 @@ -using DotNet.Testcontainers.Configurations; -using DotNet.Testcontainers.Containers; - -namespace ChromaDB.Client.Tests.TestContainer; - -public class ChromaDBContainer : DockerContainer -{ - public ChromaDBContainer(IContainerConfiguration configuration) - : base(configuration) - { } -} diff --git a/ChromaDB.Client.Tests/TestContainer/ChromaImage.cs b/ChromaDB.Client.Tests/TestContainer/ChromaImage.cs new file mode 100644 index 0000000..b8dc45a --- /dev/null +++ b/ChromaDB.Client.Tests/TestContainer/ChromaImage.cs @@ -0,0 +1,25 @@ +using System.Text.RegularExpressions; + +namespace ChromaDB.Client.Tests.TestContainer; + +// The Chroma image the tests run against, from CHROMA_IMAGE, and its version, which tells the tests what the server supports. +public static class ChromaImage +{ + // Against a server already running (CHROMA_TEST_URI), like Chroma Cloud, the tests take the latest release unless CHROMA_IMAGE says otherwise. + public static readonly string Name = Environment.GetEnvironmentVariable("CHROMA_IMAGE") is { Length: > 0 } image ? image + : Environment.GetEnvironmentVariable("CHROMA_TEST_URI") is { Length: > 0 } ? "chromadb/chroma:latest" + : "chromadb/chroma:0.6.3"; + public static readonly System.Version Version = ParseVersion(Name); + + // "latest" is newer than any release; other tags start with the version, like "1.5.9" or "1.5.10.dev312". + static System.Version ParseVersion(string image) + { + var tag = image.Substring(image.LastIndexOf(':') + 1); + if (tag == "latest") + return new System.Version(int.MaxValue, 0); + var match = Regex.Match(tag, @"^\d+\.\d+(\.\d+)?"); + return match.Success + ? System.Version.Parse(match.Value) + : throw new InvalidOperationException($"Cannot read the Chroma version from '{image}'."); + } +} diff --git a/ChromaDB.Client.Tests/UnevenAnswersTests.cs b/ChromaDB.Client.Tests/UnevenAnswersTests.cs new file mode 100644 index 0000000..3f1a46f --- /dev/null +++ b/ChromaDB.Client.Tests/UnevenAnswersTests.cs @@ -0,0 +1,150 @@ +using System.Net; +using ChromaDB.Client.Models; +using NUnit.Framework; + +namespace ChromaDB.Client.Tests; + +// An answer to a read whose lists do not have one item for every record, as the get of Chroma 1.5.9 without the embedding of a record +// while other clients write and delete the same records: the read goes again, as after an error that can pass, and after the last +// attempt a ChromaException says what the answer had, instead of an ArgumentOutOfRangeException from the lists. +[TestFixture] +public class UnevenAnswersTests +{ + static readonly ChromaReadRetries NoDelay = new(4, TimeSpan.Zero, TimeSpan.Zero); + static readonly string[] Reads = ["get", "get with ids", "peek", "query", "search"]; + + [Test] + public async Task TheReadGoesAgain([ValueSource(nameof(Reads))] string read) + { + var server = new Server(uneven: 2); + Assert.That(await Run(Client(server, NoDelay), read), Is.EqualTo(1)); + Assert.That(server.Reads, Is.EqualTo(3)); + } + + [Test] + public void AfterTheLastAttemptAChromaException([ValueSource(nameof(Reads))] string read) + { + var server = new Server(uneven: int.MaxValue); + var ex = Assert.ThrowsAsync(() => Run(Client(server, NoDelay), read)); + Assert.That(ex!.Message, Does.StartWith("The answer of the server to a ").And.Contain("one item for every record")); + Assert.That(server.Reads, Is.EqualTo(4)); + } + + [Test] + public void WithoutAttempts([ValueSource(nameof(Reads))] string read) + { + var server = new Server(uneven: int.MaxValue); + Assert.ThrowsAsync(() => Run(Client(server, ChromaReadRetries.None), read)); + Assert.That(server.Reads, Is.EqualTo(1)); + } + + // The message names the list that is short. + [Test] + public void TheMessage() + { + var ex = Assert.ThrowsAsync(() => Client(new Server(uneven: int.MaxValue), ChromaReadRetries.None).GetAsync(["a"], include: ChromaGetInclude.Embeddings)); + Assert.That(ex!.Message, Is.EqualTo("The answer of the server to a get has 1 id and 0 embeddings, while it has one item for every record in each list.")); + } + + // A query has one list of each for every query embedding. + [Test] + public void AQueryWithoutTheListOfAnEmbedding() + { + var server = new FixedServer("""{"ids":[["a"]],"distances":[],"include":["distances"]}"""); + var ex = Assert.ThrowsAsync(() => Client(server, ChromaReadRetries.None).QueryAsync(new ChromaQuery([new([1f, 0f])]) { Include = ChromaQueryInclude.Distances })); + Assert.That(ex!.Message, Is.EqualTo("The answer of the server to a query has 1 lists of ids and 0 lists of distances, while it has one list of each for every query embedding.")); + } + + // A query has no null list for a query embedding with records: it is uneven, unlike a search. + [Test] + public void AQueryWithANullList() + { + var server = new FixedServer("""{"ids":[["a"]],"embeddings":[null],"include":["embeddings"]}"""); + var ex = Assert.ThrowsAsync(() => Client(server, ChromaReadRetries.None).QueryAsync(new ChromaQuery([new([1f, 0f])]) { Include = ChromaQueryInclude.Embeddings })); + Assert.That(ex!.Message, Is.EqualTo("The answer of the server to a query has 1 id and 0 embeddings, while it has one item for every record in each list.")); + } + + // A query has one list of ids for every query embedding, and a search for every search: fewer is uneven, not fewer results. + [Test] + public void AQueryWithoutTheIdsOfAnEmbedding() + { + var server = new FixedServer("""{"ids":[],"include":["distances"]}"""); + var ex = Assert.ThrowsAsync(() => Client(server, ChromaReadRetries.None).QueryAsync(new ChromaQuery([new([1f, 0f])]) { Include = ChromaQueryInclude.Distances })); + Assert.That(ex!.Message, Is.EqualTo("The answer of the server to a query has 0 lists of ids for 1 query embedding, while it has one list of each for every query embedding.")); + } + + [Test] + public void ASearchWithoutTheIdsOfASearch() + { + var server = new FixedServer("""{"ids":[["a"]]}"""); + var ex = Assert.ThrowsAsync(() => Client(server, ChromaReadRetries.None).SearchAsync([new ChromaSearch { Limit = 5 }, new ChromaSearch { Limit = 3 }])); + Assert.That(ex!.Message, Is.EqualTo("The answer of the server to a search has 1 list of ids for 2 searches, while it has one list of each for every search.")); + } + + // A search answers null for a list it does not select: that answer is whole. + [Test] + public async Task ASearchWithANullList() + { + var server = new FixedServer("""{"ids":[["a"]],"documents":[null],"embeddings":[null],"metadatas":[null],"scores":[[0.5]]}"""); + Assert.That((await Client(server, ChromaReadRetries.None).SearchAsync(new ChromaSearch { Limit = 5 })).Single().Id, Is.EqualTo("a")); + } + + // The records the read returns. + static async Task Run(ChromaCollectionClient client, string read) => read switch + { + "get" => (await client.GetAsync(include: ChromaGetInclude.Embeddings)).Count, + "get with ids" => (await client.GetAsync(["a"], include: ChromaGetInclude.Embeddings | ChromaGetInclude.Metadatas)).Count, + "peek" => (await client.PeekAsync()).Count, + "query" => (await client.QueryAsync(new ChromaQuery([new([1f, 0f])]) { Include = ChromaQueryInclude.Distances | ChromaQueryInclude.Embeddings })).Single().Count, + _ => (await client.SearchAsync(new ChromaSearch { Limit = 5 })).Count, + }; + + static ChromaCollectionClient Client(HttpMessageHandler handler, ChromaReadRetries retries) + => new(new ChromaCollection("c") { Id = Guid.NewGuid() }, new ChromaConfigurationOptions("https://api.trychroma.com").WithReadRetries(retries), new HttpClient(handler)); + + // Answers every read with the same answer. + sealed class FixedServer(string answer) : HttpMessageHandler + { + protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + var path = request.RequestUri!.AbsolutePath; + var last = path[(path.LastIndexOf('/') + 1)..]; + var text = last switch + { + "version" => "\"1.5.9\"", + "pre-flight-checks" => """{"max_batch_size":100,"supports_base64_encoding":false}""", + _ => answer, + }; + return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent(text) }); + } + } + + // Answers the first reads with a record and a list without its item, and then with the record whole. + sealed class Server(int uneven) : HttpMessageHandler + { + public int Reads { get; private set; } + + protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + var path = request.RequestUri!.AbsolutePath; + var last = path[(path.LastIndexOf('/') + 1)..]; + var bad = last is "get" or "query" or "search" && ++Reads <= uneven; + var answer = last switch + { + "version" => "\"1.5.9\"", + "pre-flight-checks" => """{"max_batch_size":100,"supports_base64_encoding":false}""", + "get" => bad + ? """{"ids":["a"],"embeddings":[],"metadatas":[{"k":1}],"documents":["d"],"include":["embeddings","metadatas","documents"]}""" + : """{"ids":["a"],"embeddings":[[1.0,0.0]],"metadatas":[{"k":1}],"documents":["d"],"include":["embeddings","metadatas","documents"]}""", + "query" => bad + ? """{"ids":[["a"]],"distances":[[]],"embeddings":[[[1.0,0.0]]],"include":["distances","embeddings"]}""" + : """{"ids":[["a"]],"distances":[[0.5]],"embeddings":[[[1.0,0.0]]],"include":["distances","embeddings"]}""", + "search" => bad + ? """{"ids":[["a"]],"scores":[[]]}""" + : """{"ids":[["a"]],"scores":[[0.5]]}""", + _ => "{}", + }; + return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent(answer) }); + } + } +} diff --git a/ChromaDB.Client.Tests/UpsertStrategyPropertyTests.cs b/ChromaDB.Client.Tests/UpsertStrategyPropertyTests.cs new file mode 100644 index 0000000..ce4c4fc --- /dev/null +++ b/ChromaDB.Client.Tests/UpsertStrategyPropertyTests.cs @@ -0,0 +1,137 @@ +using System.Globalization; +using System.Text.Json; +using ChromaDB.Client.Models; +using ChromaDB.Client.Tests.Common; +using FsCheck; +using FsCheck.Fluent; +using NUnit.Framework; + +namespace ChromaDB.Client.Tests; + +// SkipUnchangedEmbeddings on random records, against a fake server: a record is updated without its embedding if and only if its id is +// given once and stored, and its embedding is the one stored, each number equal, +0 and -0 too, or in a cosine collection within 4 ulp +// with the same sign. The others, and the ids given more than once, go by the upsert or the update of the server. The embeddings are +// read once for each id; each record goes once, with its own fields, in the order given. +[TestFixture] +public class UpsertStrategyPropertyTests +{ + [Test] + public void UnchangedEmbeddingsGoWithoutThem() + => PropertyChecks.ForAll(Cases, write => + { + var server = new UpsertStrategyTests.Server(); + foreach (var record in write.Records.DistinctBy(x => x.Id).Where(x => x.Stored is not null)) + { + server.Store(record.Id, record.Stored!, """{"i":-1}""", "stored"); + } + var collection = new ChromaCollection("c") { Id = Guid.Empty, Metadata = new Dictionary { ["hnsw:space"] = write.Cosine ? "cosine" : "l2" } }; + var options = new ChromaConfigurationOptions("http://localhost:8000").WithUpsertStrategy(ChromaUpsertStrategy.SkipUnchangedEmbeddings); + var client = new ChromaCollectionClient(collection, write.Limit is { } limit ? options.WithBatchSplitting(limit) : options, new HttpClient(server)); + var records = new ChromaRecords(write.Records.Select(x => x.Id).ToList()) + { + Embeddings = write.Records.Select(x => new ReadOnlyMemory(x.Given)).ToList(), + Metadatas = write.Records.Select((_, i) => (IReadOnlyDictionary?)new Dictionary { ["i"] = (long)i }).ToList(), + Documents = write.Records.Select((_, i) => $"d{i}").ToList(), + }; + (write.Upsert ? client.UpsertAsync(records) : client.UpdateAsync(records)).GetAwaiter().GetResult(); + + var expected = Expected(write); + var actual = server.Bodies.Select(x => Request(x.Path, x.Body)).ToList(); + return actual.SequenceEqual(expected).Label($"expected:\n{string.Join("\n", expected)}\nsent:\n{string.Join("\n", actual)}"); + }, 300); + + // The requests the strategy sends: the gets of the embeddings, then the update of the unchanged records without them, then the others, + // in batches of the limit of the caller, or of the max_batch_size of 100 that the fake server declares. + static List Expected(Case write) + { + var size = write.Limit ?? 100; + var repeated = write.Records.GroupBy(x => x.Id).Where(x => x.Count() > 1).Select(x => x.Key).ToHashSet(); + var places = Enumerable.Range(0, write.Records.Count).ToList(); + var unchanged = places.Where(i => write.Records[i] is var x && !repeated.Contains(x.Id) && x.Stored is not null && (write.Cosine ? x.SameInCosine : x.SameInL2)).ToList(); + var others = places.Except(unchanged).ToList(); + return + [ + .. write.Records.Select(x => x.Id).Distinct().Chunk(size).Select(ids => $"get {string.Join(",", ids)} include embeddings"), + .. unchanged.Chunk(size).Select(batch => Write("update", batch, write, embeddings: false)), + .. others.Chunk(size).Select(batch => Write(write.Upsert ? "upsert" : "update", batch, write, embeddings: true)), + ]; + } + + static string Write(string path, int[] places, Case write, bool embeddings) + => $"{path} {string.Join(",", places.Select(i => write.Records[i].Id))} documents {string.Join(",", places.Select(i => $"d{i}"))}" + + $" metadatas {string.Join(",", places)} embeddings {(embeddings ? string.Join(";", places.Select(i => Bits(write.Records[i].Given))) : "none")}"; + + static string Request(string path, JsonElement body) + { + var ids = string.Join(",", body.GetProperty("ids").EnumerateArray().Select(x => x.GetString())); + if (path == "get") + { + return $"get {ids} include {string.Join(",", body.GetProperty("include").EnumerateArray().Select(x => x.GetString()))}"; + } + var embeddings = body.TryGetProperty("embeddings", out var given) && given.ValueKind != JsonValueKind.Null + ? string.Join(";", given.EnumerateArray().Select(x => Bits(WriteBatchPropertyTests.Floats(x)))) + : "none"; + return $"{path} {ids} documents {string.Join(",", body.GetProperty("documents").EnumerateArray().Select(x => x.GetString()))}" + + $" metadatas {string.Join(",", body.GetProperty("metadatas").EnumerateArray().Select(x => x.GetProperty("i").GetInt64()))} embeddings {embeddings}"; + } + + static string Bits(float[] embedding) => string.Join(" ", embedding.Select(x => BitConverter.SingleToInt32Bits(x).ToString("x8", CultureInfo.InvariantCulture))); + + // A record written: the embedding stored for its id, if any, and the one given; whether they are the same in an l2 and in a cosine + // collection, as the generator made them. The first record of an id says what is stored. + internal sealed record Record(string Id, float[]? Stored, float[] Given, bool SameInL2, bool SameInCosine) + { + public override string ToString() + => $"{Id}: stored {(Stored is null ? "none" : Numbers(Stored))}, given {Numbers(Given)}, same in l2 {SameInL2}, in cosine {SameInCosine}"; + } + + internal sealed record Case(bool Upsert, bool Cosine, int? Limit, List Records) + { + public override string ToString() + => $"{(Upsert ? "upsert" : "update")} in a {(Cosine ? "cosine" : "l2")} collection, batches of {Limit?.ToString(CultureInfo.InvariantCulture) ?? "100"}:\n{string.Join("\n", Records)}"; + } + + static string Numbers(float[] embedding) => $"[{string.Join(", ", embedding.Select(x => x.ToString("R", CultureInfo.InvariantCulture)))}] ({Bits(embedding)})"; + + static float Ulps(float value, int ulps) => BitConverter.Int32BitsToSingle(BitConverter.SingleToInt32Bits(value) + ulps); + + // Numbers far from zero, so that a few ulp keep the sign. + static readonly Gen NonZero = + from magnitude in Gen.Choose(1, 1_000_000) + from sign in Gen.Elements(1f, -1f) + select sign * magnitude / 997f; + + static readonly Gen Zeros = Gen.Elements(0f, -0f); + + // A number stored, the one written, and whether they are the same in an l2 and in a cosine collection. + static readonly Gen<(float Stored, float Given, bool SameInL2, bool SameInCosine)> Pairs = Gen.Frequency( + (8, NonZero.Select(x => (x, x, true, true))), + (1, Zeros.Select(x => (x, x, true, true))), + (1, Zeros.Select(x => (x, -x, true, true))), + (3, from x in NonZero from ulps in Gen.Choose(-8, 8).Where(k => k != 0) select (x, Ulps(x, ulps), false, Math.Abs(ulps) <= 4)), + (1, from x in Zeros from ulps in Gen.Choose(1, 8) select (x, Ulps(x, ulps), false, ulps <= 4)), + (1, from x in Zeros from ulps in Gen.Choose(1, 4) select (x, Ulps(-x, ulps), false, false)), + (1, NonZero.Select(x => (x, -x, false, false))), + (1, NonZero.Select(x => (x, x * 2, false, false)))); + + // A stored embedding, of the same dimension or of one more, or none. + static Gen Records(int pool, int dimension) + => from id in Gen.Choose(0, pool - 1).Select(i => $"r{i}") + from pairs in Gen.ArrayOf(Pairs, dimension) + from kind in Gen.Frequency((6, Gen.Constant("stored")), (1, Gen.Constant("longer")), (2, Gen.Constant("new"))) + select kind switch + { + "stored" => new Record(id, pairs.Select(x => x.Stored).ToArray(), pairs.Select(x => x.Given).ToArray(), pairs.All(x => x.SameInL2), pairs.All(x => x.SameInCosine)), + "longer" => new Record(id, [.. pairs.Select(x => x.Stored), 1f], pairs.Select(x => x.Given).ToArray(), false, false), + _ => new Record(id, null, pairs.Select(x => x.Given).ToArray(), false, false), + }; + + static readonly Gen Cases = + from upsert in Gen.Elements(true, false) + from cosine in Gen.Elements(true, false) + from limit in Gen.Frequency((1, Gen.Constant((int?)null)), (3, Gen.Choose(1, 6).Select(x => (int?)x))) + from pool in Gen.Choose(1, 12) + from dimension in Gen.Choose(1, 4) + from records in Gen.Sized(size => Gen.Choose(0, Math.Max(1, size / 4)).SelectMany(count => Gen.ListOf(Records(pool, dimension), count))) + select new Case(upsert, cosine, limit, records.ToList()); +} diff --git a/ChromaDB.Client.Tests/UpsertStrategyServerTests.cs b/ChromaDB.Client.Tests/UpsertStrategyServerTests.cs new file mode 100644 index 0000000..0c2e397 --- /dev/null +++ b/ChromaDB.Client.Tests/UpsertStrategyServerTests.cs @@ -0,0 +1,141 @@ +using ChromaDB.Client.Models; +using NUnit.Framework; + +namespace ChromaDB.Client.Tests; + +// The upsert strategies on a server: what an upsert leaves is what Chroma would leave, and a query finds every record. +[TestFixture] +public class UpsertStrategyServerTests : ChromaTestsBase +{ + // With Server, what Chroma leaves; the strategy must leave the same. + [TestCase(ChromaUpsertStrategy.Server)] + [TestCase(ChromaUpsertStrategy.SkipUnchangedEmbeddings)] + public async Task UpsertLeavesWhatChromaWould(ChromaUpsertStrategy strategy) + { + Assume.That(IsChroma1, Is.True, "On Chroma 0.x, which has not the defect, the writes go as they are."); + var options = BaseConfigurationOptions.WithMetadataValues(ChromaMetadataValues.Exact).WithUpsertStrategy(strategy); + var collection = await new ChromaClient(options, HttpClient).CreateCollectionAsync($"collection{Random.Shared.Next()}"); + var client = new ChromaCollectionClient(collection, options, HttpClient); + await client.AddAsync(new ChromaRecords(["a", "b"]) + { + Embeddings = [new([1f, 0f]), new([0f, 1f])], + Metadatas = [new Dictionary { ["k"] = 1L, ["keep"] = "x" }, new Dictionary { ["k"] = 1L, ["gone"] = 2L }], + Documents = ["doc a", "doc b"], + }); + await client.UpsertAsync(new ChromaRecords(["a", "b", "c"]) + { + Embeddings = [new([1f, 0f]), new([1f, 1f]), new([0f, 2f])], + Metadatas = [new Dictionary { ["k"] = 2L }, new Dictionary { ["k"] = 2L, ["gone"] = null! }, new Dictionary { ["k"] = 3L }], + Documents = [null, null, "doc c"], + }); + var records = (await client.GetAsync(include: ChromaGetInclude.Embeddings | ChromaGetInclude.Metadatas | ChromaGetInclude.Documents)).ToDictionary(x => x.Id); + Assert.That(records["a"].Metadata, Is.EquivalentTo(new Dictionary { ["k"] = 2L, ["keep"] = "x" })); + Assert.That(records["a"].Document, Is.EqualTo("doc a")); + Assert.That(records["b"].Metadata, Is.EquivalentTo(new Dictionary { ["k"] = 2L })); + Assert.That(records["b"].Document, Is.EqualTo("doc b")); + Assert.That(records["b"].Embedding!.Value.ToArray(), Is.EqualTo(new[] { 1f, 1f })); + Assert.That(records["c"].Document, Is.EqualTo("doc c")); + var found = await client.QueryAsync(new ChromaQuery([new([1f, 1f])]) { NResults = 3 }); + Assert.That(found[0].Select(x => x.Id), Is.EquivalentTo(new[] { "a", "b", "c" })); + } + + // The case of KD-49, many times: 120 records with random vectors, an upsert of 20 of them with new vectors, then a query of all. + // Counts the runs where the query misses a record, for each strategy: SkipUnchangedEmbeddings cannot help, since every embedding + // written changes. Not in the CI: it takes minutes. + [TestCase(ChromaUpsertStrategy.Server)] + [TestCase(ChromaUpsertStrategy.SkipUnchangedEmbeddings)] + [Explicit("A measurement of KD-49 on a single Chroma server.")] + public async Task LostRecordsAfterUpserts(ChromaUpsertStrategy strategy) + { + // More runs with dotnet test -- TestRunParameters.Parameter(name=\"runs\", value=\"600\"). + var runs = int.Parse(TestContext.Parameters.Get("runs", "200"), System.Globalization.CultureInfo.InvariantCulture); + const int records = 120; + var options = BaseConfigurationOptions.WithUpsertStrategy(strategy); + var random = new Random(1); + var lost = 0; + for (var run = 0; run < runs; run++) + { + var collection = await new ChromaClient(options, HttpClient).CreateCollectionAsync(new ChromaCollectionDefinition($"lost{Random.Shared.Next()}") { Configuration = new() { Space = ChromaSpace.Cosine } }); + var client = new ChromaCollectionClient(collection, options, HttpClient); + var ids = Enumerable.Range(0, records).Select(i => $"r{i}").ToList(); + await client.UpsertAsync(ids, ids.Select(_ => Vector(random)).ToList()); + var rewritten = ids.OrderBy(_ => random.Next()).Take(20).ToList(); + await client.UpsertAsync(rewritten, rewritten.Select(_ => Vector(random)).ToList()); + var found = await client.QueryAsync(new ChromaQuery([Vector(random)]) { NResults = records }); + if (found[0].Count < records) + { + lost++; + } + await new ChromaClient(options, HttpClient).DeleteCollectionAsync(collection.Name); + } + TestContext.Out.WriteLine($"{strategy}: {lost} runs of {runs} miss a record."); + } + + // The case that SkipUnchangedEmbeddings protects, many times: 60 records with random vectors, an upsert of 20 of them with the same + // vectors and new metadata, then a query of all. Without deletes, Chroma searches on the graph. Not in the CI: it takes minutes. + [TestCase(ChromaUpsertStrategy.Server)] + [TestCase(ChromaUpsertStrategy.SkipUnchangedEmbeddings)] + [Explicit("A measurement of KD-49 on a single Chroma server.")] + public async Task LostRecordsAfterUpsertsWithTheSameEmbeddings(ChromaUpsertStrategy strategy) + { + const int runs = 300; + const int records = 60; + var options = BaseConfigurationOptions.WithUpsertStrategy(strategy); + var random = new Random(1); + var lost = 0; + for (var run = 0; run < runs; run++) + { + var collection = await new ChromaClient(options, HttpClient).CreateCollectionAsync(new ChromaCollectionDefinition($"same{Random.Shared.Next()}") { Configuration = new() { Space = ChromaSpace.Cosine } }); + var client = new ChromaCollectionClient(collection, options, HttpClient); + var ids = Enumerable.Range(0, records).Select(i => $"r{i}").ToList(); + var vectors = ids.Select(_ => Vector(random)).ToList(); + await client.UpsertAsync(ids, vectors); + var rewritten = Enumerable.Range(0, records).OrderBy(_ => random.Next()).Take(20).ToList(); + await client.UpsertAsync(rewritten.Select(i => ids[i]).ToList(), rewritten.Select(i => vectors[i]).ToList(), rewritten.Select(_ => (IReadOnlyDictionary)new Dictionary { ["run"] = (long)run }).ToList()); + var found = await client.QueryAsync(new ChromaQuery([Vector(random)]) { NResults = records }); + if (found[0].Count < records) + { + lost++; + } + await new ChromaClient(options, HttpClient).DeleteCollectionAsync(collection.Name); + } + TestContext.Out.WriteLine($"{strategy}, same embeddings: {lost} runs of {runs} miss a record."); + if (strategy == ChromaUpsertStrategy.SkipUnchangedEmbeddings) + { + Assert.That(lost, Is.EqualTo(0)); + } + } + + // What one thread on the server costs: an add of 5,000 records of 384 dimensions, an upsert of the same records with new vectors, + // and 100 queries of 10 results, 3 times. Not in the CI: it takes minutes. + [Test] + [Explicit("A measurement of the cost of RAYON_NUM_THREADS=1 on a single Chroma server.")] + public async Task WriteAndQueryTimes() + { + const int records = 5000; + var random = new Random(1); + ReadOnlyMemory Large() => Enumerable.Range(0, 384).Select(_ => (float)random.NextDouble()).ToArray(); + for (var round = 0; round < 3; round++) + { + var collection = await new ChromaClient(BaseConfigurationOptions, HttpClient).CreateCollectionAsync(new ChromaCollectionDefinition($"times{Random.Shared.Next()}") { Configuration = new() { Space = ChromaSpace.Cosine } }); + var client = new ChromaCollectionClient(collection, BaseConfigurationOptions, HttpClient); + var ids = Enumerable.Range(0, records).Select(i => $"r{i}").ToList(); + var watch = System.Diagnostics.Stopwatch.StartNew(); + await client.AddAsync(ids, ids.Select(_ => Large()).ToList()); + var add = watch.Elapsed; + watch.Restart(); + await client.UpsertAsync(ids, ids.Select(_ => Large()).ToList()); + var upsert = watch.Elapsed; + watch.Restart(); + for (var query = 0; query < 100; query++) + { + await client.QueryAsync(new ChromaQuery([Large()]) { NResults = 10 }); + } + var queries = watch.Elapsed; + TestContext.Out.WriteLine($"Round {round}: add {add.TotalSeconds:F2} s, upsert {upsert.TotalSeconds:F2} s, 100 queries {queries.TotalSeconds:F2} s."); + await new ChromaClient(BaseConfigurationOptions, HttpClient).DeleteCollectionAsync(collection.Name); + } + } + + static ReadOnlyMemory Vector(Random random) => Enumerable.Range(0, 4).Select(_ => (float)random.NextDouble()).ToArray(); +} diff --git a/ChromaDB.Client.Tests/UpsertStrategyTests.cs b/ChromaDB.Client.Tests/UpsertStrategyTests.cs new file mode 100644 index 0000000..319b48c --- /dev/null +++ b/ChromaDB.Client.Tests/UpsertStrategyTests.cs @@ -0,0 +1,261 @@ +using System.Net; +using System.Text.Json; +using System.Text.Json.Nodes; +using ChromaDB.Client.Models; +using NUnit.Framework; + +namespace ChromaDB.Client.Tests; + +// The upsert strategies, against a fake server: Chroma 1.0.21 to 1.5.9 may lose a record from the vector index after an upsert or an +// update with embeddings of records that exist (KD-49). +[TestFixture] +public class UpsertStrategyTests +{ + static readonly ReadOnlyMemory Same = new([1f, 0f]); + static readonly ReadOnlyMemory Old = new([0f, 1f]); + static readonly ReadOnlyMemory New = new([1f, 1f]); + + [Test] + public void TheOption() + { + var options = new ChromaConfigurationOptions("http://localhost:8000"); + Assert.That(options.UpsertStrategy, Is.EqualTo(ChromaUpsertStrategy.Server)); + Assert.That(options.WithUpsertStrategy(ChromaUpsertStrategy.Server).WithDatabase("d").UpsertStrategy, Is.EqualTo(ChromaUpsertStrategy.Server)); + } + + // The default: the upsert and the update go as they are, without reading the records. + [Test] + public async Task ServerByDefault() + { + var server = new Server(); + server.Store("a", Old, """{"k":1}""", "doc a"); + var collection = Collection(server, null); + await collection.UpsertAsync(["a"], [New]); + await collection.UpdateAsync(["a"], [Same]); + Assert.That(server.Writes(), Is.EqualTo(new[] { "upsert a", "update a" })); + } + + // SkipUnchangedEmbeddings: a keeps its embedding and is updated without it, which does not touch the vector index; b changes it + // and c is new, and they go by the upsert of the server. The fields go as given: without documents, the update sends none. + [Test] + public async Task SkipUnchangedEmbeddingsInAnUpsert() + { + var server = new Server(); + server.Store("a", Same, """{"k":1}""", "doc a"); + server.Store("b", Old, """{"k":1}""", "doc b"); + await Collection(server, ChromaUpsertStrategy.SkipUnchangedEmbeddings).UpsertAsync(new ChromaRecords(["a", "b", "c"]) + { + Embeddings = [Same, New, New], + Metadatas = [new Dictionary { ["k"] = 2L }, new Dictionary { ["k"] = 2L }, new Dictionary { ["k"] = 3L }], + }); + Assert.That(server.Writes(), Is.EqualTo(new[] { "get a,b,c", "update a", "upsert b,c" })); + var update = server.Body("update"); + Assert.That(update.TryGetProperty("embeddings", out var embeddings) && embeddings.ValueKind != JsonValueKind.Null, Is.False); + Assert.That(update.GetProperty("metadatas")[0].ToString(), Is.EqualTo("""{"k":2}""")); + Assert.That(update.TryGetProperty("documents", out var documents) && documents.ValueKind != JsonValueKind.Null, Is.False); + Assert.That(server.Body("upsert").GetProperty("embeddings")[0].EnumerateArray().Select(x => x.GetSingle()), Is.EqualTo(new[] { 1f, 1f })); + } + + [Test] + public async Task SkipUnchangedEmbeddingsInAnUpdate() + { + var server = new Server(); + server.Store("a", Same, """{"k":1}""", "doc a"); + await Collection(server, ChromaUpsertStrategy.SkipUnchangedEmbeddings).UpdateAsync(["a", "z"], [Same, New]); + Assert.That(server.Writes(), Is.EqualTo(new[] { "get a,z", "update a", "update z" })); + } + + // The defect is of Chroma 1.x: on Chroma 0.x the writes go as they are, whatever the strategy. + [Test] + public async Task OnChroma0TheWritesGoAsTheyAre() + { + var server = new Server { Version = "0.6.3" }; + server.Store("a", Same, """{"k":1}""", "doc a"); + await Collection(server, ChromaUpsertStrategy.SkipUnchangedEmbeddings).UpsertAsync(["a"], [Same]); + Assert.That(server.Writes(), Is.EqualTo(new[] { "upsert a" })); + } + + // Without embeddings an update or an upsert does not touch the vector index: it goes as it is. + [Test] + public async Task WithoutEmbeddings() + { + var server = new Server(); + server.Store("a", Old, """{"k":1}""", "doc a"); + await Collection(server, ChromaUpsertStrategy.SkipUnchangedEmbeddings).UpdateAsync(["a"], metadatas: [new Dictionary { ["k"] = 2L }]); + Assert.That(server.Writes(), Is.EqualTo(new[] { "update a" })); + } + + // In a cosine collection Chroma 1.x gives an embedding back 1 or 2 ulp off (KD-8): the same embedding sent again is unchanged, and + // one that differs by more is not. + [Test] + public async Task CosineEmbeddingsReadBackOff() + { + var server = new Server(); + server.Store("a", new([0.31594023f, 0.19201598f, 0.9178214f, -0.39999998f]), """{"k":1}""", "doc a"); + var collection = new ChromaCollection("c") { Id = Guid.Empty, Metadata = new Dictionary { ["hnsw:space"] = "cosine" } }; + var client = new ChromaCollectionClient(collection, new ChromaConfigurationOptions("http://localhost:8000").WithUpsertStrategy(ChromaUpsertStrategy.SkipUnchangedEmbeddings), new HttpClient(server)); + await client.UpsertAsync(["a"], [new([0.31594023f, 0.19201598f, 0.91782147f, -0.4f])]); + Assert.That(server.Writes(), Is.EqualTo(new[] { "get a", "update a" })); + server.Bodies.Clear(); + await client.UpsertAsync(["a"], [new([0.31594023f, 0.19201598f, 0.9178215f, -0.40001f])]); + Assert.That(server.Writes(), Is.EqualTo(new[] { "get a", "upsert a" })); + } + + // An id given more than once goes to the server in the order given, as with Server: Chroma applies the writes in order, and the last + // one stays. + [Test] + public async Task RepeatedIdsGoInTheOrderGiven() + { + var server = new Server(); + server.Store("a", Same, """{"k":1}""", "doc a"); + await Collection(server, null).UpsertAsync(["a", "a"], [New, Same]); + var expected = server.Body("upsert").ToString(); + server.Bodies.Clear(); + await Collection(server, ChromaUpsertStrategy.SkipUnchangedEmbeddings).UpsertAsync(["a", "a"], [New, Same]); + Assert.That(server.Writes(), Is.EqualTo(new[] { "get a", "upsert a,a" })); + Assert.That(server.Body("upsert").ToString(), Is.EqualTo(expected)); + } + + // The strategy reads the embeddings only: the other fields go as given. + [Test] + public async Task ReadsTheEmbeddingsOnly() + { + var server = new Server(); + server.Store("a", Same, """{"k":1}""", "doc a"); + await Collection(server, ChromaUpsertStrategy.SkipUnchangedEmbeddings).UpsertAsync(["a"], [Same]); + Assert.That(server.Body("get").GetProperty("include").EnumerateArray().Select(x => x.GetString()), Is.EqualTo(new[] { "embeddings" })); + } + + // Once the unchanged records went, a failure of the others is partly done: a client made by name does not run the write again on a + // collection created again under the name, which would read the records once more. + [Test] + public async Task APartialWriteIsNotRunAgainByName() + { + var server = new Server(); + server.Store("a", Same, """{"k":1}""", "doc a"); + server.Store("b", Old, """{"k":1}""", "doc b"); + var options = new ChromaConfigurationOptions("http://localhost:8000").WithUpsertStrategy(ChromaUpsertStrategy.SkipUnchangedEmbeddings); + var collection = new ChromaClient(options, new HttpClient(server)).GetCollectionClient("c"); + await collection.GetCollectionAsync(); + server.GoneOnUpsert = true; + await Assert.ThatAsync(() => collection.UpsertAsync(["a", "b"], [Same, New]), Throws.InstanceOf().With.Message.StartsWith("1 of the 2 records went before the error, and stay")); + Assert.That(server.Writes(), Is.EqualTo(new[] { "get a,b", "update a", "upsert b" })); + } + + // The reads and the writes go in batches of the batch size. + [Test] + public async Task InBatches() + { + var server = new Server(); + server.Store("a", Same, """{"k":1}""", "doc a"); + server.Store("b", Same, """{"k":1}""", "doc b"); + await Collection(server, ChromaUpsertStrategy.SkipUnchangedEmbeddings, maxBatchSize: 1).UpsertAsync(["a", "b", "c"], [Same, Same, New]); + Assert.That(server.Writes(), Is.EqualTo(new[] { "get a", "get b", "get c", "update a", "update b", "upsert c" })); + } + + // In a cosine collection, 4 ulp count as the same embedding, and 5 do not. + [TestCase(4, "update a")] + [TestCase(-4, "update a")] + [TestCase(5, "upsert a")] + [TestCase(-5, "upsert a")] + public async Task CosineThreshold(int ulps, string write) + { + var server = new Server(); + server.Store("a", new([0.5f, 0.25f]), """{"k":1}""", "doc a"); + var off = BitConverter.Int32BitsToSingle(BitConverter.SingleToInt32Bits(0.25f) + ulps); + var collection = new ChromaCollection("c") { Id = Guid.Empty, Metadata = new Dictionary { ["hnsw:space"] = "cosine" } }; + var client = new ChromaCollectionClient(collection, new ChromaConfigurationOptions("http://localhost:8000").WithUpsertStrategy(ChromaUpsertStrategy.SkipUnchangedEmbeddings), new HttpClient(server)); + await client.UpsertAsync(["a"], [new([0.5f, off])]); + Assert.That(server.Writes(), Is.EqualTo(new[] { "get a", write })); + } + + // A write that fails halfway says once how many records went, of all those given: the unchanged ones that went, and the others. + [TestCase("update 2", "1 of the 4 records went before the error, and stay: write failed")] + [TestCase("upsert 2", "3 of the 4 records went before the error, and stay: write failed")] + public async Task HowManyRecordsWent(string failAt, string message) + { + var server = new Server { FailAt = failAt }; + server.Store("a", Same, """{"k":1}""", "doc a"); + server.Store("b", Same, """{"k":1}""", "doc b"); + await Assert.ThatAsync(() => Collection(server, ChromaUpsertStrategy.SkipUnchangedEmbeddings, maxBatchSize: 1).UpsertAsync(["a", "b", "c", "d"], [Same, Same, New, New]), + Throws.InstanceOf().With.Message.EqualTo(message)); + } + + static ChromaCollectionClient Collection(HttpMessageHandler handler, ChromaUpsertStrategy? strategy, int? maxBatchSize = null, ChromaConfigurationOptions? options = null) + { + options ??= new ChromaConfigurationOptions("http://localhost:8000"); + options = maxBatchSize is { } size ? options.WithBatchSplitting(size) : options; + return new(Guid.Empty, "c", strategy is { } chosen ? options.WithUpsertStrategy(chosen) : options, new HttpClient(handler)); + } + + // Keeps the records it is given, answers a get of ids with them, and records the writes and their bodies. The collection c is read by + // name; with GoneOnUpsert an upsert finds it gone, and the name has another id from then on; FailAt makes one write answer 500. + internal sealed class Server : HttpMessageHandler + { + readonly Dictionary _stored = []; + string _id = "11111111-1111-1111-1111-111111111111"; + string? _gone; + public string Version { get; set; } = "1.0.0"; + public bool GoneOnUpsert { get; set; } + // Like "update 2": the second update answers 500. + public string? FailAt { get; set; } + readonly Dictionary _writes = []; + public List<(string Path, JsonElement Body)> Bodies { get; } = []; + + public void Store(string id, ReadOnlyMemory embedding, string metadata, string? document) => _stored[id] = (embedding.ToArray(), metadata, document); + + public IEnumerable Writes() + => Bodies.Select(x => $"{x.Path} {string.Join(",", x.Body.GetProperty("ids").EnumerateArray().Select(id => id.GetString()))}"); + + public JsonElement Body(string path) => Bodies.Single(x => x.Path == path).Body; + + protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + var path = request.RequestUri!.AbsolutePath.Split('/').Last(); + if (path == "pre-flight-checks") + { + return Answer(HttpStatusCode.OK, """{"max_batch_size":100,"supports_base64_encoding":false}"""); + } + if (path == "version") + { + return Answer(HttpStatusCode.OK, $"\"{Version}\""); + } + if (path == "c") + { + return Answer(HttpStatusCode.OK, "{\"id\":\"" + _id + "\",\"name\":\"c\",\"configuration_json\":{\"hnsw\":{\"space\":\"l2\"}}}"); + } + if (_gone is { } gone && request.RequestUri.AbsolutePath.Contains(gone)) + { + return Answer(HttpStatusCode.NotFound, """{"error":"NotFoundError","message":"Collection does not exist"}"""); + } + var body = JsonDocument.Parse(await request.Content!.ReadAsStringAsync(cancellationToken)).RootElement.Clone(); + Bodies.Add((path, body)); + _writes[path] = _writes.TryGetValue(path, out var count) ? count + 1 : 1; + if (FailAt == $"{path} {_writes[path]}") + { + return Answer(HttpStatusCode.InternalServerError, """{"error":"InternalError","message":"write failed"}"""); + } + if (path == "upsert" && GoneOnUpsert) + { + (_gone, _id, GoneOnUpsert) = (_id, "22222222-2222-2222-2222-222222222222", false); + return Answer(HttpStatusCode.NotFound, """{"error":"NotFoundError","message":"Collection does not exist"}"""); + } + if (path != "get") + { + return Answer(HttpStatusCode.OK, "{}"); + } + var ids = body.GetProperty("ids").EnumerateArray().Select(x => x.GetString()!).Where(_stored.ContainsKey).ToList(); + var answer = new JsonObject + { + ["ids"] = new JsonArray(ids.Select(id => (JsonNode)JsonValue.Create(id)!).ToArray()), + ["embeddings"] = new JsonArray(ids.Select(id => (JsonNode)new JsonArray(_stored[id].Embedding.Select(x => (JsonNode)JsonValue.Create(x)!).ToArray())).ToArray()), + ["metadatas"] = new JsonArray(ids.Select(id => JsonNode.Parse(_stored[id].Metadata)).ToArray()), + ["documents"] = new JsonArray(ids.Select(id => (JsonNode?)JsonValue.Create(_stored[id].Document)).ToArray()), + ["uris"] = new JsonArray(ids.Select(_ => (JsonNode?)null).ToArray()), + }; + return Answer(HttpStatusCode.OK, answer.ToJsonString()); + } + + static HttpResponseMessage Answer(HttpStatusCode status, string body) => new(status) { Content = new StringContent(body) }; + } +} diff --git a/ChromaDB.Client.Tests/WhereFilterPropertyTests.cs b/ChromaDB.Client.Tests/WhereFilterPropertyTests.cs new file mode 100644 index 0000000..d022886 --- /dev/null +++ b/ChromaDB.Client.Tests/WhereFilterPropertyTests.cs @@ -0,0 +1,197 @@ +using System.Globalization; +using ChromaDB.Client.Tests.Common; +using FsCheck; +using FsCheck.Fluent; +using NUnit.Framework; + +namespace ChromaDB.Client.Tests; + +// Filters made at random from &, |, Not, All, None, the comparisons, In, NotIn and Contains: the client sends the JSON that an oracle +// written here gives, Not(Not(f)) is f, and De Morgan holds. +[TestFixture] +public class WhereFilterPropertyTests +{ + [Test] + public void TheJsonOfTheOracle() + => PropertyChecks.ForAll(Filters, filter => filter.Build().ToString() == Json(Normal(filter, negated: false)), 500); + + [Test] + public void NotOfNotIsTheFilter() + => PropertyChecks.ForAll(Filters, filter => + { + var built = filter.Build(); + return ChromaWhereOperator.Not(ChromaWhereOperator.Not(built)).ToString() == built.ToString(); + }, 500); + + [Test] + public void DeMorgan() + => PropertyChecks.ForAll(Gen.Zip(Filters, Filters), pair => + { + var (a, b) = (pair.Item1.Build(), pair.Item2.Build()); + return ChromaWhereOperator.Not(a & b).ToString() == (ChromaWhereOperator.Not(a) | ChromaWhereOperator.Not(b)).ToString() + && ChromaWhereOperator.Not(a | b).ToString() == (ChromaWhereOperator.Not(a) & ChromaWhereOperator.Not(b)).ToString(); + }, 500); + + // The filters, as the tests make them. + internal abstract record Filter + { + public abstract ChromaWhereOperator Build(); + } + + sealed record Compare(string Key, string Operator, object Value) : Filter + { + public override ChromaWhereOperator Build() => Operator switch + { + "$eq" => ChromaWhereOperator.Equal(Key, Value), + "$ne" => ChromaWhereOperator.NotEqual(Key, Value), + "$gt" => ChromaWhereOperator.GreaterThan(Key, Value), + "$gte" => ChromaWhereOperator.GreaterThanOrEqual(Key, Value), + "$lt" => ChromaWhereOperator.LessThan(Key, Value), + "$lte" => ChromaWhereOperator.LessThanOrEqual(Key, Value), + "$contains" => ChromaWhereOperator.Contains(Key, Value), + _ => ChromaWhereOperator.NotContains(Key, Value), + }; + + public override string ToString() => $"{Key} {Operator} {Text(Value)}"; + } + + sealed record InList(string Key, bool In, object[] Values) : Filter + { + public override ChromaWhereOperator Build() => In ? ChromaWhereOperator.In(Key, Values) : ChromaWhereOperator.NotIn(Key, Values); + + public override string ToString() => $"{Key} {(In ? "$in" : "$nin")} [{string.Join(",", Values.Select(Text))}]"; + } + + sealed record Constant(bool All) : Filter + { + public override ChromaWhereOperator Build() => All ? ChromaWhereOperator.All : ChromaWhereOperator.None; + + public override string ToString() => All ? "All" : "None"; + } + + // With Conditional, && or || in place of & or |. + sealed record Logical(bool And, Filter Left, Filter Right, bool Conditional) : Filter + { + public override ChromaWhereOperator Build() + { + var (left, right) = (Left.Build(), Right.Build()); + return (And, Conditional) switch + { + (true, false) => left & right, + (true, true) => left && right, + (false, false) => left | right, + (false, true) => left || right, + }; + } + + public override string ToString() => $"({Left} {(And ? "&" : "|")}{(Conditional ? (And ? "&" : "|") : "")} {Right})"; + } + + sealed record Negation(Filter Inner) : Filter + { + public override ChromaWhereOperator Build() => ChromaWhereOperator.Not(Inner.Build()); + + public override string ToString() => $"Not({Inner})"; + } + + static readonly string[] Comparisons = ["$eq", "$ne", "$gt", "$gte", "$lt", "$lte", "$contains", "$not_contains"]; + + static readonly Gen Keys = Gen.Elements("a", "b", "c"); + + // Values whose JSON has one way to be written: whole numbers, numbers with a fraction, texts of letters, booleans. + static readonly Gen[] ValuesOfAKind = + [ + Gen.Choose(-1000, 1000).Select(x => (object)(long)x), + Gen.Choose(-400, 400).Select(x => (object)(x + 0.25)), + Gen.Elements("x", "y", "zz").Select(x => (object)x), + Gen.Elements(true, false).Select(x => (object)x), + ]; + + static readonly Gen Leaves = Gen.Frequency( + (5, from key in Keys from @operator in Gen.Elements(Comparisons) from value in Gen.OneOf(ValuesOfAKind) select (Filter)new Compare(key, @operator, value)), + (2, from key in Keys + from @in in Gen.Elements(true, false) + from values in Gen.Elements(ValuesOfAKind).SelectMany(kind => Gen.Choose(0, 3).SelectMany(count => Gen.ArrayOf(kind, count))) + select (Filter)new InList(key, @in, values)), + (1, Gen.Elements(true, false).Select(all => (Filter)new Constant(all)))); + + static Gen Trees(int depth) + { + if (depth == 0) + { + return Leaves; + } + var inner = Trees(depth - 1); + return Gen.Frequency( + (2, Leaves), + (4, from and in Gen.Elements(true, false) + from left in inner + from right in inner + from conditional in Gen.Elements(false, false, false, true) + select (Filter)new Logical(and, left, right, conditional)), + (1, inner.Select(x => (Filter)new Negation(x)))); + } + + static readonly Gen Filters = Gen.Sized(size => Trees(Math.Min(5, 1 + size / 20))); + + // The oracle: a filter as Chroma takes it. Chroma has no $not, so a negation goes into the operators and swaps $and and $or; All and + // None go away inside & and |; a chain of the same operator is one list. + abstract record Node; + + sealed record ConstantNode(bool All) : Node; + + sealed record LeafNode(string Key, string Operator, string Value) : Node; + + sealed record ListNode(string Operator, IReadOnlyList Items) : Node; + + static readonly Dictionary Negated = new() + { + ["$eq"] = "$ne", ["$ne"] = "$eq", ["$gt"] = "$lte", ["$lte"] = "$gt", ["$gte"] = "$lt", ["$lt"] = "$gte", + ["$in"] = "$nin", ["$nin"] = "$in", ["$contains"] = "$not_contains", ["$not_contains"] = "$contains", + }; + + static Node Normal(Filter filter, bool negated) => filter switch + { + Compare x => new LeafNode(x.Key, negated ? Negated[x.Operator] : x.Operator, Text(x.Value)), + // In without values matches no record, and NotIn without values every record. + InList { Values.Length: 0 } x => new ConstantNode(x.In == negated), + InList x => new LeafNode(x.Key, (x.In != negated) ? "$in" : "$nin", $"[{string.Join(",", x.Values.Select(Text))}]"), + Constant x => new ConstantNode(x.All != negated), + Logical x => Join(x.And != negated, Normal(x.Left, negated), Normal(x.Right, negated)), + Negation x => Normal(x.Inner, !negated), + _ => throw new ArgumentException(filter.ToString()), + }; + + static Node Join(bool and, Node left, Node right) + { + if (left is ConstantNode l) + { + return l.All == and ? right : left; + } + if (right is ConstantNode r) + { + return r.All == and ? left : right; + } + var @operator = and ? "$and" : "$or"; + return new ListNode(@operator, [.. Items(left, @operator), .. Items(right, @operator)]); + } + + static IReadOnlyList Items(Node node, string @operator) => node is ListNode list && list.Operator == @operator ? list.Items : [node]; + + static string Json(Node node) => node switch + { + ConstantNode x => x.All ? "true" : "false", + LeafNode x => $"{{\"{x.Key}\":{{\"{x.Operator}\":{x.Value}}}}}", + ListNode x => $"{{\"{x.Operator}\":[{string.Join(",", x.Items.Select(Json))}]}}", + _ => throw new ArgumentException(node.ToString()), + }; + + static string Text(object value) => value switch + { + long x => x.ToString(CultureInfo.InvariantCulture), + double x => x.ToString("R", CultureInfo.InvariantCulture), + string x => $"\"{x}\"", + bool x => x ? "true" : "false", + _ => throw new ArgumentException(value.ToString()), + }; +} diff --git a/ChromaDB.Client.Tests/WhereSplitServerTests.cs b/ChromaDB.Client.Tests/WhereSplitServerTests.cs new file mode 100644 index 0000000..06efd16 --- /dev/null +++ b/ChromaDB.Client.Tests/WhereSplitServerTests.cs @@ -0,0 +1,71 @@ +using ChromaDB.Client.Models; +using NUnit.Framework; + +namespace ChromaDB.Client.Tests; + +// The conditions on the ids and on the documents in the where clause, on every tested server: get, query and delete send them as the +// ids and where_document of the request, the Search API of Chroma Cloud as they are. +[TestFixture] +public class WhereSplitServerTests : ChromaTestsBase +{ + static readonly ReadOnlyMemory Embedding1 = new([1f, 0f]); + static readonly ReadOnlyMemory Embedding2 = new([0f, 1f]); + static readonly ChromaWhereOperator Pool = ChromaWhereOperator.Document(ChromaWhereDocumentOperator.Contains("pool")); + + [Test] + public async Task GetQueryAndDelete() + { + var collection = await Init(); + var where = ChromaWhereOperator.In(ChromaSearchKeys.Id, "a", "b", "c") & Pool & ChromaWhereOperator.Equal("k", 1); + Assert.That((await collection.GetAsync(where: where, include: ChromaGetInclude.None)).Select(x => x.Id), Is.EqualTo(new[] { "a" })); + var query = await collection.QueryAsync(new ChromaQuery([Embedding1]) { NResults = 1, Where = Pool & ChromaWhereOperator.Equal("k", 2), Include = ChromaQueryInclude.Distances }); + Assert.That(query[0].Select(x => x.Id), Is.EqualTo(new[] { "c" })); + await collection.DeleteAsync(["a", "b", "c"], ChromaWhereOperator.Equal(ChromaSearchKeys.Id, "c")); + Assert.That((await collection.GetAsync(include: ChromaGetInclude.None)).Select(x => x.Id), Is.EquivalentTo(new[] { "a", "b" })); + } + + // The negation of a condition on the documents goes as $not_contains in where_document. + [Test] + public async Task NotOnTheDocuments() + { + Assume.That(CountCollectionsAndNotContainsSupported, Is.True, "Chroma 0.4.15 has no count_collections, no $not_contains filter and no tenant and database in the collections."); + var collection = await Init(); + var query = await collection.QueryAsync(new ChromaQuery([Embedding1]) { NResults = 3, Where = ChromaWhereOperator.Not(ChromaWhereOperator.Document(ChromaWhereDocumentOperator.Contains("spa"))), Include = ChromaQueryInclude.Distances }); + Assert.That(query[0].Select(x => x.Id), Is.EquivalentTo(new[] { "b", "c" })); + } + + [Test] + public async Task SearchOnChromaCloud() + { + Assume.That(ChromaCloud, Is.True, "Only Chroma Cloud has the Search API."); + var collection = await Init(); + var found = await collection.SearchAsync(new ChromaSearch { Where = ChromaWhereOperator.In(ChromaSearchKeys.Id, "b") | Pool, Select = [ChromaSearchKeys.Document] }); + Assert.That(found.Select(x => x.Id), Is.EquivalentTo(new[] { "a", "b", "c" })); + } + + // A query that expects another space than the one of the collection throws, before the request. + [Test] + public async Task ExpectedSpace() + { + var client = new ChromaClient(BaseConfigurationOptions, HttpClient); + var created = await client.CreateCollectionAsync(new ChromaCollectionDefinition($"collection{Random.Shared.Next()}") { Configuration = new() { Space = ChromaSpace.L2 } }); + Assume.That(created.Space, Is.Not.Null, "The server does not report the space of the collection."); + var collection = client.GetCollectionClient(created); + await collection.AddAsync(new ChromaRecords(["a"]) { Embeddings = [Embedding1] }); + await Assert.ThatAsync(() => collection.QueryAsync(new ChromaQuery([Embedding1]) { ExpectedSpace = ChromaSpace.Cosine }), Throws.InvalidOperationException); + Assert.That((await collection.QueryAsync(new ChromaQuery([Embedding1]) { ExpectedSpace = ChromaSpace.L2 }))[0].Single().Id, Is.EqualTo("a")); + } + + async Task Init() + { + var client = new ChromaClient(BaseConfigurationOptions, HttpClient); + var collection = client.GetCollectionClient(await client.CreateCollectionAsync($"collection{Random.Shared.Next()}")); + await collection.AddAsync(new ChromaRecords(["a", "b", "c"]) + { + Embeddings = [Embedding1, Embedding2, Embedding1], + Metadatas = [new Dictionary { ["k"] = 1 }, new Dictionary { ["k"] = 1 }, new Dictionary { ["k"] = 2 }], + Documents = ["pool and spa", "gym", "pool"], + }); + return collection; + } +} diff --git a/ChromaDB.Client.Tests/WhereSplitTests.cs b/ChromaDB.Client.Tests/WhereSplitTests.cs new file mode 100644 index 0000000..87ba02b --- /dev/null +++ b/ChromaDB.Client.Tests/WhereSplitTests.cs @@ -0,0 +1,150 @@ +using System.Net; +using System.Text.Json; +using ChromaDB.Client.Models; +using NUnit.Framework; + +namespace ChromaDB.Client.Tests; + +// Get, query and delete take neither ids nor documents in their where clause: the client sends the conditions on #id, with Equal and +// In, and the ones of Document as the ids and where_document of the request, when they are joined to the others with &. The Search +// API takes them in its where clause as they are. +[TestFixture] +public class WhereSplitTests +{ + static readonly ReadOnlyMemory Embedding = new([1f, 0f]); + static readonly ChromaWhereOperator Pool = ChromaWhereOperator.Document(ChromaWhereDocumentOperator.Contains("pool")); + + [Test] + public async Task Get() + { + var server = new FakeServer("""{"ids":[]}"""); + await Client(server).GetAsync(ids: ["a", "b", "c"], where: ChromaWhereOperator.In(ChromaSearchKeys.Id, "b", "c", "d") & Pool & ChromaWhereOperator.Equal("k", 1) & ChromaWhereOperator.Equal(ChromaSearchKeys.Id, "c")); + var body = server.Body("get"); + Assert.That(body.GetProperty("ids").EnumerateArray().Select(x => x.GetString()), Is.EqualTo(new[] { "c" })); + Assert.That(body.GetProperty("where").GetRawText(), Is.EqualTo("""{"k":{"$eq":1}}""")); + Assert.That(body.GetProperty("where_document").GetRawText(), Is.EqualTo("""{"$contains":"pool"}""")); + } + + // Filters on the documents combined with each other, and negated, go as one where_document. + [Test] + public async Task DocumentsCombined() + { + var server = new FakeServer("""{"ids":[]}"""); + var spa = ChromaWhereOperator.Document(ChromaWhereDocumentOperator.Contains("spa")); + await Client(server).GetAsync(where: ChromaWhereOperator.Not(Pool | spa)); + var body = server.Body("get"); + Assert.That(body.TryGetProperty("where", out var where) && where.ValueKind != JsonValueKind.Null, Is.False); + Assert.That(body.GetProperty("where_document").GetRawText(), Is.EqualTo("""{"$and":[{"$not_contains":"pool"},{"$not_contains":"spa"}]}""")); + } + + // Ids that no record can have: no request. + [Test] + public async Task NoIdsLeft() + { + var server = new FakeServer("""{"ids":[]}"""); + var client = Client(server); + Assert.That(await client.GetAsync(where: ChromaWhereOperator.Equal(ChromaSearchKeys.Id, "a") & ChromaWhereOperator.Equal(ChromaSearchKeys.Id, "b")), Is.Empty); + Assert.That(await client.GetAsync(ids: ["a"], where: ChromaWhereOperator.In(ChromaSearchKeys.Id, "b")), Is.Empty); + Assert.That(server.Paths, Is.Empty); + } + + // What the where clause of get, query and delete cannot take. + [Test] + public void NotSupported() + { + var client = Client(new FakeServer("""{"ids":[]}""")); + var key = ChromaWhereOperator.Equal("k", 1); + Assert.That(() => client.GetAsync(where: ChromaWhereOperator.In(ChromaSearchKeys.Id, "a") | key), Throws.InstanceOf()); + Assert.That(() => client.GetAsync(where: Pool | key), Throws.InstanceOf()); + Assert.That(() => client.GetAsync(where: ChromaWhereOperator.NotIn(ChromaSearchKeys.Id, "a")), Throws.InstanceOf()); + Assert.That(() => client.GetAsync(where: ChromaWhereOperator.Equal(ChromaSearchKeys.Id, 1)), Throws.ArgumentException); + Assert.That(() => ChromaWhereOperator.Document(null!), Throws.ArgumentNullException); + } + + [Test] + public async Task Query() + { + var server = new FakeServer("""{"ids":[["b"]],"distances":[[0.1]]}"""); + await Client(server).QueryAsync(new ChromaQuery([Embedding]) { Ids = ["a", "b"], Where = ChromaWhereOperator.In(ChromaSearchKeys.Id, "b", "c") & Pool, Include = ChromaQueryInclude.Distances }); + var body = server.Body("query"); + Assert.That(body.GetProperty("ids").EnumerateArray().Select(x => x.GetString()), Is.EqualTo(new[] { "b" })); + Assert.That(body.GetProperty("where_document").GetRawText(), Is.EqualTo("""{"$contains":"pool"}""")); + } + + [Test] + public async Task Delete() + { + var server = new FakeServer("""{"ids":[]}"""); + var client = Client(server); + await client.DeleteAsync(["a", "b"], ChromaWhereOperator.In(ChromaSearchKeys.Id, "b") & Pool); + var body = server.Body("delete"); + Assert.That(body.GetProperty("ids").EnumerateArray().Select(x => x.GetString()), Is.EqualTo(new[] { "b" })); + Assert.That(body.GetProperty("where_document").GetRawText(), Is.EqualTo("""{"$contains":"pool"}""")); + + await client.DeleteAsync(new ChromaDelete { Where = ChromaWhereOperator.Equal(ChromaSearchKeys.Id, "c") }); + Assert.That(server.Body("delete").GetProperty("ids").EnumerateArray().Select(x => x.GetString()), Is.EqualTo(new[] { "c" })); + await client.DeleteAsync(["a"], ChromaWhereOperator.In(ChromaSearchKeys.Id, "b")); + Assert.That(await client.DeleteAsync(new ChromaDelete { Ids = ["a"], Where = ChromaWhereOperator.In(ChromaSearchKeys.Id, "b") }), Is.EqualTo(0)); + Assert.That(server.Paths.Count(x => x == "delete"), Is.EqualTo(2)); + } + + // The Search API takes them in its where clause. + [Test] + public async Task Search() + { + var server = new FakeServer("""{"ids":[[]],"documents":[null],"embeddings":[null],"metadatas":[null],"scores":[null],"select":[[]]}"""); + await Client(server).SearchAsync(new ChromaSearch { Where = ChromaWhereOperator.In(ChromaSearchKeys.Id, "a") | Pool }); + Assert.That(server.Body("search").GetProperty("searches")[0].GetProperty("filter").GetRawText(), + Is.EqualTo("""{"$or":[{"#id":{"$in":["a"]}},{"#document":{"$contains":"pool"}}]}""")); + } + + // A query that expects another space than the one of the collection stops before the request; a collection whose space the + // server does not report is taken. + [TestCase("l2", true)] + [TestCase("cosine", false)] + [TestCase(null, false)] + public async Task ExpectedSpace(string? space, bool throws) + { + var server = new FakeServer("""{"ids":[[]],"distances":[[]]}"""); + var collection = new ChromaCollection("c") { Id = Guid.NewGuid(), Metadata = space is null ? null : new Dictionary { ["hnsw:space"] = space } }; + var client = new ChromaCollectionClient(collection, new ChromaConfigurationOptions("http://localhost:8000"), new HttpClient(server)); + var query = new ChromaQuery([Embedding]) { ExpectedSpace = ChromaSpace.Cosine }; + if (throws) + { + await Assert.ThatAsync(() => client.QueryAsync(query), Throws.InvalidOperationException.With.Message.Contains("space l2, not cosine")); + Assert.That(server.Paths, Does.Not.Contain("query")); + return; + } + await client.QueryAsync(query); + Assert.That(server.Paths, Does.Contain("query")); + } + + static ChromaCollectionClient Client(HttpMessageHandler handler) + => new(new ChromaCollection("c") { Id = Guid.NewGuid() }, new ChromaConfigurationOptions("http://localhost:8000"), new HttpClient(handler)); + + // Answers the version, pre-flight-checks with a batch size, the reads with the records, and the writes with nothing; records the + // requests. + sealed class FakeServer(string records) : HttpMessageHandler + { + public List Paths { get; } = []; + public List Bodies { get; } = []; + + // The body of the last request to the path. + public JsonElement Body(string path) => Bodies[Paths.LastIndexOf(path)]; + + protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + var path = request.RequestUri!.AbsolutePath; + var answer = path.EndsWith("/version") ? "\"1.5.9\"" + : path.EndsWith("/pre-flight-checks") ? """{"max_batch_size":100,"supports_base64_encoding":false}""" + : path.EndsWith("/get") || path.EndsWith("/query") || path.EndsWith("/search") ? records + : "{}"; + if (!path.EndsWith("/version") && !path.EndsWith("/pre-flight-checks")) + { + Paths.Add(path.Substring(path.LastIndexOf('/') + 1)); + Bodies.Add(request.Content is null ? default : JsonDocument.Parse(await request.Content.ReadAsStringAsync(cancellationToken)).RootElement.Clone()); + } + return new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent(answer) }; + } + } +} diff --git a/ChromaDB.Client.Tests/WriteBatchPropertyTests.cs b/ChromaDB.Client.Tests/WriteBatchPropertyTests.cs new file mode 100644 index 0000000..8323881 --- /dev/null +++ b/ChromaDB.Client.Tests/WriteBatchPropertyTests.cs @@ -0,0 +1,125 @@ +using System.Net; +using System.Text.Json; +using ChromaDB.Client.Models; +using ChromaDB.Client.Tests.Common; +using FsCheck; +using FsCheck.Fluent; +using NUnit.Framework; + +namespace ChromaDB.Client.Tests; + +// Writes of a random number of records, also with repeated ids, with random limits of the caller and of the server, against a fake +// server: the ids that arrive, one batch after the other, are the ones given and in their order; no batch goes over the smaller limit, +// and only the last one is not full; each record keeps its own fields. +[TestFixture] +public class WriteBatchPropertyTests +{ + [Test] + public void TheBatchesAreTheRecordsInOrder() + => PropertyChecks.ForAll(Cases, write => + { + var server = new Server(write.ServerLimit, write.Base64); + var options = new ChromaConfigurationOptions("http://localhost:8000"); + var client = new ChromaCollectionClient(Guid.Empty, "c", write.CallerLimit is { } callerLimit ? options.WithBatchSplitting(callerLimit) : options, new HttpClient(server)); + var records = new ChromaRecords(write.Ids) + { + Embeddings = write.Ids.Select((_, i) => new ReadOnlyMemory([i, -0.5f * i])).ToList(), + Metadatas = write.Ids.Select((_, i) => (IReadOnlyDictionary?)new Dictionary { ["i"] = (long)i }).ToList(), + Documents = write.Ids.Select((_, i) => $"d{i}").ToList(), + }; + (write.Operation switch + { + "add" => client.AddAsync(records), + "update" => client.UpdateAsync(records), + "upsert" => client.UpsertAsync(records), + _ => client.DeleteAsync(write.Ids), + }).GetAwaiter().GetResult(); + + var batches = server.Bodies.Where(x => x.Path == write.Operation).Select(x => x.Body).ToList(); + var limit = new[] { write.CallerLimit, write.ServerLimit }.Min(); + var ids = batches.SelectMany(x => x.GetProperty("ids").EnumerateArray().Select(id => id.GetString()!)).ToList(); + var sizes = batches.Select(x => x.GetProperty("ids").GetArrayLength()).ToList(); + // The fields of the records are numbered by their place among those given. + var places = write.Operation == "delete" ? null : batches.SelectMany(Places).ToList(); + return (server.Bodies.All(x => x.Path == write.Operation) + && ids.SequenceEqual(write.Ids) + && sizes.All(size => size <= (limit ?? write.Ids.Count)) + && sizes.Take(sizes.Count - 1).All(size => size == limit) + && (places is null || places.SequenceEqual(write.Ids.Select((_, i) => i)))) + .Label($"limit {limit?.ToString() ?? "none"}; batches: {string.Join(" ", batches.Select(x => x.GetRawText()))}"); + }, 300); + + // The place of each record of a batch, from each of its fields; -1 where they differ. A batch without records has no fields. + static IEnumerable Places(JsonElement batch) + { + if (batch.GetProperty("ids").GetArrayLength() == 0) + { + yield break; + } + var embeddings = batch.GetProperty("embeddings").EnumerateArray().Select(Floats).ToList(); + var metadatas = batch.GetProperty("metadatas").EnumerateArray().ToList(); + var documents = batch.GetProperty("documents").EnumerateArray().ToList(); + for (var j = 0; j < embeddings.Count; j++) + { + var place = (int)embeddings[j][0]; + yield return embeddings[j].SequenceEqual(new[] { place, -0.5f * place }) + && metadatas[j].GetProperty("i").GetInt32() == place + && documents[j].GetString() == $"d{place}" + ? place + : -1; + } + } + + // An embedding as a list of numbers, or as a base64 string of float32 values in little-endian order, whatever the order of the bytes + // of the machine. + internal static float[] Floats(JsonElement embedding) + { + if (embedding.ValueKind != JsonValueKind.String) + { + return embedding.EnumerateArray().Select(x => x.GetSingle() == 0 && x.GetRawText().StartsWith('-') ? -0f : x.GetSingle()).ToArray(); + } + var bytes = Convert.FromBase64String(embedding.GetString()!); + return Enumerable.Range(0, bytes.Length / 4).Select(i => System.Buffers.Binary.BinaryPrimitives.ReadSingleLittleEndian(bytes.AsSpan(i * 4))).ToArray(); + } + + internal sealed record Write(string Operation, List Ids, int? CallerLimit, int? ServerLimit, bool Base64) + { + public override string ToString() + => $"{Operation} of {Ids.Count} [{string.Join(",", Ids)}], limit of the caller {CallerLimit?.ToString() ?? "none"}, of the server {ServerLimit?.ToString() ?? "none"}, base64 {Base64}"; + } + + static readonly Gen Limits = Gen.Frequency( + (1, Gen.Constant((int?)null)), + (4, Gen.Choose(1, 25).Select(x => (int?)x))); + + // The ids from a pool, small or large, so that some repeat. A delete without ids and filters is rejected before any request. + static readonly Gen Cases = + from operation in Gen.Elements("add", "update", "upsert", "delete") + from pool in Gen.Choose(1, 80) + from ids in Gen.Sized(size => Gen.Choose(operation == "delete" ? 1 : 0, Math.Max(1, size)).SelectMany(count => Gen.ListOf(Gen.Choose(0, pool - 1).Select(i => $"r{i}"), count))) + from callerLimit in Limits + from serverLimit in Limits + from base64 in Gen.Elements(false, true) + select new Write(operation, ids.ToList(), callerLimit, serverLimit, base64); + + // Declares its limit, or has no pre-flight-checks, like Chroma 0.4.10, and keeps the requests. + sealed class Server(int? maxBatchSize, bool base64) : HttpMessageHandler + { + public List<(string Path, JsonElement Body)> Bodies { get; } = []; + + protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + var path = request.RequestUri!.AbsolutePath.Split('/').Last(); + if (path == "pre-flight-checks") + { + return maxBatchSize is { } limit + ? Answer(HttpStatusCode.OK, $"{{\"max_batch_size\":{limit},\"supports_base64_encoding\":{(base64 ? "true" : "false")}}}") + : Answer(HttpStatusCode.NotFound, """{"error":"NotFoundError","message":"Not Found"}"""); + } + Bodies.Add((path, JsonDocument.Parse(await request.Content!.ReadAsStringAsync(cancellationToken)).RootElement.Clone())); + return Answer(HttpStatusCode.OK, "{}"); + } + + static HttpResponseMessage Answer(HttpStatusCode status, string body) => new(status) { Content = new StringContent(body) }; + } +} diff --git a/ChromaDB.Client.sln b/ChromaDB.Client.sln index cef9e8b..794840a 100644 --- a/ChromaDB.Client.sln +++ b/ChromaDB.Client.sln @@ -19,34 +19,85 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "ChromaDB.Client.DependencyI EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "ChromaDB.Client.Tests", "ChromaDB.Client.Tests\ChromaDB.Client.Tests.csproj", "{B97D82A1-FB2F-43CF-A134-6AD27ADC5595}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ChromaDB.Client.TrimmingTest", "Samples\ChromaDB.Client.TrimmingTest\ChromaDB.Client.TrimmingTest.csproj", "{5F7DA509-1048-4BE9-80C9-7BA1B3A43CBF}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU + Debug|x64 = Debug|x64 + Debug|x86 = Debug|x86 Release|Any CPU = Release|Any CPU + Release|x64 = Release|x64 + Release|x86 = Release|x86 EndGlobalSection GlobalSection(ProjectConfigurationPlatforms) = postSolution {A20C05EF-80FC-493C-B318-456FD838626E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {A20C05EF-80FC-493C-B318-456FD838626E}.Debug|Any CPU.Build.0 = Debug|Any CPU + {A20C05EF-80FC-493C-B318-456FD838626E}.Debug|x64.ActiveCfg = Debug|Any CPU + {A20C05EF-80FC-493C-B318-456FD838626E}.Debug|x64.Build.0 = Debug|Any CPU + {A20C05EF-80FC-493C-B318-456FD838626E}.Debug|x86.ActiveCfg = Debug|Any CPU + {A20C05EF-80FC-493C-B318-456FD838626E}.Debug|x86.Build.0 = Debug|Any CPU {A20C05EF-80FC-493C-B318-456FD838626E}.Release|Any CPU.ActiveCfg = Release|Any CPU {A20C05EF-80FC-493C-B318-456FD838626E}.Release|Any CPU.Build.0 = Release|Any CPU + {A20C05EF-80FC-493C-B318-456FD838626E}.Release|x64.ActiveCfg = Release|Any CPU + {A20C05EF-80FC-493C-B318-456FD838626E}.Release|x64.Build.0 = Release|Any CPU + {A20C05EF-80FC-493C-B318-456FD838626E}.Release|x86.ActiveCfg = Release|Any CPU + {A20C05EF-80FC-493C-B318-456FD838626E}.Release|x86.Build.0 = Release|Any CPU {AFA2C229-4D1A-44F8-9E9E-4EF13FC77199}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {AFA2C229-4D1A-44F8-9E9E-4EF13FC77199}.Debug|Any CPU.Build.0 = Debug|Any CPU + {AFA2C229-4D1A-44F8-9E9E-4EF13FC77199}.Debug|x64.ActiveCfg = Debug|Any CPU + {AFA2C229-4D1A-44F8-9E9E-4EF13FC77199}.Debug|x64.Build.0 = Debug|Any CPU + {AFA2C229-4D1A-44F8-9E9E-4EF13FC77199}.Debug|x86.ActiveCfg = Debug|Any CPU + {AFA2C229-4D1A-44F8-9E9E-4EF13FC77199}.Debug|x86.Build.0 = Debug|Any CPU {AFA2C229-4D1A-44F8-9E9E-4EF13FC77199}.Release|Any CPU.ActiveCfg = Release|Any CPU {AFA2C229-4D1A-44F8-9E9E-4EF13FC77199}.Release|Any CPU.Build.0 = Release|Any CPU + {AFA2C229-4D1A-44F8-9E9E-4EF13FC77199}.Release|x64.ActiveCfg = Release|Any CPU + {AFA2C229-4D1A-44F8-9E9E-4EF13FC77199}.Release|x64.Build.0 = Release|Any CPU + {AFA2C229-4D1A-44F8-9E9E-4EF13FC77199}.Release|x86.ActiveCfg = Release|Any CPU + {AFA2C229-4D1A-44F8-9E9E-4EF13FC77199}.Release|x86.Build.0 = Release|Any CPU {674FD1C0-814B-4371-B74A-F68A1BC20CA4}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {674FD1C0-814B-4371-B74A-F68A1BC20CA4}.Debug|Any CPU.Build.0 = Debug|Any CPU + {674FD1C0-814B-4371-B74A-F68A1BC20CA4}.Debug|x64.ActiveCfg = Debug|Any CPU + {674FD1C0-814B-4371-B74A-F68A1BC20CA4}.Debug|x64.Build.0 = Debug|Any CPU + {674FD1C0-814B-4371-B74A-F68A1BC20CA4}.Debug|x86.ActiveCfg = Debug|Any CPU + {674FD1C0-814B-4371-B74A-F68A1BC20CA4}.Debug|x86.Build.0 = Debug|Any CPU {674FD1C0-814B-4371-B74A-F68A1BC20CA4}.Release|Any CPU.ActiveCfg = Release|Any CPU {674FD1C0-814B-4371-B74A-F68A1BC20CA4}.Release|Any CPU.Build.0 = Release|Any CPU + {674FD1C0-814B-4371-B74A-F68A1BC20CA4}.Release|x64.ActiveCfg = Release|Any CPU + {674FD1C0-814B-4371-B74A-F68A1BC20CA4}.Release|x64.Build.0 = Release|Any CPU + {674FD1C0-814B-4371-B74A-F68A1BC20CA4}.Release|x86.ActiveCfg = Release|Any CPU + {674FD1C0-814B-4371-B74A-F68A1BC20CA4}.Release|x86.Build.0 = Release|Any CPU {B97D82A1-FB2F-43CF-A134-6AD27ADC5595}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {B97D82A1-FB2F-43CF-A134-6AD27ADC5595}.Debug|Any CPU.Build.0 = Debug|Any CPU + {B97D82A1-FB2F-43CF-A134-6AD27ADC5595}.Debug|x64.ActiveCfg = Debug|Any CPU + {B97D82A1-FB2F-43CF-A134-6AD27ADC5595}.Debug|x64.Build.0 = Debug|Any CPU + {B97D82A1-FB2F-43CF-A134-6AD27ADC5595}.Debug|x86.ActiveCfg = Debug|Any CPU + {B97D82A1-FB2F-43CF-A134-6AD27ADC5595}.Debug|x86.Build.0 = Debug|Any CPU {B97D82A1-FB2F-43CF-A134-6AD27ADC5595}.Release|Any CPU.ActiveCfg = Release|Any CPU {B97D82A1-FB2F-43CF-A134-6AD27ADC5595}.Release|Any CPU.Build.0 = Release|Any CPU + {B97D82A1-FB2F-43CF-A134-6AD27ADC5595}.Release|x64.ActiveCfg = Release|Any CPU + {B97D82A1-FB2F-43CF-A134-6AD27ADC5595}.Release|x64.Build.0 = Release|Any CPU + {B97D82A1-FB2F-43CF-A134-6AD27ADC5595}.Release|x86.ActiveCfg = Release|Any CPU + {B97D82A1-FB2F-43CF-A134-6AD27ADC5595}.Release|x86.Build.0 = Release|Any CPU + {5F7DA509-1048-4BE9-80C9-7BA1B3A43CBF}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {5F7DA509-1048-4BE9-80C9-7BA1B3A43CBF}.Debug|Any CPU.Build.0 = Debug|Any CPU + {5F7DA509-1048-4BE9-80C9-7BA1B3A43CBF}.Debug|x64.ActiveCfg = Debug|Any CPU + {5F7DA509-1048-4BE9-80C9-7BA1B3A43CBF}.Debug|x64.Build.0 = Debug|Any CPU + {5F7DA509-1048-4BE9-80C9-7BA1B3A43CBF}.Debug|x86.ActiveCfg = Debug|Any CPU + {5F7DA509-1048-4BE9-80C9-7BA1B3A43CBF}.Debug|x86.Build.0 = Debug|Any CPU + {5F7DA509-1048-4BE9-80C9-7BA1B3A43CBF}.Release|Any CPU.ActiveCfg = Release|Any CPU + {5F7DA509-1048-4BE9-80C9-7BA1B3A43CBF}.Release|Any CPU.Build.0 = Release|Any CPU + {5F7DA509-1048-4BE9-80C9-7BA1B3A43CBF}.Release|x64.ActiveCfg = Release|Any CPU + {5F7DA509-1048-4BE9-80C9-7BA1B3A43CBF}.Release|x64.Build.0 = Release|Any CPU + {5F7DA509-1048-4BE9-80C9-7BA1B3A43CBF}.Release|x86.ActiveCfg = Release|Any CPU + {5F7DA509-1048-4BE9-80C9-7BA1B3A43CBF}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE EndGlobalSection GlobalSection(NestedProjects) = preSolution {AFA2C229-4D1A-44F8-9E9E-4EF13FC77199} = {8A674AF4-9823-4968-B13A-DCC5F2778035} + {5F7DA509-1048-4BE9-80C9-7BA1B3A43CBF} = {8A674AF4-9823-4968-B13A-DCC5F2778035} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {F05E4BDF-E441-4FEA-BC3A-CEA3D9876C43} diff --git a/ChromaDB.Client/ChromaApiVersion.cs b/ChromaDB.Client/ChromaApiVersion.cs new file mode 100644 index 0000000..3d10b67 --- /dev/null +++ b/ChromaDB.Client/ChromaApiVersion.cs @@ -0,0 +1,17 @@ +namespace ChromaDB.Client; + +/// +/// The version of the Chroma API the client uses, for all its requests. +/// +public enum ChromaApiVersion +{ + /// + /// The v2 API: Chroma 0.5.16 and later. The default. + /// + V2, + /// + /// The v1 API, the only API of Chroma 0.5.15 and earlier; Chroma 0.5.16 to 0.5.20 also serve it. + /// Chroma 1.x answers it with 410 Gone. + /// + V1, +} diff --git a/ChromaDB.Client/ChromaAttachedFunction.cs b/ChromaDB.Client/ChromaAttachedFunction.cs new file mode 100644 index 0000000..ccd0aa2 --- /dev/null +++ b/ChromaDB.Client/ChromaAttachedFunction.cs @@ -0,0 +1,76 @@ +using System.Text.Json.Serialization; + +namespace ChromaDB.Client.Models; + +/// +/// A function of Chroma Cloud attached to a collection, which writes its results to an output collection. +/// AttachFunctionAsync fills Id, Name and FunctionName; GetAttachedFunctionAsync fills the rest too. +/// +public class ChromaAttachedFunction +{ + /// + /// The id of the attached function. + /// + [JsonPropertyName("id")] + public Guid Id { get; init; } + + /// + /// The name the function is attached under. + /// + [JsonPropertyName("name")] + public string Name { get; init; } = null!; + + /// + /// The name of the function, like ChromaFunctions.Statistics. + /// + [JsonPropertyName("function_name")] + public string FunctionName { get; init; } = null!; + + /// + /// The id of the collection the function is attached to. + /// + [JsonPropertyName("input_collection_id")] + public Guid? InputCollectionId { get; init; } + + /// + /// The name of the output collection. + /// + [JsonPropertyName("output_collection")] + public string? OutputCollection { get; init; } + + /// + /// The id of the output collection. Null until Chroma creates the output collection. + /// + [JsonPropertyName("output_collection_id")] + public Guid? OutputCollectionId { get; init; } + + /// + /// The tenant, from tenant_id in the answer of the server. + /// + [JsonPropertyName("tenant_id")] + public string? Tenant { get; init; } + + /// + /// The database, from database_id in the answer of the server. + /// + [JsonPropertyName("database_id")] + public string? Database { get; init; } + + /// + /// The parameters of the function as JSON. + /// + [JsonPropertyName("params")] + public string? Params { get; init; } + + /// + /// The position in the log of the collection up to which the function has run. + /// + [JsonPropertyName("completion_offset")] + public long? CompletionOffset { get; init; } + + /// + /// How many new records start the function again. + /// + [JsonPropertyName("min_records_for_invocation")] + public long? MinRecordsForInvocation { get; init; } +} diff --git a/ChromaDB.Client/ChromaBm25.cs b/ChromaDB.Client/ChromaBm25.cs new file mode 100644 index 0000000..8da6cb7 --- /dev/null +++ b/ChromaDB.Client/ChromaBm25.cs @@ -0,0 +1,203 @@ +using System.Text.Json; +using ChromaDB.Client.Common; +using ChromaDB.Client.Models; + +namespace ChromaDB.Client; + +/// +/// The BM25 function of Chroma, chroma_bm25: the sparse vector of a text, computed as the Python client of Chroma 1.5.9 computes it, +/// with the Snowball English stemmer of snowballstemmer 3.1.1, so that the vectors of .NET and of Python match. The same function gives the +/// vectors of the documents and of the queries; with bm25 in the sparse vector index, the server applies the inverse document frequency. +/// +public sealed class ChromaBm25 +{ + private readonly HashSet _stopwords; + private readonly IReadOnlyList? _customStopwords; + + /// + /// The English stopwords that the function drops by default, the list of the Python client of Chroma. + /// + public static IReadOnlyList DefaultStopwords { get; } = Array.AsReadOnly(new[] + { + "a", "about", "above", "after", "again", "against", "ain", "all", "am", "an", "and", "any", "are", "aren", "aren't", "as", "at", + "be", "because", "been", "before", "being", "below", "between", "both", "but", "by", "can", "couldn", "couldn't", "d", "did", "didn", + "didn't", "do", "does", "doesn", "doesn't", "doing", "don", "don't", "down", "during", "each", "few", "for", "from", "further", "had", + "hadn", "hadn't", "has", "hasn", "hasn't", "have", "haven", "haven't", "having", "he", "her", "here", "hers", "herself", "him", + "himself", "his", "how", "i", "if", "in", "into", "is", "isn", "isn't", "it", "it's", "its", "itself", "just", "ll", "m", "ma", "me", + "mightn", "mightn't", "more", "most", "mustn", "mustn't", "my", "myself", "needn", "needn't", "no", "nor", "not", "now", "o", "of", "off", + "on", "once", "only", "or", "other", "our", "ours", "ourselves", "out", "over", "own", "re", "s", "same", "shan", "shan't", "she", + "she's", "should", "should've", "shouldn", "shouldn't", "so", "some", "such", "t", "than", "that", "that'll", "the", "their", "theirs", + "them", "themselves", "then", "there", "these", "they", "this", "those", "through", "to", "too", "under", "until", "up", "ve", "very", + "was", "wasn", "wasn't", "we", "were", "weren", "weren't", "what", "when", "where", "which", "while", "who", "whom", "why", "will", + "with", "won", "won't", "wouldn", "wouldn't", "y", "you", "you'd", "you'll", "you're", "you've", "your", "yours", "yourself", "yourselves", + }); + + /// + /// The function with the settings of the Python client of Chroma, which are its defaults: k 1.2, b 0.75, + /// avgDocLength 256, tokenMaxLength 40 and DefaultStopwords. With includeTokens the vectors also hold + /// their tokens. A collection whose schema declares other settings needs the same ones here: Bm25Function of its + /// ChromaCollection.SparseVectorIndexes has them. + /// + /// The saturation of the term frequency. + /// How much the length of the text weighs. + /// The length of an average text, in tokens. + /// The longest token kept, in characters; the longer ones are dropped. + /// The words dropped from the texts, or null for DefaultStopwords. + /// Whether the vectors hold their tokens. + public ChromaBm25(double k = 1.2, double b = 0.75, double avgDocLength = 256, int tokenMaxLength = 40, IEnumerable? stopwords = null, bool includeTokens = false) + { + K = k; + B = b; + AvgDocLength = avgDocLength; + TokenMaxLength = tokenMaxLength; + IncludeTokens = includeTokens; + _customStopwords = stopwords is null ? null : Array.AsReadOnly(stopwords.ToArray()); + _stopwords = new HashSet((_customStopwords ?? DefaultStopwords).Select(Bm25Tokenizer.PythonLower), StringComparer.Ordinal); + } + + internal const string Name = "chroma_bm25"; + + // build_from_config of the Python client: the settings missing from the config get their default values, and null stopwords are the + // default ones. Null when the config is not an object or a setting has the wrong type, where the Python client keeps no function. + internal static ChromaBm25? FromConfig(JsonElement? config) + { + if (config is not { ValueKind: JsonValueKind.Object } settings) + { + return null; + } + double? Number(string name, double defaultValue) + => !settings.TryGetProperty(name, out var value) ? defaultValue + : value.ValueKind == JsonValueKind.Number ? value.GetDouble() + : null; + if (Number("k", 1.2) is not { } k || Number("b", 0.75) is not { } b || Number("avg_doc_length", 256) is not { } avgDocLength + || Number("token_max_length", 40) is not { } tokenMaxLength || tokenMaxLength < int.MinValue || tokenMaxLength >= (double)int.MaxValue + 1) + { + return null; + } + var includeTokens = false; + if (settings.TryGetProperty("include_tokens", out var include)) + { + switch (include.ValueKind) + { + case JsonValueKind.True: + includeTokens = true; + break; + case JsonValueKind.False or JsonValueKind.Null: + break; + default: + return null; + } + } + List? stopwords = null; + if (settings.TryGetProperty("stopwords", out var list) && list.ValueKind != JsonValueKind.Null) + { + if (list.ValueKind != JsonValueKind.Array || list.EnumerateArray().Any(x => x.ValueKind != JsonValueKind.String)) + { + return null; + } + stopwords = list.EnumerateArray().Select(x => x.GetString()!).ToList(); + } + // int() of Python truncates toward zero. + return new ChromaBm25(k, b, avgDocLength, (int)Math.Truncate(tokenMaxLength), stopwords, includeTokens); + } + + /// + /// The saturation of the term frequency. + /// + public double K { get; } + + /// + /// How much the length of the text weighs. + /// + public double B { get; } + + /// + /// The length of an average text, in tokens. + /// + public double AvgDocLength { get; } + + /// + /// The longest token kept, in characters; the longer ones are dropped. + /// + public int TokenMaxLength { get; } + + /// + /// Whether the vectors hold their tokens. + /// + public bool IncludeTokens { get; } + + /// + /// The stopwords given to the constructor, or null with DefaultStopwords. + /// + public IReadOnlyList? Stopwords => _customStopwords; + + /// + /// The function as a schema declares it, chroma_bm25 with these settings, for + /// ChromaCollectionSchema.WithSparseVectorIndex; the stopwords are in it only when they are not the default ones. + /// + public ChromaEmbeddingFunctionReference Reference + { + get + { + var config = new Dictionary + { + ["k"] = K, + ["b"] = B, + ["avg_doc_length"] = AvgDocLength, + ["token_max_length"] = TokenMaxLength, + ["include_tokens"] = IncludeTokens, + }; + if (_customStopwords is not null) + { + config["stopwords"] = _customStopwords.ToArray(); + } + return ChromaEmbeddingFunctionReference.Known(Name, config); + } + } + + /// + /// The sparse vector of the text, for a document or a query: one index for each distinct token, the absolute value of the + /// MurmurHash3 of the token, in ascending order, and its BM25 weight. An empty vector when no token is left. + /// + /// The text of a document or of a query. + /// The sparse vector of the text. + public ChromaSparseVector Embed(string text) + { + var tokens = new Bm25Tokenizer(new SnowballEnglishStemmer(), _stopwords, TokenMaxLength).Tokenize(text); + if (tokens.Count == 0) + { + return new ChromaSparseVector([], []); + } + var length = (double)tokens.Count; + // Like the Counter of Python: the tokens with the same hash count together, with the first of them as the token. + var counts = new Dictionary(); + foreach (var token in tokens) + { + var hash = Math.Abs((long)MurmurHash3.Hash32(token)); + counts[hash] = counts.TryGetValue(hash, out var entry) ? (entry.Count + 1, entry.Token) : (1, token); + } + var indices = new List(counts.Count); + var values = new List(counts.Count); + var labels = IncludeTokens ? new List(counts.Count) : null; + foreach (var pair in counts.OrderBy(x => x.Key)) + { + // The same operations in the same order as the Python client, in double, then the float the vector holds. + var tf = (double)pair.Value.Count; + var denominator = tf + K * (1 - B + (B * length) / AvgDocLength); + var score = tf * (K + 1) / denominator; + // Up to 2^31, the absolute value of the smallest hash, which the token "1872d942" has. + indices.Add((uint)pair.Key); + values.Add((float)score); + labels?.Add(pair.Value.Token); + } + return new ChromaSparseVector(indices, values, labels); + } + + /// + /// The sparse vectors of the texts, in order. + /// + /// The texts. + /// The sparse vectors of the texts, in order. + public IReadOnlyList Embed(IEnumerable texts) + => texts.Select(Embed).ToList(); +} diff --git a/ChromaDB.Client/ChromaClient.cs b/ChromaDB.Client/ChromaClient.cs index d9ea4cc..0628b3f 100644 --- a/ChromaDB.Client/ChromaClient.cs +++ b/ChromaDB.Client/ChromaClient.cs @@ -1,93 +1,276 @@ -using ChromaDB.Client.Common; +using System.Globalization; +using System.Net; +using ChromaDB.Client.Common; using ChromaDB.Client.Models; using ChromaDB.Client.Models.Requests; namespace ChromaDB.Client; -public class ChromaClient +/// +/// The client of a Chroma server, for its tenants, databases and collections. The records of a collection go through the +/// ChromaCollectionClient that GetCollectionClient returns. +/// +public class ChromaClient : IDisposable { - private readonly HttpClient _httpClient; + private readonly ChromaConfigurationOptions _options; + private readonly ChromaHttpClient _httpClient; + private readonly HttpClient? _ownHttpClient; private readonly ChromaTenant _currentTenant; private readonly ChromaDatabase _currentDatabase; + /// + /// The options of this client, as it was created with them. + /// + public virtual ChromaConfigurationOptions Options => _options; + + /// + /// A client that sends its requests with the given HttpClient, to the server of the options. It adds the credentials + /// of the options to each of its requests, without changing the HttpClient, and Dispose leaves it open. + /// + /// The options of the client: the server, the credentials, the tenant and the database. + /// The HttpClient that sends the requests; the client does not dispose it. public ChromaClient(ChromaConfigurationOptions options, HttpClient httpClient) + : this(options, new ChromaHttpClient(httpClient, options)) + { } + + /// + /// A client of the server at the URI, like http://localhost:8000, with an HttpClient of its own, which + /// Dispose closes. + /// + /// The URI of the server, like http://localhost:8000. + public ChromaClient(string uri) + : this(new ChromaConfigurationOptions(uri)) + { } + + /// + /// A client of the server of the options, with an HttpClient of its own, which Dispose closes. On .NET 8 and later + /// its connections last two minutes, as with AddChromaClient, so a change of the address of the server in the DNS is seen. + /// The clients that WithMetadataValues, WithTenantAndDatabaseFromIdentityAsync and GetCollectionClient return + /// share that HttpClient: they work until this client is disposed. + /// + /// The options of the client: the server, the credentials, the tenant and the database. + public ChromaClient(ChromaConfigurationOptions options) + : this(options, CreateHttpClient(options, out var ownHttpClient)) { - _httpClient = httpClient; - _currentTenant = options.Tenant is not null and not [] - ? new ChromaTenant(options.Tenant) - : ClientConstants.DefaultTenant; - _currentDatabase = options.Database is not null and not [] - ? new ChromaDatabase(options.Database) - : ClientConstants.DefaultDatabase; + _ownHttpClient = ownHttpClient; + } - if (_httpClient.BaseAddress != options.Uri) + /// + /// For a mock in tests, made by a subclass or by a mocking library, as in the Azure SDKs: a client without options and + /// without an HttpClient, whose members are all virtual. It sends no request: only the members it overrides work. + /// + protected ChromaClient() + { + _options = null!; + _httpClient = null!; + _currentTenant = null!; + _currentDatabase = null!; + } + + // An HttpClient of its own, closed when the options are rejected, like basic authentication with a token in the Authorization header. + private static ChromaHttpClient CreateHttpClient(ChromaConfigurationOptions options, out HttpClient httpClient) + { + httpClient = NewHttpClient(); + try { - _httpClient.BaseAddress = options.Uri; + return new ChromaHttpClient(httpClient, options); } - if (options.ChromaToken is not null and not []) + catch { - _httpClient.DefaultRequestHeaders.Remove(ClientConstants.ChromaTokenHeader); - _httpClient.DefaultRequestHeaders.Add(ClientConstants.ChromaTokenHeader, options.ChromaToken); + httpClient.Dispose(); + throw; } } - public async Task> ListCollections(string? tenant = null, string? database = null) + // As the handlers of IHttpClientFactory, renewed every two minutes by default; SocketsHttpHandler is not in .NET Standard 2.0. + private static HttpClient NewHttpClient() +#if NET + => new(new SocketsHttpHandler { PooledConnectionLifetime = TimeSpan.FromMinutes(2) }); +#else + => new(); +#endif + + /// + /// Closes the HttpClient the client created; one given to the constructor stays open. + /// + public void Dispose() { - tenant = tenant is not null and not [] ? tenant : _currentTenant.Name; - database = database is not null and not [] ? database : _currentDatabase.Name; - var requestParams = new RequestQueryParams() - .Insert("{tenant}", tenant) - .Insert("{database}", database); - return await _httpClient.Get>("collections?tenant={tenant}&database={database}", requestParams); + Dispose(true); + GC.SuppressFinalize(this); } - public async Task GetCollection(string name, string? tenant = null, string? database = null) + /// + /// Closes the HttpClient the client created, when disposing. + /// + /// Whether it is called by Dispose(), not by a finalizer. + protected virtual void Dispose(bool disposing) { - tenant = tenant is not null and not [] ? tenant : _currentTenant.Name; - database = database is not null and not [] ? database : _currentDatabase.Name; - var requestParams = new RequestQueryParams() - .Insert("{collectionName}", name) - .Insert("{tenant}", tenant) - .Insert("{database}", database); - return await _httpClient.Get("collections/{collectionName}?tenant={tenant}&database={database}", requestParams); + if (disposing) + { + _ownHttpClient?.Dispose(); + } } - public async Task Heartbeat() + // The span and the duration of each operation, on the server, on a tenant, or in a database: the tenant and the database + // are the ones the request goes to. + private Task ServerOperation(string name, Func> body) + => ChromaInstrumentation.Run(name, null, null, _options.Uri, body); + + private Task TenantOperation(string name, string? tenant, Func> body) + => ChromaInstrumentation.Run(name, null, TenantName(tenant), _options.Uri, body); + + private Task TenantOperation(string name, string? tenant, Func body) + => ChromaInstrumentation.Run(name, null, TenantName(tenant), _options.Uri, body); + + private Task DatabaseOperation(string name, string? collection, string? tenant, string? database, Func> body) + => ChromaInstrumentation.Run(name, collection, $"{TenantName(tenant)}|{DatabaseName(database)}", _options.Uri, body); + + private Task DatabaseOperation(string name, string? collection, string? tenant, string? database, Func body) + => ChromaInstrumentation.Run(name, collection, $"{TenantName(tenant)}|{DatabaseName(database)}", _options.Uri, body); + + // Chroma Cloud creates a collection with a key of the schema beyond its quota of bytes for a metadata key, and then rejects every + // write with the key. + private void CheckKeysOnChromaCloud(ChromaCollectionDefinition definition) { - return await _httpClient.Get("", new RequestQueryParams()); + if (_httpClient.IsChromaCloud && definition.Schema?.Keys.FirstOrDefault(key => System.Text.Encoding.UTF8.GetByteCount(key) > ChromaCloudQuotas.MaxMetadataKeyBytes) is { } key) + { + throw new ArgumentException($"The key \"{key}\" of the schema has more than {ChromaCloudQuotas.MaxMetadataKeyBytes} bytes: Chroma Cloud creates the collection, but then rejects every write with the key.", nameof(definition)); + } } - public async Task CreateCollection(string name, Dictionary? metadata = null, string? tenant = null, string? database = null) + // The message when the collection the server answers with does not have the settings of the definition: another space, as a + // collection that exists keeps its own and Chroma 1.3.0 ignores the one of a schema that turns no index on or off, or an index other + // than the one the settings are for. Chroma 1.0.5 and earlier send no configuration to tell it by. + // As the Python client of Chroma, a new collection with the generator of the embedding function of Chroma declares it, "default". Chroma + // 0.x takes no embedding function: there the collection is created without, as before. + private async Task WithDeclaredEmbeddingFunction(ChromaCollectionDefinition definition, CancellationToken cancellationToken) { - tenant = tenant is not null and not [] ? tenant : _currentTenant.Name; - database = database is not null and not [] ? database : _currentDatabase.Name; - var requestParams = new RequestQueryParams() - .Insert("{tenant}", tenant) - .Insert("{database}", database); - var request = new CreateCollectionRequest() +#if !NETSTANDARD2_0 + var declared = definition.WithDeclaredEmbeddingFunction(); + if (declared != definition && !await _httpClient.IsChroma0(cancellationToken)) { - Name = name, - Metadata = metadata - }; - return await _httpClient.Post("collections?tenant={tenant}&database={database}", request, requestParams); + return declared; + } +#else + await Task.CompletedTask; +#endif + return definition; } - public async Task GetOrCreateCollection(string name, Dictionary? metadata = null, string? tenant = null, string? database = null) + private static string? SettingsIgnored(ChromaCollectionDefinition definition, ChromaCollection collection) { - tenant = tenant is not null and not [] ? tenant : _currentTenant.Name; - database = database is not null and not [] ? database : _currentDatabase.Name; - var requestParams = new RequestQueryParams() - .Insert("{tenant}", tenant) - .Insert("{database}", database); - var request = new GetOrCreateCollectionRequest() + if (definition.Configuration is not { } settings) + { + return null; + } + if (settings.Space is { } space && collection.Space is { } actual && actual != space) + { + return $"The collection has the space {ChromaSpaceNames.ToName(actual)}, not {ChromaSpaceNames.ToName(space)}: a collection that exists keeps its space" + + (definition.SettingsInSchema ? ", and Chroma 1.3.0 ignores the space of a schema that turns no index on or off, which 1.3.2 and later apply." : "."); + } + if (collection.ConfigurationJson is not { ValueKind: System.Text.Json.JsonValueKind.Object } configuration) + { + return null; + } + var hasHnsw = configuration.TryGetProperty("hnsw", out var hnsw) && hnsw.ValueKind == System.Text.Json.JsonValueKind.Object; + var hasSpann = configuration.TryGetProperty("spann", out var spann) && spann.ValueKind == System.Text.Json.JsonValueKind.Object; + if (settings.Hnsw is not null && !hasHnsw && hasSpann) + { + return "The collection has a SPANN index, as on Chroma Cloud, which ignores HNSW settings: set Spann instead."; + } + if (settings.Spann is not null && !hasSpann && hasHnsw) { - Name = name, - Metadata = metadata - }; - return await _httpClient.Post("collections?tenant={tenant}&database={database}", request, requestParams); + return "The collection has an HNSW index, as on a single Chroma server, which ignores SPANN settings: set Hnsw instead."; + } + return null; + } + + private string TenantName(string? tenant) + => tenant is not null and not [] ? tenant : _currentTenant.Name; + + private string DatabaseName(string? database) + => database is not null and not [] ? database : _currentDatabase.Name; + + private ChromaClient(ChromaConfigurationOptions options, ChromaHttpClient httpClient) + { + _options = options; + _httpClient = httpClient; + _currentTenant = options.Tenant is not null and not [] + ? new ChromaTenant(options.Tenant) + : ClientConstants.DefaultTenant; + _currentDatabase = options.Database is not null and not [] + ? new ChromaDatabase(options.Database) + : ClientConstants.DefaultDatabase; } - public async Task DeleteCollection(string name, string? tenant = null, string? database = null) + /// + /// The collections in the tenant and database of the options, or in the ones it is given. + /// + /// The tenant, or null for the one of the options. + /// The database, or null for the one of the options. + /// The token that cancels the operation. + /// The collections. + public virtual Task> ListCollectionsAsync(string? tenant = null, string? database = null, CancellationToken cancellationToken = default) + => DatabaseOperation>("list_collections", null, tenant, database, async () => + { + tenant = tenant is not null and not [] ? tenant : _currentTenant.Name; + database = database is not null and not [] ? database : _currentDatabase.Name; + var requestParams = new RequestQueryParams() + .Insert("{tenant}", tenant) + .Insert("{database}", database); + return await _httpClient.WithSpaces(await _httpClient.Get>(_httpClient.Routes.Collections, requestParams, cancellationToken), cancellationToken); + }); + + /// + /// One page of the collections, in the order of the server. + /// + /// The most collections to return. + /// How many collections to skip first. + /// The tenant, or null for the one of the options. + /// The database, or null for the one of the options. + /// The token that cancels the operation. + /// The collections. + public virtual Task> ListCollectionsAsync(int limit, int offset = 0, string? tenant = null, string? database = null, CancellationToken cancellationToken = default) + => DatabaseOperation>("list_collections", null, tenant, database, async () => + { + tenant = tenant is not null and not [] ? tenant : _currentTenant.Name; + database = database is not null and not [] ? database : _currentDatabase.Name; + var requestParams = new RequestQueryParams() + .Insert("{tenant}", tenant) + .Insert("{database}", database) + .Insert("{limit}", limit.ToString(CultureInfo.InvariantCulture)) + .Insert("{offset}", offset.ToString(CultureInfo.InvariantCulture)); + var route = _httpClient.Routes.Collections; + route += (route.Contains("?") ? "&" : "?") + "limit={limit}&offset={offset}"; + return await _httpClient.WithSpaces(await _httpClient.Get>(route, requestParams, cancellationToken), cancellationToken); + }); + + /// + /// The collection with the given name, in the tenant and database of the options, or in the ones it is given. + /// + /// The name of the collection. + /// The tenant, or null for the one of the options. + /// The database, or null for the one of the options. + /// The token that cancels the operation. + /// The collection. + public virtual Task GetCollectionAsync(string name, string? tenant = null, string? database = null, CancellationToken cancellationToken = default) + => DatabaseOperation("get_collection", name, tenant, database, () => GetCollectionCore(name, tenant, database, cancellationToken)); + + // The collection, or null when it does not exist. + private async Task TryGetCollection(string name, string? tenant, string? database, CancellationToken cancellationToken) + { + try + { + return await GetCollectionCore(name, tenant, database, cancellationToken); + } + catch (ChromaException ex) when (ex.IsMissingCollection) + { + return null; + } + } + + // Without a span of its own: CollectionExists has one, where a missing collection is not an error. + private async Task GetCollectionCore(string name, string? tenant, string? database, CancellationToken cancellationToken) { tenant = tenant is not null and not [] ? tenant : _currentTenant.Name; database = database is not null and not [] ? database : _currentDatabase.Name; @@ -95,26 +278,630 @@ public async Task DeleteCollection(string name, string? tenant = null, string? d .Insert("{collectionName}", name) .Insert("{tenant}", tenant) .Insert("{database}", database); - await _httpClient.Delete("collections/{collectionName}?tenant={tenant}&database={database}", requestParams); + return await _httpClient.WithSpace(await _httpClient.Get(_httpClient.Routes.CollectionByName, requestParams, cancellationToken), cancellationToken); + } + + /// + /// Whether the collection exists. A missing collection: 404 from Chroma 1.x, 400 or 500 from the 0.x + /// servers, always with "does not exist" in the message, also when the tenant or the database is missing. Any other error, + /// like a bare 404 from a wrong address, still throws. + /// + /// The name of the collection. + /// The tenant, or null for the one of the options. + /// The database, or null for the one of the options. + /// The token that cancels the operation. + /// Whether the collection exists. + public virtual Task CollectionExistsAsync(string name, string? tenant = null, string? database = null, CancellationToken cancellationToken = default) + => DatabaseOperation("collection_exists", name, tenant, database, async () => await TryGetCollection(name, tenant, database, cancellationToken) is not null); + + /// + /// The same client, reading metadata values another way: same HttpClient and options, and what it learned about the + /// server. No request is sent, and this client does not change. + /// + /// How the client reads the values of the metadata. + /// The new client; this one does not change. + public virtual ChromaClient WithMetadataValues(ChromaMetadataValues metadataValues) + => new(_options.WithMetadataValues(metadataValues), _httpClient.WithMetadataValues(metadataValues)); + + /// + /// The same client, with the tenant and the database of its credentials, which the server tells in auth/identity, as the + /// CloudClient of the Python client of Chroma does. The tenant is taken unless it is *; the database only when the + /// credentials have exactly one, other than *: on Chroma Cloud an API key for one database gives both, an API key for a + /// whole tenant only the tenant. A tenant or a database of the options other than the default one must match the one the + /// credentials give, when they give one, otherwise it throws a ChromaException; with several databases in the credentials, + /// the one of the options stays as it is, as in the Python client. The same HttpClient and what this client learned about + /// the server are kept, and this client does not change. + /// + /// The token that cancels the operation. + /// The new client; this one does not change. + public virtual async Task WithTenantAndDatabaseFromIdentityAsync(CancellationToken cancellationToken = default) + { + var identity = await GetUserIdentityAsync(cancellationToken); + var tenant = identity.Tenant is { Length: > 0 } and not "*" ? identity.Tenant : null; + var databases = identity.Databases?.Distinct().ToList(); + var database = databases is [{ Length: > 0 } single] && single != "*" ? single : null; + if (tenant is not null && _options.Tenant is { Length: > 0 } givenTenant && givenTenant != ClientConstants.DefaultTenantName && givenTenant != tenant) + { + throw new ChromaException($"The tenant {givenTenant} of the options is not {tenant}, the one of the credentials."); + } + if (database is not null && _options.Database is { Length: > 0 } givenDatabase && givenDatabase != ClientConstants.DefaultDatabaseName && givenDatabase != database) + { + throw new ChromaException($"The database {givenDatabase} of the options is not {database}, the one of the credentials."); + } + var options = tenant is not null ? _options.WithTenant(tenant) : _options; + options = database is not null ? options.WithDatabase(database) : options; + return new(options, _httpClient); } - public async Task GetVersion() + /// + /// A client for the records of the collection, with the options and the HttpClient of this client: no request is sent. + /// + /// The collection, as the server returned it. + /// The client of the records of the collection. + public virtual ChromaCollectionClient GetCollectionClient(ChromaCollection collection) + => new(collection, _options, _httpClient); + + /// + /// A client for the records of the collection with the name, in the tenant and database of the options, whichever collection has + /// that name: it reads the collection before its first request, and again when a request fails on the id it read before. When the + /// name has another id then, as when the collection was deleted and created again elsewhere, the operation runs again, once, on that + /// collection; otherwise the failure stands. No request is sent now. + /// + /// The name of the collection. + /// The client of the records of the collection with the name. + public virtual ChromaCollectionClient GetCollectionClient(string name) + => new(name, _options, _httpClient); + + /// + /// A client for the records of the collection with the name, as GetCollectionClient(name). With createIfMissing, its first + /// operation gets or creates the collection, with one request that the operations that start before it ends share; it keeps the + /// collection, and when a request fails because the collection was deleted elsewhere, it gets or creates it again, empty, and runs + /// the operation again, once, unless part of a write already went. No request is sent now: an error of the creation comes from the + /// first operation, which the next one tries again. GetOrCreateCollectionClientAsync sends it now instead. + /// + /// The name of the collection. + /// Whether the client creates the collection when the name has none; false is GetCollectionClient(name). + /// The client of the records of the collection with the name. + public virtual ChromaCollectionClient GetCollectionClient(string name, bool createIfMissing) + => GetCollectionClient(new ChromaCollectionDefinition(name), createIfMissing); + + /// + /// As GetCollectionClient(name, createIfMissing), for the collection of the definition: with createIfMissing, the + /// collection is created with its metadata, configuration and schema, as GetOrCreateCollectionAsync(definition) does; without + /// it, only the name counts. The client does not take the embedding generator of the definition: give it with + /// WithEmbeddingGenerator. + /// + /// The collection to get or create. + /// Whether the client creates the collection when the name has none. + /// The client of the records of the collection. + public virtual ChromaCollectionClient GetCollectionClient(ChromaCollectionDefinition definition, bool createIfMissing) { - return await _httpClient.Get("version", new RequestQueryParams()); + if (definition is null) + { + throw new ArgumentNullException(nameof(definition)); + } + return createIfMissing + ? new(definition.Name, _options, _httpClient, (httpClient, cancellationToken) => new ChromaClient(_options, httpClient).GetOrCreateCollectionAsync(definition, cancellationToken: cancellationToken)) + : GetCollectionClient(definition.Name); } - public async Task Reset() + /// + /// Gets or creates the collection with the name now, and returns its client: an error of the request comes from this call. Then the + /// client works as the one of GetCollectionClient(name, createIfMissing: true), and creates the collection again if it is + /// deleted elsewhere. + /// + /// The name of the collection. + /// The token that cancels the operation. + /// The client of the records of the collection. + public virtual Task GetOrCreateCollectionClientAsync(string name, CancellationToken cancellationToken = default) + => GetOrCreateCollectionClientAsync(new ChromaCollectionDefinition(name), cancellationToken); + + /// + /// As GetOrCreateCollectionClientAsync(name), for the collection of the definition, created with its metadata, configuration + /// and schema. + /// + /// The collection to get or create. + /// The token that cancels the operation. + /// The client of the records of the collection. + public virtual async Task GetOrCreateCollectionClientAsync(ChromaCollectionDefinition definition, CancellationToken cancellationToken = default) { - return await _httpClient.Post("reset", null, new RequestQueryParams()); + var client = GetCollectionClient(definition, createIfMissing: true); + await client.GetCollectionAsync(cancellationToken); + return client; } - public async Task CountCollections(string? tenant = null, string? database = null) + /// + /// The same as the overload that takes a ChromaCollection, without getting the collection first: the requests on + /// a collection need only its id. + /// + /// The id of the collection. + /// The name of the collection. + /// The client of the records of the collection. + public virtual ChromaCollectionClient GetCollectionClient(Guid collectionId, string collectionName) + => new(new ChromaCollection(collectionName) { Id = collectionId }, _options, _httpClient); + + /// + /// The collection with the given id, in the tenant and database of the options, or in the ones it is given. It needs the v2 API + /// of Chroma 1.5.7 or later: the older servers answer 404 Not Found. + /// + /// The id of the collection. + /// The tenant, or null for the one of the options. + /// The database, or null for the one of the options. + /// The token that cancels the operation. + /// The collection. + public virtual Task GetCollectionByIdAsync(Guid id, string? tenant = null, string? database = null, CancellationToken cancellationToken = default) + => DatabaseOperation("get_collection", null, tenant, database, async () => + { + tenant = tenant is not null and not [] ? tenant : _currentTenant.Name; + database = database is not null and not [] ? database : _currentDatabase.Name; + var requestParams = new RequestQueryParams() + .Insert("{collection_id}", id.ToString()) + .Insert("{tenant}", tenant) + .Insert("{database}", database); + return await _httpClient.WithSpace(await _httpClient.Get(_httpClient.Routes.CollectionById, requestParams, cancellationToken), cancellationToken); + }); + + /// + /// The heartbeat of the server, a time in nanoseconds. + /// + /// The token that cancels the operation. + /// The heartbeat. + public virtual Task HeartbeatAsync(CancellationToken cancellationToken = default) + => ServerOperation("heartbeat", async () => + { + return await _httpClient.Get(_httpClient.Routes.Heartbeat, new RequestQueryParams(), cancellationToken); + }); + + /// + /// Whether the server is ready to serve requests, from Chroma 1.0.0; a server that is not ready answers 503, + /// a ChromaException. + /// + /// The token that cancels the operation. + /// Whether the parts of the server are ready. + public virtual Task HealthcheckAsync(CancellationToken cancellationToken = default) + => ServerOperation("healthcheck", async () => + { + return await _httpClient.Get(_httpClient.Routes.Healthcheck, new RequestQueryParams(), cancellationToken); + }); + + /// + /// A collection by its Chroma Resource Name, <tenant resource name>:<database>:<collection>: + /// Chroma Cloud only, as in the JavaScript client of Chroma. The operation is hidden in the OpenAPI description. On Chroma Cloud + /// this request, sent with an API key limited to one database and with an API key for the whole tenant, got 403, also for + /// a collection of that tenant. + /// + /// The Chroma Resource Name of the collection, <tenant resource name>:<database>:<collection>. + /// The token that cancels the operation. + /// The collection. + public virtual Task GetCollectionByCrnAsync(string crn, CancellationToken cancellationToken = default) + => ServerOperation("get_collection", async () => + { + var requestParams = new RequestQueryParams() + .Insert("{crn}", crn); + return await _httpClient.Get(_httpClient.Routes.CollectionByCrn, requestParams, cancellationToken); + }); + + /// + /// Creates a collection with the given name and metadata, in the tenant and database of the options, or in the ones it is given. + /// + /// The name of the collection. + /// The metadata of the collection, or null for none. + /// The tenant, or null for the one of the options. + /// The database, or null for the one of the options. + /// The token that cancels the operation. + /// The collection created. + public virtual Task CreateCollectionAsync(string name, IReadOnlyDictionary? metadata = null, string? tenant = null, string? database = null, CancellationToken cancellationToken = default) + => CreateCollectionAsync(new ChromaCollectionDefinition(name) { Metadata = metadata }, tenant, database, cancellationToken); + + /// + /// Creates a collection from its definition, with its name, metadata, configuration and schema, in the tenant and database of the + /// options, or in the ones it is given. The space and the HNSW settings go as the hnsw: metadata, the SPANN settings and the + /// embedding function in the configuration of the request. When the server ignores settings it reports, it deletes the collection + /// and throws a ChromaException. + /// + /// The collection: name, metadata, configuration and schema. + /// The tenant, or null for the one of the options. + /// The database, or null for the one of the options. + /// The token that cancels the operation. + /// The collection created. + public virtual Task CreateCollectionAsync(ChromaCollectionDefinition definition, string? tenant = null, string? database = null, CancellationToken cancellationToken = default) + => DatabaseOperation("create_collection", definition.Name, tenant, database, async () => + { + tenant = tenant is not null and not [] ? tenant : _currentTenant.Name; + database = database is not null and not [] ? database : _currentDatabase.Name; + var requestParams = new RequestQueryParams() + .Insert("{tenant}", tenant) + .Insert("{database}", database); + definition.Validate(); + definition = await WithDeclaredEmbeddingFunction(definition.ForServer(_httpClient.IsChromaCloud), cancellationToken); + CheckKeysOnChromaCloud(definition); + // Chroma 0.5.4 to 0.6.3 fail on the configuration of the request, and 0.4.10 to 0.5.3 ignore it. + if (definition.SettingsInConfiguration && await _httpClient.IsChroma0(cancellationToken)) + { + throw new ChromaException("The SPANN settings and the embedding function of a new collection need Chroma 1.0.0 or later: Chroma 0.x does not apply them."); + } + var request = new CreateCollectionRequest() + { + Name = definition.Name, + Metadata = definition.ToRequestMetadata(), + Configuration = definition.ToRequestConfiguration(), + Schema = definition.ToRequestSchema(), + }; + var collection = await _httpClient.Post(_httpClient.Routes.Collections, request, requestParams, cancellationToken); + // Chroma 1.0.0 to 1.2.2 create the collection without the schema and without an error: the collection just created goes. + if (definition.SettingsInSchema && collection.SchemaJson is not { ValueKind: System.Text.Json.JsonValueKind.Object }) + { + // Not canceled with the call: the answer arrived, so the collection is created, and it must go. A call canceled before the + // answer leaves nothing to tell whether the collection was created, so nothing is deleted then. + await DatabaseOperation("delete_collection", collection.Name, tenant, database, () => DeleteCollectionCore(collection.Name, tenant, database, deleteRecordsFirst: false, CancellationToken.None)); + throw new ChromaException("The server creates the collection without its schema: Chroma 1.3.0 and later apply it. The collection was deleted."); + } + // As the deletion, not canceled with the call: the answer arrived, and the space decides whether the collection must go. + await _httpClient.WithSpace(collection, CancellationToken.None); + // Chroma 1.3.0 creates the collection with the space of a schema that turns no index on or off ignored: l2. + if (SettingsIgnored(definition, collection) is { } ignored) + { + await DatabaseOperation("delete_collection", collection.Name, tenant, database, () => DeleteCollectionCore(collection.Name, tenant, database, deleteRecordsFirst: false, CancellationToken.None)); + throw new ChromaException($"{ignored} The collection was deleted."); + } + return collection; + }); + + /// + /// The collection with the given name, created when it does not exist, in the tenant and database of the options, or in + /// the ones it is given. + /// + /// The name of the collection. + /// The metadata of the collection, or null for none. + /// The tenant, or null for the one of the options. + /// The database, or null for the one of the options. + /// The token that cancels the operation. + /// The collection, created or existing. + public virtual Task GetOrCreateCollectionAsync(string name, IReadOnlyDictionary? metadata = null, string? tenant = null, string? database = null, CancellationToken cancellationToken = default) + => GetOrCreateCollectionAsync(new ChromaCollectionDefinition(name) { Metadata = metadata }, tenant, database, cancellationToken); + + /// + /// The collection with the name of the definition, created from the definition when it does not exist, in the tenant and + /// database of the options, or in the ones it is given, with the settings of the configuration as CreateCollectionAsync sends them. + /// + /// The collection: name, metadata, configuration and schema. + /// The tenant, or null for the one of the options. + /// The database, or null for the one of the options. + /// The token that cancels the operation. + /// The collection, created or existing. + public virtual Task GetOrCreateCollectionAsync(ChromaCollectionDefinition definition, string? tenant = null, string? database = null, CancellationToken cancellationToken = default) + => DatabaseOperation("get_or_create_collection", definition.Name, tenant, database, async () => + { + tenant = tenant is not null and not [] ? tenant : _currentTenant.Name; + database = database is not null and not [] ? database : _currentDatabase.Name; + var requestParams = new RequestQueryParams() + .Insert("{tenant}", tenant) + .Insert("{database}", database); + definition.Validate(); + definition = await WithDeclaredEmbeddingFunction(definition.ForServer(_httpClient.IsChromaCloud), cancellationToken); + CheckKeysOnChromaCloud(definition); + // Chroma 0.5.4 to 0.6.3 fail on the configuration of the request, and 0.4.10 to 0.5.3 ignore it. + if (definition.SettingsInConfiguration && await _httpClient.IsChroma0(cancellationToken)) + { + throw new ChromaException("The SPANN settings and the embedding function of a new collection need Chroma 1.0.0 or later: Chroma 0.x does not apply them."); + } + // Chroma 0.4 writes the space of the definition into the metadata of a collection that exists, whose index keeps its own + // space, so that the collection then reports a space it does not use: on the 0.x servers the client reads the collection + // first, and throws before the request when it has another space. + if (definition.Configuration?.Space is not null && await _httpClient.IsChroma0(cancellationToken) + && await TryGetCollection(definition.Name, tenant, database, cancellationToken) is { } existing + && SettingsIgnored(definition, existing) is { } overwritten) + { + throw new ChromaException(overwritten); + } + var request = new GetOrCreateCollectionRequest() + { + Name = definition.Name, + Metadata = definition.ToRequestMetadata(), + Configuration = definition.ToRequestConfiguration(), + Schema = definition.ToRequestSchema(), + }; + var collection = await _httpClient.WithSpace(await _httpClient.Post(_httpClient.Routes.Collections, request, requestParams, cancellationToken), cancellationToken); + // As in CreateCollection, but the collection stays: it may have existed before. + if (definition.SettingsInSchema && collection.SchemaJson is not { ValueKind: System.Text.Json.JsonValueKind.Object }) + { + throw new ChromaException("The server answers without the schema of the collection: Chroma 1.3.0 and later apply it."); + } + if (SettingsIgnored(definition, collection) is { } ignored) + { + throw new ChromaException(ignored); + } + return collection; + }); + + /// + /// Deletes the collection with the given name, in the tenant and database of the options, or in the ones it is given, with one + /// request. The overload with deleteRecordsFirst can delete the records first. + /// + /// The name of the collection. + /// The tenant, or null for the one of the options. + /// The database, or null for the one of the options. + /// The token that cancels the operation. + public virtual Task DeleteCollectionAsync(string name, string? tenant = null, string? database = null, CancellationToken cancellationToken = default) + => DeleteCollectionAsync(name, deleteRecordsFirst: false, tenant, database, cancellationToken); + + /// + /// Deletes the collection with the given name, in the tenant and database of the options, or in the ones it is given. + /// + /// The name of the collection. + /// Whether to delete the records first, in batches, on Chroma 1.x except Chroma Cloud. Chroma 1.5 gives + /// the lists in the metadata of the records of a deleted collection to the next records it stores, in other collections too: pass + /// true when the records have lists in their metadata. It takes about two requests for every 5,461 records, about 370 for a million. + /// If it stops halfway, the collection keeps part of its records or none of them: delete it again. + /// The tenant, or null for the one of the options. + /// The database, or null for the one of the options. + /// The token that cancels the operation. + public virtual Task DeleteCollectionAsync(string name, bool deleteRecordsFirst, string? tenant = null, string? database = null, CancellationToken cancellationToken = default) + => DatabaseOperation("delete_collection", name, tenant, database, () => DeleteCollectionCore(name, tenant, database, deleteRecordsFirst, cancellationToken)); + + /// + /// Deletes the collection with the given name when it exists, as DeleteCollectionAsync does, and tells whether it did. A missing + /// collection is not an error: every server tells it in its own way, which the client recognizes as CollectionExistsAsync does. + /// + /// The name of the collection. + /// The tenant, or null for the one of the options. + /// The database, or null for the one of the options. + /// Whether to delete the records first, as in DeleteCollectionAsync. + /// The token that cancels the operation. + /// Whether the collection existed and was deleted. + public virtual Task DeleteCollectionIfExistsAsync(string name, string? tenant = null, string? database = null, bool deleteRecordsFirst = false, CancellationToken cancellationToken = default) + => DatabaseOperation("delete_collection", name, tenant, database, async () => + { + try + { + await DeleteCollectionCore(name, tenant, database, deleteRecordsFirst, cancellationToken); + return true; + } + catch (ChromaException ex) when (ex.IsMissingCollection) + { + return false; + } + }); + + // Without a span of its own. A collection just created has no records to delete first. + private async Task DeleteCollectionCore(string name, string? tenant, string? database, bool deleteRecordsFirst, CancellationToken cancellationToken) { tenant = tenant is not null and not [] ? tenant : _currentTenant.Name; database = database is not null and not [] ? database : _currentDatabase.Name; var requestParams = new RequestQueryParams() + .Insert("{collectionName}", name) .Insert("{tenant}", tenant) .Insert("{database}", database); - return await _httpClient.Get("count_collections?tenant={tenant}&database={database}", requestParams); + if (deleteRecordsFirst && await KeepsTheListsOfDeletedRecords(cancellationToken)) + { + await new ChromaCollectionClient(await GetCollectionCore(name, tenant, database, cancellationToken), _options, _httpClient).DeleteAllRecords(cancellationToken); + } + await _httpClient.Delete(_httpClient.Routes.CollectionByName, requestParams, cancellationToken); } + + // Works around KD-12 (docs/COMPATIBILITY.md) + // Chroma 1.5 keeps the lists in the metadata of the records of a collection or a database it deletes, and gives them to the next + // records it stores, in any collection and database; deleting the records first deletes their lists. Every Chroma 1.x sends the + // same version, so the records go first on all of them: not on Chroma 0.x, which stores no lists, nor on Chroma Cloud. + private async Task KeepsTheListsOfDeletedRecords(CancellationToken cancellationToken) + => !_httpClient.IsChromaCloud && !await _httpClient.IsChroma0(cancellationToken); + + /// + /// The version of the server. The 0.x servers send their own version; every Chroma 1.x answers 1.0.0. + /// + /// The token that cancels the operation. + /// The version of the server, like 1.5.9. + public virtual Task GetVersionAsync(CancellationToken cancellationToken = default) + => ServerOperation("get_version", async () => + { + return await _httpClient.Get(_httpClient.Routes.Version, new RequestQueryParams(), cancellationToken); + }); + + /// + /// The user the server sees for the credentials of the client, with its tenant and databases. + /// + /// The token that cancels the operation. + /// The identity: the user, the tenant and the databases of the credentials. + public virtual Task GetUserIdentityAsync(CancellationToken cancellationToken = default) + => ServerOperation("get_user_identity", async () => + { + return await _httpClient.Get(_httpClient.Routes.UserIdentity, new RequestQueryParams(), cancellationToken); + }); + + /// + /// The limits of the server from pre-flight-checks, like max_batch_size, the most records a single add, update, + /// upsert or delete can carry. + /// + /// The token that cancels the operation. + /// The limits and the features the server declares. + public virtual Task GetPreFlightChecksAsync(CancellationToken cancellationToken = default) + => ServerOperation("get_pre_flight_checks", async () => + { + return await _httpClient.Get(_httpClient.Routes.PreFlightChecks, new RequestQueryParams(), cancellationToken); + }); + + /// + /// Resets the server and returns its answer. Chroma Cloud does not allow it to an API key. + /// + /// The token that cancels the operation. + /// Whether the server was reset. + public virtual Task ResetAsync(CancellationToken cancellationToken = default) + => ServerOperation("reset", async () => + { + return await _httpClient.Post(_httpClient.Routes.Reset, null, new RequestQueryParams(), cancellationToken); + }); + + /// + /// The number of collections in the tenant and database of the options, or in the ones it is given. + /// + /// The tenant, or null for the one of the options. + /// The database, or null for the one of the options. + /// The token that cancels the operation. + /// The number of collections. + public virtual Task CountCollectionsAsync(string? tenant = null, string? database = null, CancellationToken cancellationToken = default) + => DatabaseOperation("count_collections", null, tenant, database, async () => + { + tenant = tenant is not null and not [] ? tenant : _currentTenant.Name; + database = database is not null and not [] ? database : _currentDatabase.Name; + var requestParams = new RequestQueryParams() + .Insert("{tenant}", tenant) + .Insert("{database}", database); + return await _httpClient.Get(_httpClient.Routes.CollectionsCount, requestParams, cancellationToken); + }); + + /// + /// Creates a tenant with the given name. Chroma Cloud does not allow it to an API key. + /// + /// The name of the tenant. + /// The token that cancels the operation. + public virtual Task CreateTenantAsync(string name, CancellationToken cancellationToken = default) + => TenantOperation("create_tenant", name, async () => + { + var request = new CreateTenantRequest() + { + Name = name, + }; + await _httpClient.Post(_httpClient.Routes.Tenants, request, new RequestQueryParams(), cancellationToken); + }); + + /// + /// The tenant with the given name, with the resource name that UpdateTenantAsync sets. + /// + /// The name of the tenant. + /// The token that cancels the operation. + /// The tenant. + public virtual Task GetTenantAsync(string name, CancellationToken cancellationToken = default) + => TenantOperation("get_tenant", name, async () => + { + var requestParams = new RequestQueryParams() + .Insert("{tenant}", name); + return await _httpClient.Get(_httpClient.Routes.Tenant, requestParams, cancellationToken); + }); + + /// + /// Sets the name of the tenant in the resource names of Chroma Cloud, like the CRN of a collection. + /// + /// The name of the tenant. + /// The resource name of the tenant, the first part of the CRNs of its collections. + /// The token that cancels the operation. + public virtual Task UpdateTenantAsync(string name, string resourceName, CancellationToken cancellationToken = default) + => TenantOperation("update_tenant", name, async () => + { + var requestParams = new RequestQueryParams() + .Insert("{tenant}", name); + var request = new UpdateTenantRequest() + { + ResourceName = resourceName, + }; + await _httpClient.Patch(_httpClient.Routes.Tenant, request, requestParams, cancellationToken); + }); + + /// + /// Creates a database with the given name, in the tenant of the options, or in the one it is given. + /// + /// The name of the database. + /// The tenant, or null for the one of the options. + /// The token that cancels the operation. + public virtual Task CreateDatabaseAsync(string name, string? tenant = null, CancellationToken cancellationToken = default) + => DatabaseOperation("create_database", null, tenant, name, async () => + { + tenant = tenant is not null and not [] ? tenant : _currentTenant.Name; + var requestParams = new RequestQueryParams() + .Insert("{tenant}", tenant); + var request = new CreateDatabaseRequest() + { + Name = name, + }; + await _httpClient.Post(_httpClient.Routes.Databases, request, requestParams, cancellationToken); + }); + + /// + /// The database with the given name, in the tenant of the options, or in the one it is given. + /// + /// The name of the database. + /// The tenant, or null for the one of the options. + /// The token that cancels the operation. + /// The database. + public virtual Task GetDatabaseAsync(string name, string? tenant = null, CancellationToken cancellationToken = default) + => DatabaseOperation("get_database", null, tenant, name, async () => + { + tenant = tenant is not null and not [] ? tenant : _currentTenant.Name; + var requestParams = new RequestQueryParams() + .Insert("{database}", name) + .Insert("{tenant}", tenant); + return await _httpClient.Get(_httpClient.Routes.Database, requestParams, cancellationToken); + }); + + /// + /// The databases in the tenant of the options, or in the one it is given. It needs the v2 API of Chroma 0.6.3 or later: + /// the older servers answer 405 Method Not Allowed. + /// + /// The tenant, or null for the one of the options. + /// The token that cancels the operation. + /// The databases. + public virtual Task> ListDatabasesAsync(string? tenant = null, CancellationToken cancellationToken = default) + => TenantOperation>("list_databases", tenant, async () => + { + tenant = tenant is not null and not [] ? tenant : _currentTenant.Name; + var requestParams = new RequestQueryParams() + .Insert("{tenant}", tenant); + return await _httpClient.Get>(_httpClient.Routes.Databases, requestParams, cancellationToken); + }); + + /// + /// One page of the databases, in the order of the server. + /// + /// The most databases to return. + /// How many databases to skip first. + /// The tenant, or null for the one of the options. + /// The token that cancels the operation. + /// The databases. + public virtual Task> ListDatabasesAsync(int limit, int offset = 0, string? tenant = null, CancellationToken cancellationToken = default) + => TenantOperation>("list_databases", tenant, async () => + { + tenant = tenant is not null and not [] ? tenant : _currentTenant.Name; + var requestParams = new RequestQueryParams() + .Insert("{tenant}", tenant) + .Insert("{limit}", limit.ToString(CultureInfo.InvariantCulture)) + .Insert("{offset}", offset.ToString(CultureInfo.InvariantCulture)); + var route = _httpClient.Routes.Databases; + route += (route.Contains("?") ? "&" : "?") + "limit={limit}&offset={offset}"; + return await _httpClient.Get>(route, requestParams, cancellationToken); + }); + + /// + /// Deletes the database with the given name, in the tenant of the options, or in the one it is given, with one request. It needs + /// the v2 API of Chroma 0.6.3 or later: the older servers answer 405 Method Not Allowed. The overload with + /// deleteRecordsFirst can delete the records of its collections first. + /// + /// The name of the database. + /// The tenant, or null for the one of the options. + /// The token that cancels the operation. + public virtual Task DeleteDatabaseAsync(string name, string? tenant = null, CancellationToken cancellationToken = default) + => DeleteDatabaseAsync(name, deleteRecordsFirst: false, tenant, cancellationToken); + + /// + /// Deletes the database with the given name, in the tenant of the options, or in the one it is given. It needs the v2 API of + /// Chroma 0.6.3 or later: the older servers answer 405 Method Not Allowed. + /// + /// The name of the database. + /// Whether to delete the records of its collections first, as in DeleteCollectionAsync. + /// The tenant, or null for the one of the options. + /// The token that cancels the operation. + public virtual Task DeleteDatabaseAsync(string name, bool deleteRecordsFirst, string? tenant = null, CancellationToken cancellationToken = default) + => DatabaseOperation("delete_database", null, tenant, name, async () => + { + tenant = tenant is not null and not [] ? tenant : _currentTenant.Name; + var requestParams = new RequestQueryParams() + .Insert("{database}", name) + .Insert("{tenant}", tenant); + if (deleteRecordsFirst && await KeepsTheListsOfDeletedRecords(cancellationToken)) + { + foreach (var collection in await _httpClient.Get>(_httpClient.Routes.Collections, requestParams, cancellationToken)) + { + await new ChromaCollectionClient(collection, _options, _httpClient).DeleteAllRecords(cancellationToken); + } + } + await _httpClient.Delete(_httpClient.Routes.Database, requestParams, cancellationToken); + }); } diff --git a/ChromaDB.Client/ChromaCloudQuotas.cs b/ChromaDB.Client/ChromaCloudQuotas.cs new file mode 100644 index 0000000..547c38c --- /dev/null +++ b/ChromaDB.Client/ChromaCloudQuotas.cs @@ -0,0 +1,59 @@ +namespace ChromaDB.Client; + +/// +/// The default quotas of a tenant on Chroma Cloud, as its documentation +/// lists them; a tenant can ask for higher ones. Beyond a quota Chroma Cloud answers 422 with Quota exceeded. A single +/// Chroma server has none of them. +/// +public static class ChromaCloudQuotas +{ + /// + /// The most records in a write, and in the results of a read: the batch size of the client on Chroma Cloud. + /// + public static readonly int MaxRecordsPerRequest = 300; + + /// + /// The most bytes of a value in the metadata of a record, in UTF-8. + /// + public static readonly int MaxMetadataValueBytes = 8182; + + /// + /// The most bytes of a document, in UTF-8. + /// + public static readonly int MaxDocumentBytes = 16384; + + /// + /// The most keys in the metadata of a record. + /// + public static readonly int MaxMetadataKeys = 32; + + /// + /// The most bytes of a metadata key, in UTF-8. + /// + public static readonly int MaxMetadataKeyBytes = 36; + + /// + /// The most predicates in a where filter, nested ones included; the values of $in do not count. + /// + public static readonly int MaxWherePredicates = 8; + + /// + /// The most bytes of the text of a full-text or regular expression filter on the documents, in UTF-8. + /// + public static readonly int MaxDocumentFilterBytes = 256; + + /// + /// The most bytes of the id of a record, in UTF-8. + /// + public static readonly int MaxIdBytes = 128; + + /// + /// The most bytes of the URI of a record, in UTF-8. + /// + public static readonly int MaxUriBytes = 256; + + /// + /// The most dimensions of an embedding. + /// + public static readonly int MaxEmbeddingDimensions = 4096; +} diff --git a/ChromaDB.Client/ChromaCollection.cs b/ChromaDB.Client/ChromaCollection.cs index 790346e..e5b90e7 100644 --- a/ChromaDB.Client/ChromaCollection.cs +++ b/ChromaDB.Client/ChromaCollection.cs @@ -1,24 +1,153 @@ -using System.Text.Json.Serialization; +using System.Text.Json; +using System.Text.Json.Serialization; +using ChromaDB.Client.Common; namespace ChromaDB.Client.Models; +/// +/// A collection of Chroma, as the server describes it. +/// public class ChromaCollection { + /// + /// The id of the collection. + /// [JsonPropertyName("id")] public Guid Id { get; init; } + /// + /// The name of the collection. + /// [JsonPropertyName("name")] public string Name { get; } + /// + /// The metadata of the collection. + /// [JsonPropertyName("metadata")] - public Dictionary? Metadata { get; init; } + public IReadOnlyDictionary? Metadata { get; init; } + /// + /// The tenant of the collection. + /// [JsonPropertyName("tenant")] public string? Tenant { get; init; } + /// + /// The database of the collection. + /// [JsonPropertyName("database")] public string? Database { get; init; } + /// + /// The number of dimensions of the embeddings of the collection, set by the first write: null before it, and from Chroma 0.5.0 + /// and earlier, which do not send it. + /// + [JsonPropertyName("dimension")] + public int? Dimension { get; init; } + + /// + /// The version of the collection, as the server counts it: null from Chroma 0.5.0 and earlier, which do not send it. + /// + [JsonPropertyName("version")] + public int? Version { get; init; } + + /// + /// The position of the collection in the log of the server: null from Chroma 0.5.7 and earlier, which do not send it. + /// + [JsonPropertyName("log_position")] + public long? LogPosition { get; init; } + + /// + /// The configuration as the server sends it: Chroma 0.4.10 to 0.5.3 send none. + /// + [JsonPropertyName("configuration_json")] + public JsonElement? ConfigurationJson { get; init; } + + /// + /// The schema as the server sends it: the indexes of the keys. Chroma 1.2.2 and earlier send none. + /// + [JsonPropertyName("schema")] + public JsonElement? SchemaJson { get; init; } + + /// + /// The sparse vector indexes that the schema enables, like the one for BM25; empty without a schema. + /// + [JsonIgnore] + public IReadOnlyList SparseVectorIndexes + { + get + { + if (SchemaJson is not { ValueKind: JsonValueKind.Object } schema + || !schema.TryGetProperty("keys", out var keys) || keys.ValueKind != JsonValueKind.Object) + { + return []; + } + var indexes = new List(); + foreach (var key in keys.EnumerateObject()) + { + if (key.Value.ValueKind == JsonValueKind.Object + && key.Value.TryGetProperty("sparse_vector", out var sparse) && sparse.ValueKind == JsonValueKind.Object + && sparse.TryGetProperty("sparse_vector_index", out var index) && index.ValueKind == JsonValueKind.Object + && index.TryGetProperty("enabled", out var enabled) && enabled.ValueKind == JsonValueKind.True) + { + var config = index.TryGetProperty("config", out var c) && c.ValueKind == JsonValueKind.Object ? c : default; + var function = config.ValueKind == JsonValueKind.Object && config.TryGetProperty("embedding_function", out var f) ? f : default; + indexes.Add(new ChromaSparseVectorIndex( + key.Name, + StringProperty(config, "source_key"), + config.ValueKind == JsonValueKind.Object && config.TryGetProperty("bm25", out var bm25) && bm25.ValueKind == JsonValueKind.True, + StringProperty(function, "name"), + function.ValueKind == JsonValueKind.Object && function.TryGetProperty("config", out var settings) && settings.ValueKind != JsonValueKind.Null ? settings.Clone() : null, + StringProperty(config, "algorithm") == "max_score" ? ChromaSparseIndexAlgorithm.MaxScore : ChromaSparseIndexAlgorithm.Wand)); + } + } + return indexes; + } + } + + /// + /// The sparse vector index of the schema whose vectors the client computes with BM25 (chroma_bm25) from the text of the source + /// key, like #document or a metadata key: the one a text query of ChromaRank.SparseKnn on that text uses. Null when the + /// schema has none. + /// + /// The key of the text: #document, or a metadata key. + /// The index, or null. + public ChromaSparseVectorIndex? FindBm25Index(string sourceKey) + => SparseVectorIndexes.FirstOrDefault(index => index.SourceKey == sourceKey && index.Bm25Function is not null); + + private static string? StringProperty(JsonElement element, string name) + => element.ValueKind == JsonValueKind.Object && element.TryGetProperty(name, out var value) && value.ValueKind == JsonValueKind.String ? value.GetString() : null; + + /// + /// The distance function of the collection. + /// From the hnsw:space metadata, which every tested Chroma sends back, or from hnsw.space of the configuration, + /// which Chroma 1.0.6 and later send, or from spann.space, which Chroma Cloud sends with hnsw null. + /// On Chroma 0.x, which keeps the space only in that metadata, a collection without it uses l2, the default of Chroma: + /// a collection the client reads from a 0.x server has then L2. + /// Null when none is there on the other servers: Chroma 0.5.4 to 1.0.5 send hnsw_configuration.space, + /// which says l2 also for the collections that use another space. + /// + [JsonIgnore] + public ChromaSpace? Space + => Metadata is not null && Metadata.TryGetValue(ChromaSpaceNames.MetadataKey, out var space) && space is string name + ? ChromaSpaceNames.FromName(name) + : ConfigurationSpace("hnsw") ?? ConfigurationSpace("spann") ?? DefaultSpace; + + // The space of the server for a collection that does not report one: l2 on Chroma 0.x. + internal ChromaSpace? DefaultSpace { get; set; } + + private ChromaSpace? ConfigurationSpace(string index) + => ConfigurationJson is { ValueKind: JsonValueKind.Object } configuration + && configuration.TryGetProperty(index, out var settings) && settings.ValueKind == JsonValueKind.Object + && settings.TryGetProperty("space", out var space) && space.ValueKind == JsonValueKind.String + ? ChromaSpaceNames.FromName(space.GetString()) + : null; + + /// + /// Creates the object for a collection with the given name; it sends no request. + /// + /// The name of the collection. public ChromaCollection(string name) { Name = name; diff --git a/ChromaDB.Client/ChromaCollectionClient.cs b/ChromaDB.Client/ChromaCollectionClient.cs index 60ee381..1bddbb6 100644 --- a/ChromaDB.Client/ChromaCollectionClient.cs +++ b/ChromaDB.Client/ChromaCollectionClient.cs @@ -2,149 +2,2263 @@ using ChromaDB.Client.Models; using ChromaDB.Client.Models.Requests; using ChromaDB.Client.Models.Responses; +#if !NETSTANDARD2_0 +using Microsoft.Extensions.AI; +#endif namespace ChromaDB.Client; +/// +/// The client for the records of a collection, and for the operations on the collection itself, like CountAsync, +/// ModifyAsync and ForkAsync. +/// public class ChromaCollectionClient { - private readonly ChromaCollection _collection; - private readonly HttpClient _httpClient; + // For a client made by name, the collection read last, which changes when the name has another id. + private volatile ChromaCollection _collection; + private readonly ChromaHttpClient _httpClient; + private readonly bool _byName; + private volatile bool _resolved; + // A client of GetCollectionClient(name, createIfMissing: true): gets or creates the collection instead of reading it, with the + // ChromaHttpClient of this client, so with its way of reading metadata values. + private readonly Func>? _getOrCreate; + // The first read of a client made by name, which the operations that start before it ends share. + private Task? _firstRead; + private readonly object _firstReadLock = new(); + // The first read in flight, or null: only the tests read it, to wait for it. + internal Task? FirstRead + { + get + { + lock (_firstReadLock) + { + return _firstRead; + } + } + } + private readonly string _tenant; + private readonly string _database; + private readonly Uri _server; + private readonly string? _documentCopyKey; + private readonly string? _documentDeletionKey; + private readonly bool _emptyDocumentDeletions; +#if !NETSTANDARD2_0 + private readonly IEmbeddingGenerator>? _embeddingGenerator; +#endif + + /// + /// Creates a client for the records of the collection, which sends its requests with the options and the + /// HttpClient. The requests go to the tenant and database of the collection, or else to those of the + /// options. + /// + /// The collection, as the server returned it. + /// The options of the client: the server, the credentials, the tenant and the database. + /// The HttpClient that sends the requests; the client does not dispose it. public ChromaCollectionClient(ChromaCollection collection, ChromaConfigurationOptions options, HttpClient httpClient) + : this(collection, options, new ChromaHttpClient(httpClient, options)) + { } + + // Shares the ChromaHttpClient of a ChromaClient, with what it keeps, like the version of the server. + internal ChromaCollectionClient(ChromaCollection collection, ChromaConfigurationOptions options, ChromaHttpClient httpClient) { _collection = collection; _httpClient = httpClient; + _server = options.Uri; + // An empty tenant or database means the default, as in ChromaClient. + _tenant = collection.Tenant is not null and not [] ? collection.Tenant + : options.Tenant is not null and not [] ? options.Tenant + : ClientConstants.DefaultTenantName; + _database = collection.Database is not null and not [] ? collection.Database + : options.Database is not null and not [] ? options.Database + : ClientConstants.DefaultDatabaseName; + } + + // The client of the collection with the name, whichever it is: ChromaClient.GetCollectionClient(name). + internal ChromaCollectionClient(string name, ChromaConfigurationOptions options, ChromaHttpClient httpClient) + : this(new ChromaCollection(name), options, httpClient) + { + _byName = true; + } + + // The client of the collection with the name, which gets or creates it: ChromaClient.GetCollectionClient(name, createIfMissing: true). + internal ChromaCollectionClient(string name, ChromaConfigurationOptions options, ChromaHttpClient httpClient, Func> getOrCreate) + : this(name, options, httpClient) + { + _getOrCreate = getOrCreate; + } + + // The same client with another way of reading metadata values, a document copy key, a document deletion key or empty document + // deletions; a client made by name starts from the collection read last, and reads it again by itself. + private ChromaCollectionClient(ChromaCollectionClient client, ChromaHttpClient httpClient, string? documentCopyKey, string? documentDeletionKey, bool emptyDocumentDeletions) + { + _collection = client._collection; + _httpClient = httpClient; + _byName = client._byName; + _resolved = client._resolved; + _getOrCreate = client._getOrCreate; + _tenant = client._tenant; + _database = client._database; + _server = client._server; + _documentCopyKey = documentCopyKey; + _documentDeletionKey = documentDeletionKey; + _emptyDocumentDeletions = emptyDocumentDeletions; +#if !NETSTANDARD2_0 + _embeddingGenerator = client._embeddingGenerator; +#endif + } + +#if !NETSTANDARD2_0 + // The same client with the embedding generator. + private ChromaCollectionClient(ChromaCollectionClient client, IEmbeddingGenerator> embeddingGenerator) + : this(client, client._httpClient, client._documentCopyKey, client._documentDeletionKey, client._emptyDocumentDeletions) + { + _embeddingGenerator = embeddingGenerator; + } +#endif + + /// + /// Creates a client without getting the collection first: the requests on a collection need only its id, and the + /// tenant and database of the options. + /// + /// The id of the collection. + /// The name of the collection. + /// The options of the client: the server, the credentials, the tenant and the database. + /// The HttpClient that sends the requests; the client does not dispose it. + public ChromaCollectionClient(Guid collectionId, string collectionName, ChromaConfigurationOptions options, HttpClient httpClient) + : this(new ChromaCollection(collectionName) { Id = collectionId }, options, httpClient) + { } + + /// + /// For a mock in tests, made by a subclass or by a mocking library, as in the Azure SDKs: a client without a collection and + /// without an HttpClient, whose members are all virtual. It sends no request: only the members it overrides work. + /// + protected ChromaCollectionClient() + { + _collection = null!; + _httpClient = null!; + _tenant = null!; + _database = null!; + _server = null!; + } + + // The span and the duration of each operation on the collection. + private Task Operation(string name, CancellationToken cancellationToken, Func> body) + => ChromaInstrumentation.Run(name, _collection.Name, $"{_tenant}|{_database}", _server, _byName ? () => ByName(body, cancellationToken) : body); + + private Task Operation(string name, CancellationToken cancellationToken, Func body) + => ChromaInstrumentation.Run(name, _collection.Name, $"{_tenant}|{_database}", _server, _byName ? () => ByName(async () => { await body(); return true; }, cancellationToken) : body); + + // A read changes nothing, so after an error that can pass it goes again, with ReadRetries: Chroma 1.5.9 answers 500 "Error finding id" + // or "Error creating hnsw segment reader" to a get with embeddings while other clients write and delete the same records, and the + // same get right after goes. Before each new attempt it waits a random time up to the base delay times 2 to the attempts that went + // again before, and up to the largest delay: the full jitter of AWS, so that clients that failed together do not come back together. + // An answer whose lists do not have one item for every record goes again too (AnswerChecks). A missing collection is not an error + // that passes, and a write never goes again: one that failed may have gone. A read that went before, as the query of KD-24, counts + // its attempts on from them, after the delay of the last one. + private async Task Read(Func> read, CancellationToken cancellationToken, Func? handledElsewhere = null, int attemptsBefore = 0) + { + var retries = _httpClient.ReadRetries; + if (attemptsBefore > 0) + { + await Task.Delay(retries.Delay(attemptsBefore - 1, retries.Jitter?.Invoke() ?? Jitter()), cancellationToken); + } + for (var attempt = attemptsBefore + 1; ; attempt++) + { + try + { + return await read(); + } + catch (ChromaException ex) when (attempt < retries.MaxAttempts && CanPass(ex) && handledElsewhere?.Invoke(ex) != true) + { + await Task.Delay(retries.Delay(attempt - 1, retries.Jitter?.Invoke() ?? Jitter()), cancellationToken); + } + } + } + + private static bool CanPass(ChromaException ex) + => (int?)ex.StatusCode is 429 or 500 or 502 or 503 or 504 && !ex.IsMissingCollection + || ex.StatusCode is null && ex.InnerException is HttpRequestException or IOException + || ex.UnevenAnswer; + + private static readonly Random JitterSource = new(); + + private static double Jitter() + { + lock (JitterSource) + { + return JitterSource.NextDouble(); + } + } + + // A client made by name reads the collection before its first request. When a request fails on the id it read before, it reads the + // collection again: if the name has another id now, as when the collection was deleted and created again elsewhere, the operation + // runs again, once, on that collection; otherwise the failure stands. The servers tell a collection gone in their own ways, like + // Chroma 0.4 with "coroutine raised StopIteration", so the client compares the ids instead. + private async Task ByName(Func> body, CancellationToken cancellationToken) + { + var resolved = _resolved; + if (!resolved) + { + await ResolveOnce(cancellationToken); + } + try + { + return await body(); + } + catch (ChromaException ex) when (resolved && !ex.PartlyDone) + { + var id = _collection.Id; + if (!await TryResolve(cancellationToken) || _collection.Id == id) + { + throw; + } + } + return await body(); + } - if (_httpClient.BaseAddress != options.Uri) + // Reads the collection again; when the name has no collection either, the failure of the operation stands. + private async Task TryResolve(CancellationToken cancellationToken) + { + try + { + await Resolve(cancellationToken); + return true; + } + catch (ChromaException) { - _httpClient.BaseAddress = options.Uri; + return false; } } - public ChromaCollection Collection => _collection; + // The first read is one request, which the operations that start before it ends wait for, each with its own cancellation: it goes on + // without the token of any of them. When it fails, each of them throws, and the next operation reads again, also when every one of + // them was cancelled before: a failed read is never waited for again. + private Task ResolveOnce(CancellationToken cancellationToken) + { + // An operation cancelled before it starts sends nothing, also not the creation of the collection. + cancellationToken.ThrowIfCancellationRequested(); + Task read; + lock (_firstReadLock) + { + if (_firstRead is { IsFaulted: true } or { IsCanceled: true }) + { + _firstRead = null; + } + read = _firstRead ??= Resolve(CancellationToken.None); + } + return WaitWithCancellation(read, cancellationToken); + } - public async Task Get(string id, ChromaWhereOperator? where = null, ChromaWhereDocumentOperator? whereDocument = null, ChromaGetInclude? include = null) - => (await Get([id], where: where, whereDocument: whereDocument, include: include)).FirstOrDefault(); + private static async Task WaitWithCancellation(Task task, CancellationToken cancellationToken) + { +#if NET8_0_OR_GREATER + await task.WaitAsync(cancellationToken); +#else + if (!cancellationToken.CanBeCanceled || task.IsCompleted) + { + await task; + return; + } + var cancelled = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + using (cancellationToken.Register(() => cancelled.TrySetCanceled(cancellationToken))) + { + await await Task.WhenAny(task, cancelled.Task); + } +#endif + } - public async Task> Get(List? ids = null, ChromaWhereOperator? where = null, ChromaWhereDocumentOperator? whereDocument = null, int? limit = null, int? offset = null, ChromaGetInclude? include = null) + private async Task Resolve(CancellationToken cancellationToken) { + if (_getOrCreate is { } getOrCreate) + { + _collection = await getOrCreate(_httpClient, cancellationToken); + _resolved = true; + return; + } var requestParams = new RequestQueryParams() + .Insert("{collectionName}", _collection.Name) + .Insert("{tenant}", _tenant) + .Insert("{database}", _database); + _collection = await _httpClient.WithSpace(await _httpClient.Get(_httpClient.Routes.CollectionByName, requestParams, cancellationToken), cancellationToken); + _resolved = true; + } + + /// + /// The collection the client works on. For a client made by name, with ChromaClient.GetCollectionClient(name), the + /// collection read last, or one with the name only before the first request. + /// + public virtual ChromaCollection Collection => _collection; + + /// + /// The collection the client works on, as Collection. A client made by name reads it on the first call or request, and + /// again when a request fails on the id it read before; the other clients send no request. + /// + /// The token that cancels the operation. + /// The collection. + public virtual Task GetCollectionAsync(CancellationToken cancellationToken = default) + => _byName ? Operation("get_collection", cancellationToken, () => Task.FromResult(_collection)) : Task.FromResult(_collection); + + /// + /// A client of the same collection that copies each document into the metadata key, so that a where filter can compare + /// the whole text, which where_document cannot; the copy replaces what the metadata has under the key. Chroma has no deletion + /// of a document: for a null document with ChromaRecords.NullDocumentsDelete the client writes an empty one and deletes its + /// copy, and it reads an empty document without its copy as null, so that a document comes back as it was written, empty or null. + /// For that it reads the metadata with the documents, and leaves it out of the results that do not ask for it. It deletes a document + /// without reading the record first, also of a record without a document, which a where_document filter that matches an empty + /// text then sees as one with an empty document. Chroma Cloud takes a metadata value of at most 8,182 bytes: there a longer document + /// goes without its copy, and an update or an upsert deletes the copy it had. + /// A collection client chooses once how it deletes documents: the same call again on a client that made it changes nothing, and a call + /// that would change the choice throws an InvalidOperationException. + /// + /// The metadata key of the copies. + /// The client of the same collection, with the copies. + public virtual ChromaCollectionClient WithDocumentCopyKey(string documentCopyKey) + { + if (documentCopyKey is null) + { + throw new ArgumentNullException(nameof(documentCopyKey)); + } + CheckWayToDeleteDocuments(documentCopyKey, null, false); + return new(this, _httpClient, documentCopyKey, null, false); + } + + /// + /// A client of the same collection that marks a document deleted with ChromaRecords.NullDocumentsDelete under the metadata + /// key, instead of copying every document. Chroma has no deletion of a document: for a null document with + /// NullDocumentsDelete the client writes an empty one and true under the key, and any other document that an update or an + /// upsert writes deletes the mark with a null, so no write reads the records first. It reads an empty document with the mark as null, + /// and an empty one without it as empty; the mark stays in the metadata like any key. For that it reads the metadata with the + /// documents, and leaves it out of the results that do not ask for it. It deletes a document also of a record without a document, + /// which a where_document filter that matches an empty text then sees as one with an empty document. On Chroma Cloud the mark + /// and its null count against the quota of 32 keys. + /// A collection client chooses once how it deletes documents: the same call again on a client that made it changes nothing, and a call + /// that would change the choice throws an InvalidOperationException. + /// + /// The metadata key of the mark. + /// The client of the same collection, with the marks. + public virtual ChromaCollectionClient WithDocumentDeletionKey(string documentDeletionKey) + { + if (documentDeletionKey is null) + { + throw new ArgumentNullException(nameof(documentDeletionKey)); + } + CheckWayToDeleteDocuments(null, documentDeletionKey, false); + return new(this, _httpClient, null, documentDeletionKey, false); + } + + /// + /// A client of the same collection that deletes a document with ChromaRecords.NullDocumentsDelete by writing an empty one, + /// without a copy or a mark: Chroma has no deletion of a document, and the empty document reads back as an empty string, as the + /// Python client of Chroma reads it. No write reads the records first. It deletes a document also of a record without a document, + /// which a where_document filter that matches an empty text then sees as one with an empty document. + /// A collection client chooses once how it deletes documents: the same call again on a client that made it changes nothing, and a call + /// that would change the choice throws an InvalidOperationException. + /// + /// The client of the same collection, with the empty documents. + public virtual ChromaCollectionClient WithEmptyDocumentDeletions() + { + CheckWayToDeleteDocuments(null, null, true); + return new(this, _httpClient, null, null, true); + } + + // A collection client chooses once how it deletes documents: the same call again changes nothing, while a call of another of + // WithDocumentCopyKey, WithDocumentDeletionKey and WithEmptyDocumentDeletions, or of the same one with another key, throws. + private void CheckWayToDeleteDocuments(string? copyKey, string? deletionKey, bool empty) + { + if (Way(_documentCopyKey, _documentDeletionKey, _emptyDocumentDeletions) is { } way + && (copyKey != _documentCopyKey || deletionKey != _documentDeletionKey || empty != _emptyDocumentDeletions)) + { + throw new InvalidOperationException($"This collection client already deletes documents with {way}, and a collection client does not change that: call {Way(copyKey, deletionKey, empty)} on a new collection client of the collection."); + } + } + + // The way a client deletes documents, as the call that chose it; null for none. + private static string? Way(string? copyKey, string? deletionKey, bool empty) + => copyKey is not null ? $"WithDocumentCopyKey(\"{copyKey}\")" + : deletionKey is not null ? $"WithDocumentDeletionKey(\"{deletionKey}\")" + : empty ? "WithEmptyDocumentDeletions()" + : null; + + /// + /// The BM25 index on the text of the source key, as ChromaCollection.FindBm25Index finds it in the schema of the collection; + /// for the document copy key, also the one on the documents, which hold the whole text. Null when the collection has none. + /// + /// The key of the text: a metadata key, or ChromaSearchKeys.Document. + /// The token that cancels the operation. + /// The index, or null. + public virtual async Task FindBm25IndexAsync(string sourceKey, CancellationToken cancellationToken = default) + { + var collection = await GetCollectionAsync(cancellationToken); + return collection.FindBm25Index(sourceKey) ?? (sourceKey == _documentCopyKey ? collection.FindBm25Index(ChromaSearchKeys.Document) : null); + } + + /// + /// A client of the same collection that reads metadata values the given way, whatever the client it comes from: same + /// HttpClient and options. + /// + /// How the client reads metadata values. + /// The client of the same collection, reading metadata values that way. + public virtual ChromaCollectionClient WithMetadataValues(ChromaMetadataValues metadataValues) + => new(this, _httpClient.WithMetadataValues(metadataValues), _documentCopyKey, _documentDeletionKey, _emptyDocumentDeletions); + +#if !NETSTANDARD2_0 + /// + /// A client of the same collection that computes the embeddings with the generator, as an embedding function does in the Python client + /// of Chroma. AddAsync, UpdateAsync and UpsertAsync compute the embeddings of records given without them from + /// their documents, and QueryAsync with a text and QueryTextsAsync compute those of the query texts. Records given with + /// embeddings keep them. The builds for .NET 8 and .NET Framework have this method; the one for .NET Standard 2.0 does not. + /// + /// The generator of the embeddings; the client does not dispose it. + /// The client of the same collection, with the generator. + public virtual ChromaCollectionClient WithEmbeddingGenerator(IEmbeddingGenerator> embeddingGenerator) + { + if (embeddingGenerator is null) + { + throw new ArgumentNullException(nameof(embeddingGenerator)); + } + return new(this, embeddingGenerator); + } + + /// + /// The generator of the embeddings of the client, from WithEmbeddingGenerator, or null for none. + /// + public virtual IEmbeddingGenerator>? EmbeddingGenerator => _embeddingGenerator; +#endif + + // The metadata key that tells a deleted document from an empty one: the document copy key, or the document deletion key. + private string? DocumentReadKey => _documentCopyKey ?? _documentDeletionKey; + + // With a document copy key or a document deletion key, the documents are read with the metadata, which tells a deleted document + // from an empty one. + private DocumentReader ReadsDocuments(bool documents, bool metadata) + => !documents ? default + : _documentCopyKey is { } copyKey ? new DocumentReader(copyKey, mark: false, metadata) + : _documentDeletionKey is { } deletionKey ? new DocumentReader(deletionKey, mark: true, metadata) + : default; + + /// + /// Gets the record with the id, or null when the server returns none. Without include, the metadata and the + /// document are included. + /// + /// The id of the record. + /// The filter on the metadata, or null for none. + /// The filter on the documents, or null for none. + /// What the results include, or null for the default. + /// The token that cancels the operation. + /// The record, or null when the server returns none. + public virtual async Task GetAsync(string id, ChromaWhereOperator? where = null, ChromaWhereDocumentOperator? whereDocument = null, ChromaGetInclude? include = null, CancellationToken cancellationToken = default) + => (await GetAsync([id], where: where, whereDocument: whereDocument, include: include, cancellationToken: cancellationToken)).FirstOrDefault(); + + /// + /// Gets the records selected by the ids and the filters, a page at a time with limit and offset. Without + /// include, the metadatas and the documents are included. By default, unless WithBatchSplitting(false), more + /// records than the batch size are read in pages, ids beyond it in batches: Chroma Cloud answers at most 300 records, without + /// an error. + /// + /// The ids of the records, or null for all the records the filters select. + /// The filter on the metadata, or null for none. + /// The filter on the documents, or null for none. + /// The most records to return, or null for all. + /// How many records to skip first, or null for none. + /// What the results include, or null for the default. + /// The token that cancels the operation. + /// The records. + public virtual Task> GetAsync(IReadOnlyList? ids = null, ChromaWhereOperator? where = null, ChromaWhereDocumentOperator? whereDocument = null, int? limit = null, int? offset = null, ChromaGetInclude? include = null, CancellationToken cancellationToken = default) + { + // As SearchAsync and ChromaQuery.Offset, whatever the number of ids, before the collection of a client made by name is read. + if (limit < 0 || offset < 0) + { + throw new ArgumentOutOfRangeException(limit < 0 ? nameof(limit) : nameof(offset), "The limit and the offset of a get cannot be negative."); + } + CheckDocumentFilters(() => ChromaWhereOperator.Split(where, whereDocument, ids).WhereDocument?.ToRequestWhereDocument(), document: true, nameof(whereDocument)); + return WithRegexQuota(() => [(ChromaWhereOperator.Split(where, whereDocument, ids).WhereDocument?.ToRequestWhereDocument(), true)], Operation>("get", cancellationToken, async () => + { + // Works around KD-13 (docs/COMPATIBILITY.md) + // No get for a limit of 0, after a client made by name has read its collection, as with None: one that does not exist throws. + if (limit == 0) + { + return []; + } + var split = ChromaWhereOperator.Split(where, whereDocument, ids); + return await GetEntries(split.Ids, split.Where, split.WhereDocument, limit, offset, include, cancellationToken); + })); + } + + private async Task> GetEntries(IReadOnlyList? ids, ChromaWhereOperator? where, ChromaWhereDocumentOperator? whereDocument, int? limit, int? offset, ChromaGetInclude? include, CancellationToken cancellationToken) + { + if (where == ChromaWhereOperator.None) + { + return []; + } + // With batch splitting, on by default, more records than the batch size come in pages: Chroma Cloud answers at most 300 records, without + // an error. The ids go in batches, each read whole, and the limit and the offset apply to all of them together; without ids + // beyond the batch size, pages of the batch size follow the offset until the limit or a page that is not full. + if (!_httpClient.BatchSplitting || await BatchSize(cancellationToken) is not { } size + || ids is { } few && few.Count <= size || ids is null && limit <= size) + { + // With ids, no more records than the ids: a limit beyond them, like 1000, goes over the quota of 300 of Chroma Cloud. + var single = ids is { Count: > 0 } && limit > ids.Count ? ids.Count : limit; + try + { + return await GetPage(ids, where, whereDocument, single, offset, include, cancellationToken); + } + // Chroma Cloud rejects a get beyond its quota, "'Limit value'" for the limit and, with ids and no limit, "'Number of records'" + // for them: the client keeps the limit and reads in pages, or the ids in batches. + catch (ChromaException ex) when (_httpClient.BatchSplitting && (LimitQuota(ex) ?? RecordsQuota(ex)) is { } quota && quota < (single ?? ids?.Count)) + { + _httpClient.LearnRecordsLimit(quota); + return await GetEntries(ids, where, whereDocument, limit, offset, include, cancellationToken); + } + } + var entries = new List(); + if (ids is not null) + { + // An id given twice would come back twice, from two batches: one request gives it once. + var unique = ids.Distinct().ToList(); + for (var i = 0; i < unique.Count;) + { + var batch = unique.Skip(i).Take(size).ToList(); + try + { + entries.AddRange(await GetPage(batch, where, whereDocument, null, null, include, cancellationToken)); + i += batch.Count; + } + // A batch beyond the quota of Chroma Cloud: the client keeps the limit and reads that batch again, smaller; the batches read stay. + catch (ChromaException ex) when ((LimitQuota(ex) ?? RecordsQuota(ex)) is { } quota && quota < batch.Count) + { + _httpClient.LearnRecordsLimit(quota); + size = quota; + } + } + return entries.Skip(offset ?? 0).Take(limit ?? int.MaxValue).ToList(); + } + var start = offset ?? 0; + while (limit is null || entries.Count < limit) + { + var take = limit is { } total ? Math.Min(size, total - entries.Count) : size; + List page; + try + { + page = await GetPage(null, where, whereDocument, take, start, include, cancellationToken); + } + catch (ChromaException ex) when (LimitQuota(ex) is { } quota && quota < take) + { + _httpClient.LearnRecordsLimit(quota); + size = quota; + continue; + } + entries.AddRange(page); + if (page.Count < take) + { + break; + } + start += take; + } + return entries; + } + + /// + /// Deletes every record of the collection, and returns how many it deleted. Chroma rejects a delete without ids or filters, so the + /// client reads a page of ids from the start, as many as the batch size of the server and of Chroma Cloud, deletes them, and goes on + /// until none is left. The collection stays. If the server returns the records of a page after their delete, the client throws a + /// ChromaException. + /// + /// The token that cancels the operation. + /// How many records it deleted. + public virtual Task DeleteAllAsync(CancellationToken cancellationToken = default) + => Operation("delete", cancellationToken, () => DeleteAllRecords(cancellationToken)); + + // Deletes the records a page of ids at a time, a batch each, until none is left, and counts them: DeleteAllAsync, and ChromaClient + // before it deletes the collection. A page that comes back the same was not deleted, and stops it. An error after a page that went + // says so, and a collection client made by name does not run it again on a collection created again under the name. + internal async Task DeleteAllRecords(CancellationToken cancellationToken) + { + var requestParams = new RequestQueryParams() + .Insert("{tenant}", _tenant) + .Insert("{database}", _database) .Insert("{collection_id}", _collection.Id); + var size = await BatchSize(cancellationToken); + List deleted = []; + var count = 0; + try + { + while (true) + { + List ids; + try + { + ids = (await GetPage(null, null, null, size, null, ChromaGetInclude.None, cancellationToken)).Select(entry => entry.Id).ToList(); + } + catch (ChromaException ex) when (LimitQuota(ex) is { } quota && quota < size) + { + _httpClient.LearnRecordsLimit(quota); + size = quota; + continue; + } + if (ids.Count == 0) + { + return count; + } + if (ids.SequenceEqual(deleted)) + { + throw new ChromaException($"The records were not deleted: the server returns them after their delete, like {ids[0]}. The collection stays with them."); + } + // Chroma Cloud rejects a delete beyond its quota of records, which can be lower than the one of the reads, before it deletes + // any of them: the client keeps that limit and reads a smaller page. + try + { + await _httpClient.Post(_httpClient.Routes.Collection + "/delete", new CollectionDeleteRequest() { Ids = ids }, requestParams, cancellationToken); + } + catch (ChromaException ex) when (RecordsQuota(ex) is { } quota && quota < ids.Count) + { + _httpClient.LearnRecordsLimit(quota); + size = quota; + continue; + } + count += ids.Count; + deleted = ids; + } + } + catch (ChromaException ex) when (count > 0) + { + throw new ChromaException($"{count} {(count == 1 ? "record was" : "records were")} deleted before the error: {ex.Message}", ex) + { + StatusCode = ex.StatusCode, + ErrorType = ex.ErrorType, + PartlyDone = true, + }; + } + } + + private async Task> GetPage(IReadOnlyList? ids, ChromaWhereOperator? where, ChromaWhereDocumentOperator? whereDocument, int? limit, int? offset, ChromaGetInclude? include, CancellationToken cancellationToken) + { + var requestParams = new RequestQueryParams() + .Insert("{tenant}", _tenant) + .Insert("{database}", _database) + .Insert("{collection_id}", _collection.Id); + var asked = include ?? ChromaGetInclude.Metadatas | ChromaGetInclude.Documents; + var copy = ReadsDocuments(asked.HasFlag(ChromaGetInclude.Documents), asked.HasFlag(ChromaGetInclude.Metadatas)); var request = new CollectionGetRequest() { Ids = ids, - Where = where?.ToWhere(), - WhereDocument = whereDocument?.ToWhereDocument(), + Where = ChromaWhereOperator.ToRequestWhere(where), + WhereDocument = whereDocument?.ToRequestWhereDocument(), Limit = limit, Offset = offset, - Include = (include ?? ChromaGetInclude.Metadatas | ChromaGetInclude.Documents).ToInclude(), + Include = (copy.ReadsMetadata ? asked | ChromaGetInclude.Metadatas : asked).ToInclude(), }; - var response = await _httpClient.Post("collections/{collection_id}/get", request, requestParams); - return response.Map() ?? []; + var response = await Read(async () => (await _httpClient.Post(_httpClient.Routes.Collection + "/get", request, requestParams, cancellationToken)).Checked("get"), cancellationToken); + return response.Map(copy) ?? []; } - public async Task> Query(ReadOnlyMemory queryEmbeddings, int nResults = 10, ChromaWhereOperator? where = null, ChromaWhereDocumentOperator? whereDocument = null, ChromaQueryInclude? include = null) - => (await Query([queryEmbeddings], nResults: nResults, where: where, whereDocument: whereDocument, include: include)).FirstOrDefault() ?? []; + /// + /// Searches the nResults records nearest to the query embedding, among the ones with ids when given, as + /// ChromaQuery.Ids. Without include, the metadatas, the documents and the distances are included. + /// + /// The query embedding. + /// How many nearest records each query embedding returns. + /// The filter on the metadata, or null for none. + /// The filter on the documents, or null for none. + /// What the results include, or null for the default. + /// The ids of the records to search among, or null for all. + /// The token that cancels the operation. + /// The nearest records, nearest first. + public virtual async Task> QueryAsync(ReadOnlyMemory queryEmbeddings, int nResults = 10, ChromaWhereOperator? where = null, ChromaWhereDocumentOperator? whereDocument = null, ChromaQueryInclude? include = null, IReadOnlyList? ids = null, CancellationToken cancellationToken = default) + => (await QueryAsync([queryEmbeddings], nResults: nResults, where: where, whereDocument: whereDocument, include: include, ids: ids, cancellationToken: cancellationToken)).FirstOrDefault() ?? []; - public async Task>> Query(List> queryEmbeddings, int nResults = 10, ChromaWhereOperator? where = null, ChromaWhereDocumentOperator? whereDocument = null, ChromaQueryInclude? include = null) + /// + /// Searches the nResults records nearest to each query embedding, among the ones with ids when given, as + /// ChromaQuery.Ids, and returns one list of results per embedding. Without include, the metadatas, the + /// documents and the distances are included. + /// + /// The query embeddings: the results are one list for each. + /// How many nearest records each query embedding returns. + /// The filter on the metadata, or null for none. + /// The filter on the documents, or null for none. + /// What the results include, or null for the default. + /// The ids of the records to search among, or null for all. + /// The token that cancels the operation. + /// For each query embedding, its nearest records, nearest first. + public virtual Task>> QueryAsync(IReadOnlyList> queryEmbeddings, int nResults = 10, ChromaWhereOperator? where = null, ChromaWhereDocumentOperator? whereDocument = null, ChromaQueryInclude? include = null, IReadOnlyList? ids = null, CancellationToken cancellationToken = default) + => QueryAsync(new ChromaQuery(queryEmbeddings) { NResults = nResults, Where = where, WhereDocument = whereDocument, Include = include, Ids = ids }, cancellationToken); + +#if !NETSTANDARD2_0 + /// + /// Searches the nResults records nearest to the query text, with the embedding that the generator of + /// WithEmbeddingGenerator computes from it, as query_texts in the Python client of Chroma. Without a generator it throws an + /// InvalidOperationException. Without include, the metadatas, the documents and the distances are included. + /// + /// The query text. + /// How many nearest records the query returns. + /// The filter on the metadata, or null for none. + /// The filter on the documents, or null for none. + /// What the results include, or null for the default. + /// The ids of the records to search among, or null for all. + /// The token that cancels the operation. + /// The nearest records, nearest first. + public virtual async Task> QueryAsync(string queryText, int nResults = 10, ChromaWhereOperator? where = null, ChromaWhereDocumentOperator? whereDocument = null, ChromaQueryInclude? include = null, IReadOnlyList? ids = null, CancellationToken cancellationToken = default) { - var requestParams = new RequestQueryParams() - .Insert("{collection_id}", _collection.Id); - var request = new CollectionQueryRequest() + if (queryText is null) + { + throw new ArgumentNullException(nameof(queryText)); + } + return (await QueryTextsAsync([queryText], nResults: nResults, where: where, whereDocument: whereDocument, include: include, ids: ids, cancellationToken: cancellationToken)).FirstOrDefault() ?? []; + } + + // Another name than QueryAsync: an overload with a list of texts would make QueryAsync([]) ambiguous, which compiles today. + /// + /// Searches the nResults records nearest to each query text, with the embeddings that the generator of + /// WithEmbeddingGenerator computes from them, as query_texts in the Python client of Chroma, and returns one list of results + /// per text. Without a generator it throws an InvalidOperationException. Without include, the metadatas, the documents and + /// the distances are included. + /// + /// The query texts: the results are one list for each. + /// How many nearest records each query text returns. + /// The filter on the metadata, or null for none. + /// The filter on the documents, or null for none. + /// What the results include, or null for the default. + /// The ids of the records to search among, or null for all. + /// The token that cancels the operation. + /// For each query text, its nearest records, nearest first. + public virtual async Task>> QueryTextsAsync(IReadOnlyList queryTexts, int nResults = 10, ChromaWhereOperator? where = null, ChromaWhereDocumentOperator? whereDocument = null, ChromaQueryInclude? include = null, IReadOnlyList? ids = null, CancellationToken cancellationToken = default) + { + if (queryTexts is null) + { + throw new ArgumentNullException(nameof(queryTexts)); + } + if (queryTexts.Any(text => text is null)) + { + throw new ArgumentException("A query text cannot be null.", nameof(queryTexts)); + } + var generator = _embeddingGenerator ?? throw new InvalidOperationException("The client has no embedding generator to compute the embeddings of the query texts: use WithEmbeddingGenerator, or query with embeddings."); + var embeddings = await Embed(generator, queryTexts, cancellationToken); + return await QueryAsync(new ChromaQuery(embeddings) { NResults = nResults, Where = where, WhereDocument = whereDocument, Include = include, Ids = ids }, cancellationToken); + } +#endif + + /// + /// Runs the query and returns one list of results per query embedding. The ids of the query without a record are left + /// out, as Chroma Cloud does: Chroma 1.x fails on them, so the query goes again with the ids that have one. When the query + /// has ids and the server searches outside them, as Chroma 0.x does, it throws a ChromaException instead of + /// returning the results. + /// + /// The query: embeddings, number of results, filters, what to include and ids. + /// The token that cancels the operation. + /// For each query embedding, its nearest records, nearest first. + public virtual Task>> QueryAsync(ChromaQuery query, CancellationToken cancellationToken = default) + { + CheckDocumentFilters(() => ChromaWhereOperator.Split(query.Where, query.WhereDocument, query.Ids).WhereDocument?.ToRequestWhereDocument(), document: true, nameof(query)); + return WithRegexQuota(() => [(ChromaWhereOperator.Split(query.Where, query.WhereDocument, query.Ids).WhereDocument?.ToRequestWhereDocument(), true)], Operation>>("query", cancellationToken, async () => { - QueryEmbeddings = queryEmbeddings, - NResults = nResults, - Where = where?.ToWhere(), - WhereDocument = whereDocument?.ToWhereDocument(), - Include = (include ?? ChromaQueryInclude.Metadatas | ChromaQueryInclude.Documents | ChromaQueryInclude.Distances).ToInclude(), + var requestParams = new RequestQueryParams() + .Insert("{tenant}", _tenant) + .Insert("{database}", _database) + .Insert("{collection_id}", _collection.Id); + var (where, whereDocument, queryIds) = ChromaWhereOperator.Split(query.Where, query.WhereDocument, query.Ids); + var asked = query.Include ?? ChromaQueryInclude.Metadatas | ChromaQueryInclude.Documents | ChromaQueryInclude.Distances; + var copy = ReadsDocuments(asked.HasFlag(ChromaQueryInclude.Documents), asked.HasFlag(ChromaQueryInclude.Metadatas)); + Task Send(IReadOnlyList? ids, Func? handledElsewhere = null, int attemptsBefore = 0) + => Read(async () => (await _httpClient.Post(_httpClient.Routes.Collection + "/query", new CollectionQueryRequest() + { + QueryEmbeddings = query.QueryEmbeddings, + NResults = query.NResults + query.Offset, + Where = ChromaWhereOperator.ToRequestWhere(where), + WhereDocument = whereDocument?.ToRequestWhereDocument(), + Include = (copy.ReadsMetadata ? asked | ChromaQueryInclude.Metadatas : asked).ToInclude(), + Ids = ids, + }, requestParams, cancellationToken)).Checked(query.QueryEmbeddings.Count), cancellationToken, handledElsewhere, attemptsBefore); + if (query.Offset > int.MaxValue - query.NResults) + { + throw new ArgumentOutOfRangeException(nameof(query), "The results and the offset of a query together go beyond the largest number of results."); + } + if (where == ChromaWhereOperator.None) + { + return query.QueryEmbeddings.Select(_ => (IReadOnlyList)[]).ToList(); + } + if (query.ExpectedSpace is { } expected && _collection.Space is { } actual && actual != expected) + { + throw new InvalidOperationException( + $"The collection has the space {ChromaSpaceNames.ToName(actual)}, not {ChromaSpaceNames.ToName(expected)}: its distances are not the ones the query expects."); + } + CollectionEntriesQueryResponse response; + try + { + // With ids, an internal error goes at once to the workaround of KD-24, which tells an id without a record from an error that + // can pass. + response = await Send(queryIds, ex => queryIds is { Count: > 0 } && ex.ErrorType == "InternalError"); + } + catch (ChromaException ex) when (queryIds is { Count: > 0 } && ex.ErrorType == "InternalError") + { + // Works around KD-24 (docs/COMPATIBILITY.md) + // Chroma 1.x without filters answers 500 "Error finding id" when an id has no record, where Chroma Cloud leaves the id + // out: the query goes again with the ids that have a record, and without any it has no results. + var found = new HashSet((await GetEntries(queryIds, null, null, null, null, ChromaGetInclude.None, cancellationToken)).Select(entry => entry.Id)); + if (queryIds.All(found.Contains)) + { + // Every id has a record, so the error has another cause, which can pass: the query goes again, with ReadRetries, its + // first attempt counted. + if (_httpClient.ReadRetries.MaxAttempts == 1) + { + throw; + } + response = await Send(queryIds, attemptsBefore: 1); + } + else if (found.Count == 0) + { + return query.QueryEmbeddings.Select(_ => (IReadOnlyList)[]).ToList(); + } + else + { + response = await Send(queryIds.Where(found.Contains).ToList()); + } + } + var result = response.Map(copy) ?? []; + // Works around KD-36 (docs/COMPATIBILITY.md) + // Chroma 0.x ignores the ids and searches all the records: a result outside the ids shows it. When all the results + // are among the ids, they are also the nearest among them, so the answer is right on those servers too. + if (queryIds is not null) + { + var ids = new HashSet(queryIds); + if (result.Any(entries => entries.Any(entry => !ids.Contains(entry.Id)))) + { + throw new ChromaException("The server searched outside the ids of the query: it does not support them. Chroma 1.0.0 and later do."); + } + } + return query.Offset == 0 ? result : result.Select(entries => (IReadOnlyList)entries.Skip(query.Offset).ToList()).ToList(); + })); + } + + /// + /// Adds the records with the ids, embeddings, metadatas and documents. Since Chroma 1.0.16 the server requires the + /// embeddings: the client computes them from the documents with a generator from WithEmbeddingGenerator. + /// + /// The ids of the records. + /// The embeddings, one for each id, or null for none. + /// The metadata, one for each id, or null for none. A null value throws an ArgumentException. + /// The documents, one for each id, or null for none. + /// The token that cancels the operation. + public virtual Task AddAsync(IReadOnlyList ids, IReadOnlyList>? embeddings = null, IReadOnlyList>? metadatas = null, IReadOnlyList? documents = null, CancellationToken cancellationToken = default) + => AddAsync(new ChromaRecords(ids) { Embeddings = embeddings, Metadatas = metadatas, Documents = documents }, cancellationToken); + + /// + /// Adds the records; by default, unless WithBatchSplitting(false), they go in batches of the max_batch_size of + /// the server. Since Chroma 1.0.16 the server requires the embeddings: the client computes them from the documents with a generator + /// from WithEmbeddingGenerator, and then every record needs a document. It computes the sparse + /// vectors of the chroma_bm25 indexes of the schema that have a source key, as the Python client of Chroma does. + /// A null value in the metadata throws an ArgumentException: Chroma 0.x would drop the key, and 1.x rejects the request. + /// + /// The records: ids, and embeddings, metadatas, documents and URIs when given. + /// The token that cancels the operation. + public virtual Task AddAsync(ChromaRecords records, CancellationToken cancellationToken = default) + { + records = WithoutEmptyMetadatas(records); + // Before the collection of a client made by name is read: records that cannot go send no request. + ChromaRequestChecks.SameLengths(records, nameof(records)); + ChromaRequestChecks.MetadataValues(records.Metadatas, nameof(records)); + CheckGeneratedDocumentKey(records, update: false); + CheckDocumentsToEmbed(records); + // The embeddings are computed once: a client made by name that runs the operation again on another collection keeps them. + Task? withEmbeddings = null; + return Operation("add", cancellationToken, async () => + { + var embedded = await (withEmbeddings ??= WithEmbeddings(records, cancellationToken)); + ChromaRequestChecks.NoNullValues(records.Metadatas, nameof(records)); + // A local copy: a collection client made by name may run the operation again on another collection, from the records given. + var prepared = WithSparseVectors(WithDocumentCopies(embedded, update: false)); + await CheckListsInMetadata(prepared, cancellationToken); + var base64 = prepared.Embeddings is not null && await _httpClient.SupportsBase64Embeddings(cancellationToken); + var requestParams = new RequestQueryParams() + .Insert("{tenant}", _tenant) + .Insert("{database}", _database) + .Insert("{collection_id}", _collection.Id); + await InBatches(prepared, async batch => + { + var request = new CollectionAddRequest() + { + Ids = batch.Ids, + Embeddings = batch.Embeddings is { } embeddings ? new ChromaEmbeddings(embeddings, base64) : null, + Metadatas = MetadatasOrNone(batch.Metadatas), + Documents = batch.Documents, + Uris = batch.Uris, + }; + await _httpClient.Post(_httpClient.Routes.Collection + "/add", request, requestParams, cancellationToken); + }, cancellationToken); + }); + } + + /// + /// Updates the embeddings, metadatas and documents of the records with the ids. With a generator from + /// WithEmbeddingGenerator, documents given without embeddings get theirs. + /// + /// The ids of the records. + /// The embeddings, one for each id, or null for none. + /// The metadata, one for each id, or null for none. A null value deletes the key on every tested Chroma: + /// write it null!, as the type does not allow it. + /// The documents, one for each id, or null for none. + /// The token that cancels the operation. + public virtual Task UpdateAsync(IReadOnlyList ids, IReadOnlyList>? embeddings = null, IReadOnlyList>? metadatas = null, IReadOnlyList? documents = null, CancellationToken cancellationToken = default) + => UpdateAsync(new ChromaRecords(ids) { Embeddings = embeddings, Metadatas = metadatas, Documents = documents }, cancellationToken); + + /// + /// Updates the records with the ids; by default, unless WithBatchSplitting(false), they go in batches of the + /// max_batch_size of the server. The client computes the sparse vectors of the chroma_bm25 indexes of the schema + /// that have a source key, as the Python client of Chroma does. A null value in the metadata, written null! as the type does + /// not allow it, or an empty list deletes the key and the sparse vectors computed from its text. A null document keeps the stored one + /// on Chroma 1.x and Chroma Cloud, and the 0.x servers delete it; with ChromaRecords.NullDocumentsDelete a null document + /// deletes the document on every server, which needs WithDocumentCopyKey, + /// WithDocumentDeletionKey or WithEmptyDocumentDeletions: without one of them, a null document throws an + /// ArgumentException before any request. A null goes to the record whether it has the key or not, so + /// two writes of the same record at the same time leave the record of one of them; the keys the metadata does not have stay. On Chroma + /// Cloud, a write with nulls that would take the metadata of a record beyond the quota of 32 keys throws an ArgumentException + /// before it goes, without a read; with ChromaConfigurationOptions.ReadsBeyondKeyQuota the client reads the record first and + /// sends only the nulls of its keys, and a write that lands between that read and the write can keep what it wrote there. A record + /// with more than 32 values and no null goes, and Chroma Cloud rejects it. + /// With a generator from WithEmbeddingGenerator, records given with documents and without embeddings get the embeddings of + /// their documents, and then every record needs a document. + /// + /// The records: ids, and embeddings, metadatas, documents and URIs when given. + /// The token that cancels the operation. + public virtual Task UpdateAsync(ChromaRecords records, CancellationToken cancellationToken = default) + { + records = WithoutEmptyMetadatas(records); + // Before the collection of a client made by name is read: records that cannot go send no request. + ChromaRequestChecks.SameLengths(records, nameof(records)); + ChromaRequestChecks.MetadataValues(records.Metadatas, nameof(records)); + CheckDocumentDeletions(records); + CheckGeneratedDocumentKey(records, update: true); + CheckDocumentsToEmbed(records); + // The embeddings are computed once: a client made by name that runs the operation again on another collection keeps them. + Task? withEmbeddings = null; + return Operation("update", cancellationToken, async () => + { + var embedded = await (withEmbeddings ??= WithEmbeddings(records, cancellationToken)); + // A local copy: a collection client made by name may run the operation again on another collection, from the records given. + // The sparse vectors before the deletions, which count every key that goes against the quota of Chroma Cloud. + var prepared = await WithDeletions(WithSparseVectors(WithDocumentMarks(WithDocumentCopies(embedded, update: true))), cancellationToken); + await CheckListsInMetadata(prepared, cancellationToken); + var base64 = prepared.Embeddings is not null && await _httpClient.SupportsBase64Embeddings(cancellationToken); + var requestParams = new RequestQueryParams() + .Insert("{tenant}", _tenant) + .Insert("{database}", _database) + .Insert("{collection_id}", _collection.Id); + if (await WritesByStrategy(prepared, cancellationToken)) + { + await WriteByStrategy(prepared, upsert: false, base64, requestParams, cancellationToken); + return; + } + await InBatches(prepared, async batch => + { + var request = new CollectionUpdateRequest() + { + Ids = batch.Ids, + Embeddings = batch.Embeddings is { } embeddings ? new ChromaEmbeddings(embeddings, base64) : null, + Metadatas = MetadatasOrNone(batch.Metadatas), + Documents = batch.Documents, + Uris = batch.Uris, + }; + await _httpClient.Post(_httpClient.Routes.Collection + "/update", request, requestParams, cancellationToken); + }, cancellationToken); + }); + } + + /// + /// Adds the records with the ids, or updates the ones that already exist. Since Chroma 1.0.16 the server requires + /// the embeddings: the client computes them from the documents with a generator from WithEmbeddingGenerator. + /// + /// The ids of the records. + /// The embeddings, one for each id, or null for none. + /// The metadata, one for each id, or null for none. A null value deletes the key on every tested Chroma: + /// write it null!, as the type does not allow it. + /// The documents, one for each id, or null for none. + /// The token that cancels the operation. + public virtual Task UpsertAsync(IReadOnlyList ids, IReadOnlyList>? embeddings = null, IReadOnlyList>? metadatas = null, IReadOnlyList? documents = null, CancellationToken cancellationToken = default) + => UpsertAsync(new ChromaRecords(ids) { Embeddings = embeddings, Metadatas = metadatas, Documents = documents }, cancellationToken); + + /// + /// Adds the records, or updates the ones that already exist; by default, unless WithBatchSplitting(false), they go in + /// batches of the max_batch_size of the server. Since Chroma 1.0.16 the server requires the embeddings: the client does + /// not compute them without a generator. It computes the sparse vectors of the chroma_bm25 indexes of the schema that have a source key, as + /// the Python client of Chroma does. A null value in the metadata, written null! as the type does not allow it, or an empty + /// list deletes the key of a record that exists and the sparse vectors computed from its text, and a new record is added without + /// it. A null document keeps the stored one on Chroma 1.x and Chroma Cloud, and the 0.x servers delete it; with + /// ChromaRecords.NullDocumentsDelete a null document deletes the document on every server, which needs + /// WithDocumentCopyKey, WithDocumentDeletionKey or WithEmptyDocumentDeletions: without one of them, a null + /// document throws an ArgumentException before any request. A null goes to the record whether it has the key + /// or not, so two writes of the same record at the same time leave the record of one of them; the keys the metadata does not have + /// stay. On Chroma Cloud, a write with nulls that would take the metadata of a record beyond the quota of 32 keys throws an + /// ArgumentException before it goes, without a read; with ChromaConfigurationOptions.ReadsBeyondKeyQuota the client reads + /// the record first and sends only the nulls of its keys, and a write that lands between that read and the write can keep what it + /// wrote there. A record with more than 32 values and no null goes, and Chroma Cloud rejects it. + /// With a generator from WithEmbeddingGenerator, records given with documents and without embeddings get the embeddings of + /// their documents, and then every record needs a document. + /// + /// The records: ids, and embeddings, metadatas, documents and URIs when given. + /// The token that cancels the operation. + public virtual Task UpsertAsync(ChromaRecords records, CancellationToken cancellationToken = default) + { + records = WithoutEmptyMetadatas(records); + // Before the collection of a client made by name is read: records that cannot go send no request. + ChromaRequestChecks.SameLengths(records, nameof(records)); + ChromaRequestChecks.MetadataValues(records.Metadatas, nameof(records)); + CheckDocumentDeletions(records); + CheckGeneratedDocumentKey(records, update: true); + CheckDocumentsToEmbed(records); + // The embeddings are computed once: a client made by name that runs the operation again on another collection keeps them. + Task? withEmbeddings = null; + return Operation("upsert", cancellationToken, async () => + { + var embedded = await (withEmbeddings ??= WithEmbeddings(records, cancellationToken)); + // A local copy: a collection client made by name may run the operation again on another collection, from the records given. + // The sparse vectors before the deletions, which count every key that goes against the quota of Chroma Cloud. + var prepared = await WithDeletions(WithSparseVectors(WithDocumentMarks(WithDocumentCopies(embedded, update: true))), cancellationToken); + await CheckListsInMetadata(prepared, cancellationToken); + var base64 = prepared.Embeddings is not null && await _httpClient.SupportsBase64Embeddings(cancellationToken); + var requestParams = new RequestQueryParams() + .Insert("{tenant}", _tenant) + .Insert("{database}", _database) + .Insert("{collection_id}", _collection.Id); + if (await WritesByStrategy(prepared, cancellationToken)) + { + await WriteByStrategy(prepared, upsert: true, base64, requestParams, cancellationToken); + return; + } + await InBatches(prepared, async batch => + { + var request = new CollectionUpsertRequest() + { + Ids = batch.Ids, + Embeddings = batch.Embeddings is { } embeddings ? new ChromaEmbeddings(embeddings, base64) : null, + Metadatas = MetadatasOrNone(batch.Metadatas), + Documents = batch.Documents, + Uris = batch.Uris, + }; + await _httpClient.Post(_httpClient.Routes.Collection + "/upsert", request, requestParams, cancellationToken); + }, cancellationToken); + }); + } + + // With a generator, records without embeddings get them from their documents, so each needs a document: a null one, which keeps the + // stored document or deletes it, has no text. Before any request, as the other checks of the records. + private void CheckDocumentsToEmbed(ChromaRecords records) + { +#if !NETSTANDARD2_0 + if (_embeddingGenerator is not null && records.Embeddings is null && records.Documents is { } documents && documents.Any(document => document is null)) + { + throw new ArgumentException("The client computes the embeddings from the documents, and a record has a null document: give every record a document, or give the embeddings.", nameof(records)); + } +#endif + } + + // As an embedding function in the Python client of Chroma: records given without embeddings get the ones the generator computes from + // their documents. Without a generator, or without documents, the records go as they are. +#if !NETSTANDARD2_0 + private async Task WithEmbeddings(ChromaRecords records, CancellationToken cancellationToken) + { + if (_embeddingGenerator is not { } generator || records.Embeddings is not null || records.Documents is not { } documents) + { + return records; + } + var embedded = new ChromaRecords(records.Ids) + { + // CheckDocumentsToEmbed made sure that none is null. + Embeddings = await Embed(generator, documents.Select(document => document!).ToList(), cancellationToken), + Metadatas = records.Metadatas, + Documents = records.Documents, + Uris = records.Uris, + NullDocumentsDelete = records.NullDocumentsDelete, }; - var response = await _httpClient.Post("collections/{collection_id}/query", request, requestParams); - return response.Map() ?? []; + // The computed embeddings get the check of the given ones, finite numbers, before any batch goes. + ChromaRequestChecks.SameLengths(embedded, nameof(records)); + return embedded; } - public async Task Add(List ids, List>? embeddings = null, List>? metadatas = null, List? documents = null) + // One embedding for each text, in the order of the texts. + private static async Task>> Embed(IEmbeddingGenerator> generator, IReadOnlyList texts, CancellationToken cancellationToken) { - var requestParams = new RequestQueryParams() - .Insert("{collection_id}", _collection.Id); - var request = new CollectionAddRequest() + if (texts.Count == 0) { - Ids = ids, - Embeddings = embeddings, - Metadatas = metadatas, - Documents = documents, + return []; + } + var embeddings = await generator.GenerateAsync(texts, null, cancellationToken); + if (embeddings.Count != texts.Count) + { + throw new InvalidOperationException($"The embedding generator returned {embeddings.Count} embeddings for {texts.Count} texts."); + } + return embeddings.Select(embedding => embedding.Vector).ToList(); + } +#else + private static Task WithEmbeddings(ChromaRecords records, CancellationToken cancellationToken) + => Task.FromResult(records); +#endif + + // The copy of each document under the document copy key, when the server takes it: Chroma Cloud takes a metadata value of at most + // 8,182 bytes. In an update or an upsert, a document without its copy, as one too long or one deleted, deletes the stored copy with + // a null, which WithDeletions sends along; a null document that stays keeps its copy. + // An empty metadata goes as no metadata, null: in add, update and upsert, also in batches with other metadatas, every tested Chroma, + // 0.4.10 to 1.5.9 and Chroma Cloud, treats the two the same. A new record has no metadata, and a stored one keeps its own. + private static ChromaRecords WithoutEmptyMetadatas(ChromaRecords records) + => records.Metadatas is not { } metadatas || !metadatas.Any(metadata => metadata is { Count: 0 }) ? records : new ChromaRecords(records.Ids) + { + Embeddings = records.Embeddings, + Metadatas = metadatas.Select(metadata => metadata is { Count: 0 } ? null : metadata).ToList(), + Documents = records.Documents, + Uris = records.Uris, + NullDocumentsDelete = records.NullDocumentsDelete, + }; + + private ChromaRecords WithDocumentCopies(ChromaRecords records, bool update) + { + if (_documentCopyKey is not { } key || records.Documents is not { } documents) + { + return records; + } + var metadatas = new List?>(records.Ids.Count); + for (var i = 0; i < records.Ids.Count; i++) + { + var given = records.Metadatas?[i]; + var document = documents[i]; + var fits = document is not null && (!_httpClient.IsChromaCloud || System.Text.Encoding.UTF8.GetByteCount(document) <= ChromaCloudQuotas.MaxMetadataValueBytes); + if (!fits && !(update && (document is not null || records.NullDocumentsDelete))) + { + metadatas.Add(given); + continue; + } + var metadata = given?.ToDictionary(pair => pair.Key, pair => pair.Value) ?? []; + metadata[key] = fits ? document! : null!; + metadatas.Add(metadata); + } + return new ChromaRecords(records.Ids) + { + Embeddings = records.Embeddings, + Metadatas = metadatas.Any(metadata => metadata is not null) ? metadatas : null, + Documents = records.Documents, + Uris = records.Uris, + NullDocumentsDelete = records.NullDocumentsDelete, }; - await _httpClient.Post("collections/{collection_id}/add", request, requestParams); } - public async Task Update(List ids, List>? embeddings = null, List>? metadatas = null, List? documents = null) + // The mark under the document deletion key, in an update or an upsert: true for a document deleted with NullDocumentsDelete, which + // goes empty, and the null that deletes it for any other document, so that no write reads the records first. A null document that + // stays keeps its mark. + private ChromaRecords WithDocumentMarks(ChromaRecords records) { - var requestParams = new RequestQueryParams() - .Insert("{collection_id}", _collection.Id); - var request = new CollectionUpdateRequest() + if (_documentDeletionKey is not { } key || records.Documents is not { } documents) { - Ids = ids, - Embeddings = embeddings, - Metadatas = metadatas, + return records; + } + var metadatas = new List?>(records.Ids.Count); + for (var i = 0; i < records.Ids.Count; i++) + { + var given = records.Metadatas?[i]; + if (documents[i] is null && !records.NullDocumentsDelete) + { + metadatas.Add(given); + continue; + } + var metadata = given?.ToDictionary(pair => pair.Key, pair => pair.Value) ?? []; + metadata[key] = documents[i] is null ? true : null!; + metadatas.Add(metadata); + } + return new ChromaRecords(records.Ids) + { + Embeddings = records.Embeddings, + Metadatas = metadatas.Any(metadata => metadata is not null) ? metadatas : null, + Documents = records.Documents, + Uris = records.Uris, + NullDocumentsDelete = records.NullDocumentsDelete, + }; + } + + // Works around KD-58 (docs/COMPATIBILITY.md) + // The copies and the marks are added after the given metadata is checked: under chroma:document a deleted document or a mark would + // go as a value that is not a text. Checked before the request, from the same metadata, which needs no request to compute. + private void CheckGeneratedDocumentKey(ChromaRecords records, bool update) + { + if (_documentCopyKey == ChromaRequestChecks.DocumentKey || _documentDeletionKey == ChromaRequestChecks.DocumentKey) + { + var generated = WithDocumentCopies(records, update); + ChromaRequestChecks.GeneratedDocumentKey((update ? WithDocumentMarks(generated) : generated).Metadatas, nameof(records)); + } + } + + // Chroma has no deletion of a document: the client writes an empty one, which only the copy under a document copy key or the mark + // under a document deletion key tells from an empty text, and which empty document deletions read back as empty. Without one of the + // three ways, a null document with NullDocumentsDelete throws, before any request, with the three. + private void CheckDocumentDeletions(ChromaRecords records) + { + if (DocumentReadKey is null && !_emptyDocumentDeletions && records.NullDocumentsDelete && records.Documents?.Any(document => document is null) == true) + { + throw new ArgumentException("NullDocumentsDelete needs a way to tell a deleted document from an empty one: Chroma cannot delete a document, so the client writes an empty one. Choose one on the collection client: WithDocumentCopyKey(key) copies each document into a metadata key and reads an empty document without its copy as null; WithDocumentDeletionKey(key) writes only a mark under a metadata key and reads an empty document with the mark as null; WithEmptyDocumentDeletions() writes an empty document that reads back as an empty string.", nameof(records)); + } + } + + // In UpdateAsync and UpsertAsync: a null value or an empty list deletes the key, a null document deletes the document with + // NullDocumentsDelete, and a text that goes takes the sparse vectors computed from it along. A null goes to the record whether it has + // the key or not: every tested Chroma takes it, also in an upsert that creates the record, and leaves the record without the key. So + // the write does not depend on a read that another writer can change before it goes. Chroma has no deletion of a document: the client + // writes an empty one to every record whose document goes, without a read, as only a client with a document copy key, a document + // deletion key or empty document deletions gets here (CheckDocumentDeletions); with a copy or a mark it reads that empty document as + // none. Chroma Cloud counts a null against its quota of keys, and a sparse vector too: when the values, the sparse vectors that + // WithSparseVectors computed and the nulls would take the metadata of a record beyond it, the write throws before it goes, or with + // ReadsBeyondKeyQuota the client reads that record first and sends the nulls only to the keys it has. + private async Task WithDeletions(ChromaRecords records, CancellationToken cancellationToken) + { + var count = records.Ids.Count; + var indexes = _collection.SparseVectorIndexes.Where(index => index.SourceKey is not null && index.EmbeddingFunction is not null).ToList(); + var deleted = new List?[count]; + var documentDeleted = new bool[count]; + var keysRead = new bool[count]; + for (var i = 0; i < count; i++) + { + var metadata = records.Metadatas?[i]; + List? keys = null; + foreach (var pair in metadata ?? Enumerable.Empty>()) + { + if (pair.Value is null || ChromaRequestChecks.IsList(pair.Value) && ChromaRequestChecks.IsEmpty(pair.Value)) + { + (keys ??= []).Add(pair.Key); + } + } + documentDeleted[i] = records.NullDocumentsDelete && records.Documents is { } givenDocuments && givenDocuments[i] is null; + foreach (var index in indexes) + { + var textDeleted = index.SourceKey == ChromaSearchKeys.Document ? documentDeleted[i] : keys?.Contains(index.SourceKey!) == true; + // A vector the caller gives stays, and the null of one is in the keys already: each key counts once against the quota. + if (textDeleted && metadata?.ContainsKey(index.Key) != true) + { + (keys ??= []).Add(index.Key); + } + } + deleted[i] = keys; + var keyCount = keys is null ? 0 : (metadata?.Count(pair => !keys.Contains(pair.Key)) ?? 0) + keys.Count; + if (_httpClient.IsChromaCloud && keyCount > ChromaCloudQuotas.MaxMetadataKeys) + { + if (!_httpClient.ReadsBeyondKeyQuota) + { + throw new ArgumentException($"Record \"{records.Ids[i]}\" would take {keyCount} metadata keys to Chroma Cloud, beyond its quota of {ChromaCloudQuotas.MaxMetadataKeys}: its values and nulls, with the keys that the client adds, like the copy of the document, the mark of a deleted document and the BM25 sparse vectors. Send fewer keys or fewer nulls, or use ChromaConfigurationOptions.WithReadsBeyondKeyQuota() in the options of the ChromaClient: it reads the record first and sends only the nulls of the keys it has, but then two writes of the same record at the same time can mix.", nameof(records)); + } + keysRead[i] = true; + } + } + if (!deleted.Any(keys => keys is not null) && !documentDeleted.Contains(true)) + { + return records; + } + var readIds = records.Ids.Where((_, i) => keysRead[i]).Distinct().ToList(); + var stored = readIds.Count == 0 ? new Dictionary() + : (await GetEntries(readIds, null, null, null, null, ChromaGetInclude.Metadatas, cancellationToken)).ToDictionary(entry => entry.Id); + var metadatas = new List?>(count); + var documents = records.Documents?.ToList(); + for (var i = 0; i < count; i++) + { + var entry = stored.TryGetValue(records.Ids[i], out var found) ? found : null; + var given = records.Metadatas?[i]; + if (deleted[i] is { } keys) + { + var metadata = given?.Where(pair => !keys.Contains(pair.Key)).ToDictionary(pair => pair.Key, pair => pair.Value) ?? []; + foreach (var key in keys.Where(key => !keysRead[i] || entry?.Metadata?.ContainsKey(key) == true)) + { + metadata[key] = null!; + } + metadatas.Add(metadata.Count > 0 ? metadata : null); + } + else + { + metadatas.Add(given); + } + if (documentDeleted[i]) + { + documents![i] = string.Empty; + } + } + return new ChromaRecords(records.Ids) + { + Embeddings = records.Embeddings, + Metadatas = metadatas.Any(metadata => metadata is not null) ? metadatas : null, Documents = documents, + Uris = records.Uris, + NullDocumentsDelete = records.NullDocumentsDelete, }; - await _httpClient.Post("collections/{collection_id}/update", request, requestParams); } - public async Task Upsert(List ids, List>? embeddings = null, List>? metadatas = null, List? documents = null) + // As the Python client of Chroma: for each sparse vector index of the schema with a source key and an embedding function, the vector + // of each record whose metadata does not have the key, from its document (#document) or from the text in its metadata. An empty text + // gets its empty vector, which replaces the vector of the old text. The records and the metadata of the caller stay as they are: the + // ones that change are copies. + private ChromaRecords WithSparseVectors(ChromaRecords records) { - var requestParams = new RequestQueryParams() - .Insert("{collection_id}", _collection.Id); - var request = new CollectionUpsertRequest() + if (records.Metadatas is null && records.Documents is null) { - Ids = ids, - Embeddings = embeddings, + return records; + } + var indexes = _collection.SparseVectorIndexes.Where(index => index.SourceKey is not null && index.EmbeddingFunction is not null).ToList(); + if (indexes.Count == 0) + { + return records; + } + var metadatas = records.Metadatas?.ToList() ?? records.Ids.Select(_ => (IReadOnlyDictionary?)null).ToList(); + var copied = new bool[metadatas.Count]; + foreach (var index in indexes) + { + for (var i = 0; i < metadatas.Count; i++) + { + var metadata = metadatas[i]; + if (metadata is not null && metadata.TryGetValue(index.Key, out var given)) + { + // The key holds the vectors of the index: a record gives one, or deletes it with null or an empty list, and nothing else. + if (given is not null && !IsSparseVector(given) && !(ChromaRequestChecks.IsList(given) && ChromaRequestChecks.IsEmpty(given))) + { + throw new ArgumentException($"The metadata key \"{index.Key}\" holds the vectors of the sparse vector index on \"{index.SourceKey}\": a record cannot give it another value.", nameof(records)); + } + continue; + } + var text = index.SourceKey == ChromaSearchKeys.Document + ? records.Documents is { } documents && i < documents.Count ? documents[i] : null + : metadata is not null && metadata.TryGetValue(index.SourceKey!, out var value) ? value as string : null; + if (text is null) + { + continue; + } + var function = index.Bm25Function ?? throw CannotEmbed(index); + var copy = copied[i] ? (Dictionary)metadata! : metadata?.ToDictionary(x => x.Key, x => x.Value) ?? []; + copy[index.Key] = function.Embed(text); + metadatas[i] = copy; + copied[i] = true; + } + } + return !copied.Contains(true) ? records : new ChromaRecords(records.Ids) + { + Embeddings = records.Embeddings, Metadatas = metadatas, - Documents = documents, + Documents = records.Documents, + Uris = records.Uris, + NullDocumentsDelete = records.NullDocumentsDelete, }; - await _httpClient.Post("collections/{collection_id}/upsert", request, requestParams); } - public async Task Delete(List ids, ChromaWhereOperator? where = null, ChromaWhereDocumentOperator? whereDocument = null) + // Works around KD-49 (docs/COMPATIBILITY.md) + // A strategy other than the upsert of the server, for writes with embeddings: on Chroma 0.x, which has not the defect, the writes go + // as they are. + private async Task WritesByStrategy(ChromaRecords records, CancellationToken cancellationToken) + => _httpClient.UpsertStrategy != ChromaUpsertStrategy.Server && records.Embeddings is not null && !await _httpClient.IsChroma0(cancellationToken); + + // The embeddings of the records are read first. A record whose embedding does not change is updated without it, with its other + // fields as given; the others go by the update or the upsert of the server. + private async Task WriteByStrategy(ChromaRecords records, bool upsert, bool base64, RequestQueryParams requestParams, CancellationToken cancellationToken) + { + var stored = await ReadStored(records.Ids.Distinct().ToList(), cancellationToken); + var unchanged = new List(); + var others = new List(); + // An id given more than once goes with the others, in the order given: Chroma applies the writes in order, and the last one stays. + var repeated = new HashSet(records.Ids.GroupBy(id => id).Where(group => group.Count() > 1).Select(group => group.Key)); + for (var i = 0; i < records.Ids.Count; i++) + { + if (!repeated.Contains(records.Ids[i]) && stored.TryGetValue(records.Ids[i], out var entry) && entry.Embedding is { } embedding + && SameEmbedding(embedding.Span, records.Embeddings![i].Span, _collection.Space == ChromaSpace.Cosine)) + { + unchanged.Add(i); + } + else + { + others.Add(i); + } + } + // Once a write went, a failure is partly done: a client made by name does not run the operation again. The error says once how many + // records went, of all those given. + var went = 0; + try + { + if (unchanged.Count > 0) + { + await SendWrite(Pick(records, unchanged, embeddings: false), "update", base64, requestParams, cancellationToken); + went = unchanged.Count; + } + if (others.Count > 0) + { + await SendWrite(Pick(records, others), upsert ? "upsert" : "update", base64, requestParams, cancellationToken); + } + } + catch (ChromaException ex) when (went + (ex.RecordsWent ?? 0) > 0) + { + var error = ex.RecordsWent is not null && ex.InnerException is ChromaException inner ? inner : ex; + throw new ChromaException($"{went + (ex.RecordsWent ?? 0)} of the {records.Ids.Count} records went before the error, and stay: {error.Message}", error) + { + StatusCode = error.StatusCode, + ErrorType = error.ErrorType, + PartlyDone = true, + RecordsWent = went + (ex.RecordsWent ?? 0), + }; + } + } + + // Works around KD-8 (docs/COMPATIBILITY.md) + // Whether the embedding written is the one stored. In a cosine collection Chroma 1.x gives an embedding back 1 or 2 ulp off, so there + // the values may differ by up to 4 ulp, far below any change of a vector that matters. + private static bool SameEmbedding(ReadOnlySpan stored, ReadOnlySpan given, bool cosine) + { + if (!cosine || stored.Length != given.Length) + { + return stored.SequenceEqual(given); + } + var storedBits = System.Runtime.InteropServices.MemoryMarshal.Cast(stored); + var givenBits = System.Runtime.InteropServices.MemoryMarshal.Cast(given); + for (var i = 0; i < stored.Length; i++) + { + if (stored[i] != given[i] && ((storedBits[i] < 0) != (givenBits[i] < 0) || Math.Abs((long)storedBits[i] - givenBits[i]) > 4)) + { + return false; + } + } + return true; + } + + // The embeddings of the records with the ids. + private async Task> ReadStored(IReadOnlyList ids, CancellationToken cancellationToken) { var requestParams = new RequestQueryParams() + .Insert("{tenant}", _tenant) + .Insert("{database}", _database) .Insert("{collection_id}", _collection.Id); - var request = new CollectionDeleteRequest() + var size = Math.Max((_httpClient.BatchSplitting ? await BatchSize(cancellationToken) : null) ?? ids.Count, 1); + var stored = new Dictionary(); + for (var i = 0; i < ids.Count; i += size) { - Ids = ids, - Where = where?.ToWhere(), - WhereDocument = whereDocument?.ToWhereDocument(), + var request = new CollectionGetRequest() + { + Ids = ids.Skip(i).Take(size).ToList(), + Include = ChromaGetInclude.Embeddings.ToInclude(), + }; + var response = await Read(async () => (await _httpClient.Post(_httpClient.Routes.Collection + "/get", request, requestParams, cancellationToken)).Checked("get"), cancellationToken); + foreach (var entry in response.Map()) + { + stored[entry.Id] = entry; + } + } + return stored; + } + + private static ChromaRecords Pick(ChromaRecords records, List indexes, bool embeddings = true) + => new(indexes.Select(i => records.Ids[i]).ToList()) + { + Embeddings = embeddings && records.Embeddings is { } given ? indexes.Select(i => given[i]).ToList() : null, + Metadatas = records.Metadatas is { } metadatas ? indexes.Select(i => metadatas[i]).ToList() : null, + Documents = records.Documents is { } documents ? indexes.Select(i => documents[i]).ToList() : null, + Uris = records.Uris is { } uris ? indexes.Select(i => uris[i]).ToList() : null, }; - await _httpClient.Post("collections/{collection_id}/delete", request, requestParams); + + // The records in batches to the endpoint: update or upsert, which take the same fields. + private Task SendWrite(ChromaRecords records, string endpoint, bool base64, RequestQueryParams requestParams, CancellationToken cancellationToken) + => InBatches(records, batch => + { + var embeddings = batch.Embeddings is { } given ? new ChromaEmbeddings(given, base64) : null; + var metadatas = MetadatasOrNone(batch.Metadatas); + var path = _httpClient.Routes.Collection + "/" + endpoint; + return endpoint switch + { + "upsert" => _httpClient.Post(path, new CollectionUpsertRequest() { Ids = batch.Ids, Embeddings = embeddings, Metadatas = metadatas, Documents = batch.Documents, Uris = batch.Uris }, requestParams, cancellationToken), + _ => _httpClient.Post(path, new CollectionUpdateRequest() { Ids = batch.Ids, Embeddings = embeddings, Metadatas = metadatas, Documents = batch.Documents, Uris = batch.Uris }, requestParams, cancellationToken), + }; + }, cancellationToken); + + // A list of metadata that are all null goes as no metadata. + private static IReadOnlyList?>? MetadatasOrNone(IReadOnlyList?>? metadatas) + => metadatas?.Any(metadata => metadata is not null) == true ? metadatas : null; + + // The vector of a text query of SparseKnn, with the function of the sparse vector index of the key, as the Python client of Chroma does. + private ChromaSparseVector EmbedText(string key, string text) + { + var index = _collection.SparseVectorIndexes.FirstOrDefault(index => index.Key == key) + ?? throw new ChromaException($"A text query on \"{key}\" needs a sparse vector index on that key in the schema of the collection, as GetCollection returns it; or give the sparse vector."); + return (index.Bm25Function ?? throw CannotEmbed(index)).Embed(text); } - public async Task Count() + private static ChromaException CannotEmbed(ChromaSparseVectorIndex index) + => new($"The sparse vectors of \"{index.Key}\" come from the embedding function \"{index.EmbeddingFunction}\" of the schema, which the client cannot compute: it computes chroma_bm25. Give the sparse vectors yourself."); + + // Works around KD-21 (docs/COMPATIBILITY.md) + // The records in batches of the batch size, one request after the other, with batch splitting, on by default. Chroma Cloud + // rejects a batch beyond its quota of records, "current usage of 301 exceeds limit of 300", before it writes any of it: the + // client keeps that limit for the server, and sends that batch and the rest in batches of it. + private async Task InBatches(ChromaRecords records, Func send, CancellationToken cancellationToken) { - var requestParams = new RequestQueryParams() - .Insert("{collection_id}", _collection.Id); - return await _httpClient.Get("collections/{collection_id}/count", requestParams); + var total = records.Ids.Count; + var offset = 0; + do + { + var size = _httpClient.BatchSplitting ? await BatchSize(cancellationToken) : null; + var count = size is { } limit ? Math.Min(limit, total - offset) : total - offset; + var batch = offset == 0 && count == total ? records : Slice(records, offset, count); + try + { + await send(batch); + offset += count; + } + catch (ChromaException ex) when (_httpClient.BatchSplitting && RecordsQuota(ex) is { } quota && quota < count) + { + _httpClient.LearnRecordsLimit(quota); + } + // The batches sent stay: a client made by name does not run the operation again, on a collection created again either. + catch (ChromaException ex) when (offset > 0) + { + throw new ChromaException($"{offset} of the {total} records went before the error, and stay: {ex.Message}", ex) + { + StatusCode = ex.StatusCode, + ErrorType = ex.ErrorType, + PartlyDone = true, + RecordsWent = offset, + }; + } + } + while (offset < total); + } + + private static ChromaRecords Slice(ChromaRecords records, int offset, int count) + => new(records.Ids.Skip(offset).Take(count).ToList()) + { + Embeddings = records.Embeddings?.Skip(offset).Take(count).ToList(), + Metadatas = records.Metadatas?.Skip(offset).Take(count).ToList(), + Documents = records.Documents?.Skip(offset).Take(count).ToList(), + Uris = records.Uris?.Skip(offset).Take(count).ToList(), + }; + + // "Quota exceeded: 'Number of records' exceeded quota limit for action 'Add': current usage of 301 exceeds limit of 300". + private static readonly System.Text.RegularExpressions.Regex RecordsQuotaMessage = new("'Number of records' exceeded quota limit .* exceeds limit of ([0-9]+)"); + + private static int? RecordsQuota(ChromaException exception) + => RecordsQuotaMessage.Match(exception.Message) is { Success: true } match + && int.TryParse(match.Groups[1].Value, System.Globalization.NumberStyles.None, System.Globalization.CultureInfo.InvariantCulture, out var limit) && limit > 0 + ? limit + : null; + + // The quota of the records of a read, as Chroma Cloud rejects a get with a larger limit, before it reads anything: + // "Quota exceeded: 'Limit value' exceeded quota limit for action 'Get': current usage of 1000 exceeds limit of 300". The client keeps + // it as the quota of records, with the one of the writes, and reads that page again with it. + private static readonly System.Text.RegularExpressions.Regex LimitQuotaMessage = new("'Limit value' exceeded quota limit .* exceeds limit of ([0-9]+)"); + + private static int? LimitQuota(ChromaException exception) + => LimitQuotaMessage.Match(exception.Message) is { Success: true } match + && int.TryParse(match.Groups[1].Value, System.Globalization.NumberStyles.None, System.Globalization.CultureInfo.InvariantCulture, out var limit) && limit > 0 + ? limit + : null; + + // Works around KD-45 (docs/COMPATIBILITY.md) + // The smallest of the limit of the caller, 300 on Chroma Cloud when not given, the max_batch_size the server declares, and the + // quota of records the server enforced. + private async Task BatchSize(CancellationToken cancellationToken) + => new[] { _httpClient.MaxBatchSize, await _httpClient.GetMaxBatchSize(cancellationToken), _httpClient.RecordsLimit }.Min(); + + // Works around KD-18 (docs/COMPATIBILITY.md) + // The 0.x servers accept lists in metadata but drop them without an error; Chroma 1.0 to 1.4 reject them, 1.5.0 stores them. + // Sparse vectors too: Chroma 0.6.3 accepts them and stores the metadata as null. + private async Task CheckListsInMetadata(ChromaRecords records, CancellationToken cancellationToken) + { + ChromaRequestChecks.NoEmptyLists(records.Metadatas, nameof(records)); + if (records.Metadatas?.Any(metadata => metadata?.Values.Any(ChromaRequestChecks.IsList) == true) == true + && await _httpClient.IsChroma0(cancellationToken)) + { + throw new ChromaException("Chroma 0.x drops the lists in metadata without an error: they need Chroma 1.5.0 or later."); + } + if (records.Metadatas?.Any(metadata => metadata?.Values.Any(IsSparseVector) == true) == true + && await _httpClient.IsChroma0(cancellationToken)) + { + throw new ChromaException("Chroma 0.x drops the sparse vectors in metadata without an error: only Chroma Cloud stores them."); + } } - public async Task> Peek(int limit = 10) + // A tagged JsonElement object is what the client returns for a sparse vector read with ChromaMetadataValues.Inferred. + private static bool IsSparseVector(object? value) + => value is ChromaSparseVector + || value is System.Text.Json.JsonElement element && ChromaSparseVectorConverter.IsTagged(element); + + /// + /// Deletes the records with the ids, sending the filters with them when given; by default, unless + /// WithBatchSplitting(false), the ids go in batches of the max_batch_size of the server. + /// + /// The ids of the records to delete. + /// The filter on the metadata, or null for none. + /// The filter on the documents, or null for none. + /// The token that cancels the operation. + public virtual Task DeleteAsync(IReadOnlyList ids, ChromaWhereOperator? where = null, ChromaWhereDocumentOperator? whereDocument = null, CancellationToken cancellationToken = default) { - var requestParams = new RequestQueryParams() - .Insert("{collection_id}", _collection.Id); - var request = new CollectionPeekRequest() + // As in DeleteAsync(ChromaDelete): every tested Chroma rejects a delete without ids and filters. Before the collection of a + // client made by name is read. + if (ids is [] && where is null && whereDocument is null) { - Limit = limit, + throw new ArgumentException("The ids of a delete without filters cannot be empty: there is nothing to delete.", nameof(ids)); + } + CheckDocumentFilters(() => ChromaWhereOperator.Split(where, whereDocument, ids).WhereDocument?.ToRequestWhereDocument(), document: true, nameof(whereDocument)); + return WithRegexQuota(() => [(ChromaWhereOperator.Split(where, whereDocument, ids).WhereDocument?.ToRequestWhereDocument(), true)], Operation("delete", cancellationToken, async () => + { + var split = ChromaWhereOperator.Split(where, whereDocument, ids); + if (split.Where == ChromaWhereOperator.None) + { + return; + } + var requestParams = new RequestQueryParams() + .Insert("{tenant}", _tenant) + .Insert("{database}", _database) + .Insert("{collection_id}", _collection.Id); + await InBatches(new ChromaRecords(split.Ids!), async batch => + { + var request = new CollectionDeleteRequest() + { + Ids = batch.Ids, + Where = ChromaWhereOperator.ToRequestWhere(split.Where), + WhereDocument = split.WhereDocument?.ToRequestWhereDocument(), + }; + await _httpClient.Post(_httpClient.Routes.Collection + "/delete", request, requestParams, cancellationToken); + }, cancellationToken); + })); + } + + /// + /// Deletes the records with the ids, the ones the filters match, or both, at most delete.Limit of them. + /// Returns the count the server answers, from Chroma 1.5.3: for a delete by ids, the number of ids given, also those + /// without a record; null otherwise. + /// + /// What to delete: ids, filters and a limit. + /// The token that cancels the operation. + /// The count the server answers, from Chroma 1.5.3, which for a delete by ids is the number of ids given; null from the earlier servers. + public virtual Task DeleteAsync(ChromaDelete delete, CancellationToken cancellationToken = default) + { + CheckDocumentFilters(() => ChromaWhereOperator.Split(delete.Where, delete.WhereDocument, delete.Ids).WhereDocument?.ToRequestWhereDocument(), document: true, nameof(delete)); + return WithRegexQuota(() => [(ChromaWhereOperator.Split(delete.Where, delete.WhereDocument, delete.Ids).WhereDocument?.ToRequestWhereDocument(), true)], Operation("delete", cancellationToken, async () => + { + // The rules of Chroma and of its Python client, checked before any request. All is no filter. + if (delete.Ids is []) + { + throw new ArgumentException("The ids of a delete cannot be empty: leave them null to delete by the filters only.", nameof(delete)); + } + var (splitWhere, whereDocument, ids) = ChromaWhereOperator.Split(delete.Where, delete.WhereDocument, delete.Ids); + var where = splitWhere == ChromaWhereOperator.All ? null : splitWhere; + if (ids is null && where is null && whereDocument is null) + { + throw new ArgumentException("A delete needs ids, a where filter or a where document filter: without them it would select every record.", nameof(delete)); + } + if (delete.Limit is < 0) + { + throw new ArgumentOutOfRangeException(nameof(delete), "The limit of a delete cannot be negative."); + } + if (delete.Limit is not null && where is null && whereDocument is null) + { + throw new ArgumentException("The limit of a delete needs a where or where document filter: Chroma rejects it with the ids alone.", nameof(delete)); + } + if (where == ChromaWhereOperator.None) + { + return 0; + } + if (delete.Limit is not null && !await _httpClient.SupportsDeleteLimit(cancellationToken)) + { + throw new ChromaException("The server ignores the limit of a delete and would delete every matching record: Chroma 1.5.3 and later apply it."); + } + var requestParams = new RequestQueryParams() + .Insert("{tenant}", _tenant) + .Insert("{database}", _database) + .Insert("{collection_id}", _collection.Id); + int? deleted = null; + var remaining = delete.Limit; + var sent = false; + async Task Send(IReadOnlyList? ids) + { + // The limit is used up: the next batches would delete nothing. The first request goes anyway, also with a limit of 0. + if (sent && remaining is <= 0) + { + return; + } + sent = true; + var request = new CollectionDeleteRequest() + { + Ids = ids, + Where = ChromaWhereOperator.ToRequestWhere(where), + WhereDocument = whereDocument?.ToRequestWhereDocument(), + Limit = remaining, + }; + // {"deleted": n} from Chroma 1.5.3; {} or null before, and a list of ids from some 0.x servers. + var response = await _httpClient.Post(_httpClient.Routes.Collection + "/delete", request, requestParams, cancellationToken); + if (response.ValueKind == System.Text.Json.JsonValueKind.Object + && response.TryGetProperty("deleted", out var count) && count.ValueKind == System.Text.Json.JsonValueKind.Number) + { + deleted = (deleted ?? 0) + count.GetInt32(); + remaining -= count.GetInt32(); + } + } + if (ids is null) + { + await Send(null); + } + else + { + await InBatches(new ChromaRecords(ids), batch => Send(batch.Ids), cancellationToken); + } + return deleted; + })); + } + + /// + /// Counts the records of the collection. + /// + /// The token that cancels the operation. + /// The number of records. + public virtual Task CountAsync(CancellationToken cancellationToken = default) + => Operation("count", cancellationToken, async () => + { + var requestParams = new RequestQueryParams() + .Insert("{tenant}", _tenant) + .Insert("{database}", _database) + .Insert("{collection_id}", _collection.Id); + return await Read(() => _httpClient.Get(_httpClient.Routes.Collection + "/count", requestParams, cancellationToken), cancellationToken); + }); + + /// + /// The count at a read level: ChromaReadLevel.IndexOnly leaves out the records not indexed yet. Chroma Cloud indexes them + /// later, so right after a write the count can be lower; a single server indexes them at once. + /// + /// Which records are read: ChromaReadLevel.IndexOnly leaves out the ones not indexed yet. + /// The token that cancels the operation. + /// The number of records. + public virtual Task CountAsync(ChromaReadLevel readLevel, CancellationToken cancellationToken = default) + => Operation("count", cancellationToken, async () => + { + var requestParams = new RequestQueryParams() + .Insert("{tenant}", _tenant) + .Insert("{database}", _database) + .Insert("{collection_id}", _collection.Id) + .Insert("{read_level}", ReadLevelName(readLevel)); + return await Read(() => _httpClient.Get(_httpClient.Routes.Collection + "/count?read_level={read_level}", requestParams, cancellationToken), cancellationToken); + }); + + /// + /// Runs one search with the Search API of Chroma, which only Chroma Cloud serves: a single server answers 501. + /// + /// The search. + /// Which records are read: ChromaReadLevel.IndexOnly leaves out the ones not indexed yet; null for the default of the server. + /// The token that cancels the operation. + /// The records the search ranks, in order. + public virtual async Task> SearchAsync(ChromaSearch search, ChromaReadLevel? readLevel = null, CancellationToken cancellationToken = default) + => (await SearchAsync([search], readLevel, cancellationToken)).Single(); + + /// + /// Runs several searches in one request with the Search API of Chroma, which only Chroma Cloud serves: a single server answers + /// 501. The results come in the order of the searches. With ChromaReadLevel.IndexOnly the records not indexed yet are + /// left out. + /// + /// The searches, sent in one request. + /// Which records are read: ChromaReadLevel.IndexOnly leaves out the ones not indexed yet; null for the default of the server. + /// The token that cancels the operation. + /// For each search, the records it ranks, in order. + public virtual Task>> SearchAsync(IReadOnlyList searches, ChromaReadLevel? readLevel = null, CancellationToken cancellationToken = default) + { + foreach (var search in searches ?? []) + { + CheckDocumentFilters(() => search.Where is { } where && where != ChromaWhereOperator.None && where != ChromaWhereOperator.All ? ChromaWhereOperator.ToRequestWhere(where) : null, document: false, nameof(searches)); + CheckDocumentFilters(() => search.WhereDocument?.ToRequestWhereDocument(), document: true, nameof(searches)); + } + return WithRegexQuota(() => (searches ?? []).SelectMany(search => new (Dictionary?, bool)[] + { + (search.Where is { } where && where != ChromaWhereOperator.None && where != ChromaWhereOperator.All ? ChromaWhereOperator.ToRequestWhere(where) : null, false), + (search.WhereDocument?.ToRequestWhereDocument(), true), + }), Operation>>("search", cancellationToken, async () => + { + if (searches is not { Count: > 0 }) + { + throw new ArgumentException("At least one search is needed.", nameof(searches)); + } + foreach (var search in searches) + { + if (search.Offset < 0 || search.Limit is <= 0) + { + throw new ArgumentOutOfRangeException(nameof(searches), "The offset of a search cannot be negative, and its limit must be positive."); + } + if (search.Ids is []) + { + throw new ArgumentException("The ids of a search cannot be empty: leave them null to search all the records.", nameof(searches)); + } + } + // A search with None finds nothing and is not sent; without any other, no request goes. + var sent = searches.Where(search => search.Where != ChromaWhereOperator.None).ToList(); + if (sent.Count == 0) + { + return searches.Select(_ => (IReadOnlyList)[]).ToList(); + } + var requestParams = new RequestQueryParams() + .Insert("{tenant}", _tenant) + .Insert("{database}", _database) + .Insert("{collection_id}", _collection.Id); + var request = new CollectionSearchRequest() + { + Searches = sent.Select(search => new CollectionSearchPayload() + { + Filter = SearchFilter(search), + Rank = search.Rank?.ToRank(EmbedText), + GroupBy = search.GroupBy?.ToGroupBy() ?? [], + Limit = new CollectionSearchLimit() { Offset = search.Offset, Limit = search.Limit }, + Select = new CollectionSearchSelect() { Keys = SearchSelect(search) }, + }).ToList(), + ReadLevel = readLevel is { } level ? ReadLevelName(level) : null, + }; + var response = await Read(async () => (await _httpClient.Post(_httpClient.Routes.Collection + "/search", request, requestParams, cancellationToken)).Checked(sent.Count), cancellationToken); + var results = response.Ids + .Select((ids, i) => + { + var copy = SearchReadsDocuments(sent[i]); + var opposite = sent[i].Rank is ChromaOppositeScoreRank; + return (IReadOnlyList)ids + .Select((id, j) => new ChromaSearchEntry(id) + { + Document = copy.Document(response.Documents?[i]?[j], response.Metadatas?[i]?[j]), + Embedding = response.Embeddings?[i]?[j], + Metadata = copy.Metadata(response.Metadatas?[i] is { } metadatas ? metadatas[j] ?? DocumentReader.NoKeys : null), + Score = opposite ? -response.Scores?[i]?[j] : response.Scores?[i]?[j], + }) + .ToList(); + }) + .ToList(); + if (sent.Count == searches.Count) + { + return results; + } + var next = 0; + return searches.Select(search => search.Where == ChromaWhereOperator.None ? [] : results[next++]).ToList(); + })); + } + + // The keys a search selects, and with the documents the copy key or the deletion key, which tells a deleted document from an empty one. + private List SearchSelect(ChromaSearch search) + { + var keys = search.Select?.Distinct().ToList() ?? []; + if (SearchReadsDocuments(search).ReadsMetadata && !keys.Contains(ChromaSearchKeys.Metadata) && !keys.Contains(DocumentReadKey!)) + { + keys.Add(DocumentReadKey!); + } + return keys; + } + + // The metadata stays in the results of a search that selects it, or the copy key or the deletion key itself. + private DocumentReader SearchReadsDocuments(ChromaSearch search) + => search.Select is { } select + ? ReadsDocuments(select.Contains(ChromaSearchKeys.Document), select.Contains(ChromaSearchKeys.Metadata) || DocumentReadKey is { } key && select.Contains(key)) + : default; + + // Works around KD-56 and KD-57 (docs/COMPATIBILITY.md) + // On Chroma Cloud a filter on the documents gives the wrong records, also in a delete: a $regex whose pattern takes an empty text finds + // every record and its $not_regex none, and, once Chroma Cloud has indexed the records, a $contains of fewer than 3 characters finds + // none and its $not_contains every record. Rather than give or delete the wrong records, such a filter throws there, before any + // request, and so does a pattern that the client cannot read as Chroma does, which Chroma rejects. The filter is the one of the + // request, built only on Chroma Cloud: where_document, or the where of a search, whose filters on the documents are under #document. + // A filter that the operation rejects is left to it. + private void CheckDocumentFilters(Func?> filter, bool document, string paramName) + { + if (!_httpClient.IsChromaCloud) + { + return; + } + Dictionary? request; + try + { + request = filter(); + } + catch (Exception ex) when (ex is NotSupportedException or InvalidOperationException or ArgumentException) + { + return; + } + foreach (var (@operator, value) in DocumentFilters(request, document)) + { + if (@operator is "$regex" or "$not_regex") + { + var length = RegexMinimumLength.Of(value); + if (length is null or 0) + { + var reason = length is 0 ? "it takes an empty text" : "the client cannot read it as Chroma does, with the syntax of the Rust crate regex, to tell that it needs a character"; + throw new ArgumentException($"On Chroma Cloud the pattern \"{value}\" of {@operator} gives the wrong records: {reason}. There a filter on the documents whose pattern takes an empty text finds every record with $regex, also in a delete, and none with $not_regex. A pattern that needs a character is right: Regex(\"(?s).\") finds the records with a document that is not empty, and NotRegex(\"(?s).\") the records with an empty document or none.", paramName); + } + } + else if (Characters(value) < MinContainsCharacters) + { + throw new ArgumentException($"On Chroma Cloud the text \"{value}\" of {@operator} gives the wrong records: once Chroma Cloud has indexed the records, with fewer than {MinContainsCharacters} characters $contains finds no record and $not_contains every record, also in a delete. The text as the pattern of a $regex or $not_regex, its special characters escaped, is right, and Regex(\"(?s).\") finds the records with a document that is not empty.", paramName); + } + } + } + + // Chroma Cloud rejects a pattern of $regex or $not_regex beyond the quota of the tenant, 256 bytes in UTF-8 by default, in every get, + // query, delete and search, with 422 "Quota exceeded: 'Length of regex pattern' exceeded quota limit for action 'Get': current usage of + // 257 exceeds limit of 256. https://trychroma.com/request-quota-increase?...". A tenant can have a higher quota, so the client sends the + // pattern, and on that answer throws a ChromaException that names the patterns beyond the limit of the answer. + private static readonly System.Text.RegularExpressions.Regex RegexQuotaMessage = new("'Length of regex pattern' exceeded quota limit for action '([^']*)': current usage of ([0-9]+) exceeds limit of ([0-9]+)\\.(?:\\s+(https?://\\S+))?"); + + private static async Task WithRegexQuota(Func? Filter, bool Document)>> filters, Task operation) + { + try + { + return await operation; + } + catch (ChromaException ex) when (RegexQuotaMessage.Match(ex.Message) is { Success: true } match) + { + throw RegexQuotaError(ex, match, filters); + } + } + + private static async Task WithRegexQuota(Func? Filter, bool Document)>> filters, Task operation) + { + try + { + await operation; + } + catch (ChromaException ex) when (RegexQuotaMessage.Match(ex.Message) is { Success: true } match) + { + throw RegexQuotaError(ex, match, filters); + } + } + + private static ChromaException RegexQuotaError(ChromaException ex, System.Text.RegularExpressions.Match match, Func? Filter, bool Document)>> filters) + { + var limit = int.Parse(match.Groups[3].Value, System.Globalization.NumberStyles.None, System.Globalization.CultureInfo.InvariantCulture); + List<(string Operator, string Pattern, int Bytes)> patterns; + try + { + patterns = filters().SelectMany(x => DocumentFilters(x.Filter, x.Document)) + .Where(x => x.Operator is "$regex" or "$not_regex") + .Select(x => (x.Operator, Pattern: x.Value, Bytes: System.Text.Encoding.UTF8.GetByteCount(x.Value))) + .Where(x => x.Bytes > limit) + .Distinct() + .ToList(); + } + catch (Exception e) when (e is NotSupportedException or InvalidOperationException or ArgumentException) + { + patterns = []; + } + var rejected = patterns.Count > 0 + ? string.Join(" and ", patterns.Select(x => $"the pattern \"{x.Pattern}\" of {x.Operator}, of {x.Bytes} bytes in UTF-8")) + : $"a pattern of {match.Groups[2].Value} bytes in UTF-8"; + var increase = match.Groups[4].Success ? $": {match.Groups[4].Value}" : ""; + return new ChromaException($"Chroma Cloud rejects {rejected}: the quota of the tenant for a regex pattern is {limit} bytes, in '{match.Groups[1].Value}'. A tenant can ask for a higher quota{increase}", ex) + { + StatusCode = ex.StatusCode, + ErrorType = ex.ErrorType, }; - var response = await _httpClient.Post("collections/{collection_id}/get", request, requestParams); - return response.Map() ?? []; } - public async Task Modify(string? name = null, Dictionary? metadata = null) + // A text of a filter: a string, or a JSON string. + private static string? Text(object? value) => value switch { - var requestParams = new RequestQueryParams() - .Insert("{collection_id}", _collection.Id); - var request = new CollectionModifyRequest() + string text => text, + System.Text.Json.JsonElement { ValueKind: System.Text.Json.JsonValueKind.String } json => json.GetString(), + _ => null, + }; + + // The trigrams of the index of the documents of Chroma Cloud: a text of fewer characters has none. + private const int MinContainsCharacters = 3; + + // The characters of a text as Chroma counts them, the Unicode code points: a character outside the BMP is one. + private static int Characters(string text) + { + var count = 0; + for (var i = 0; i < text.Length; i++) + { + if (!(char.IsLowSurrogate(text[i]) && i > 0 && char.IsHighSurrogate(text[i - 1]))) + { + count++; + } + } + return count; + } + + // The $regex, $not_regex, $contains and $not_contains on the documents in a filter, also inside $and and $or. + // The values of a filter given as a dictionary from JSON are JsonElement: objects, arrays and strings are read as the others. + private static IEnumerable<(string Operator, string Value)> DocumentFilters(object? node, bool document) + { + if (node is System.Text.Json.JsonElement { ValueKind: System.Text.Json.JsonValueKind.Object } json) + { + node = json.EnumerateObject().ToDictionary(property => property.Name, property => (object)property.Value); + } + else if (node is System.Text.Json.JsonElement { ValueKind: System.Text.Json.JsonValueKind.Array } array) + { + node = array.EnumerateArray().Select(item => (object)item).ToList(); + } + if (node is IDictionary map) + { + foreach (var pair in map) + { + if (document && pair.Key is "$regex" or "$not_regex" or "$contains" or "$not_contains" && Text(pair.Value) is { } value) + { + yield return (pair.Key, value); + } + else + { + foreach (var found in DocumentFilters(pair.Value, document || pair.Key == ChromaSearchKeys.Document)) + { + yield return found; + } + } + } + } + else if (node is System.Collections.IEnumerable list and not string) { - Name = name, - Metadata = metadata, + foreach (var item in list) + { + foreach (var found in DocumentFilters(item, document)) + { + yield return found; + } + } + } + } + + // The where clause of the Search API holds the metadata, the documents (#document) and the ids (#id); several filters go in $and. + private static Dictionary? SearchFilter(ChromaSearch search) + { + var filters = new List>(); + if (ChromaWhereOperator.ToRequestWhere(search.Where) is { } where) + { + filters.Add(where); + } + if (search.WhereDocument is { } whereDocument) + { + filters.Add(whereDocument.ToRequestSearchWhere()); + } + if (search.Ids is { } ids) + { + filters.Add(ChromaWhereOperator.In(ChromaSearchKeys.Id, ids.ToArray()).ToWhere()); + } + return filters switch + { + [] => null, + [var single] => single, + _ => new() { ["$and"] = filters.Cast().ToArray() }, }; - await _httpClient.Put("collections/{collection_id}", request, requestParams); + } + + private static string ReadLevelName(ChromaReadLevel readLevel) => readLevel switch + { + ChromaReadLevel.IndexAndWal => "index_and_wal", + ChromaReadLevel.IndexOnly => "index_only", + ChromaReadLevel.IndexAndBoundedWal => "index_and_bounded_wal", + _ => throw new ArgumentOutOfRangeException(nameof(readLevel)), + }; + + /// + /// A copy of the collection under a new name, with the same records: Chroma Cloud only, a single server answers + /// 501. + /// + /// The name of the new collection. + /// The token that cancels the operation. + /// The new collection. + public virtual Task ForkAsync(string newName, CancellationToken cancellationToken = default) + => Operation("fork", cancellationToken, async () => + { + var requestParams = new RequestQueryParams() + .Insert("{tenant}", _tenant) + .Insert("{database}", _database) + .Insert("{collection_id}", _collection.Id); + var request = new ForkCollectionRequest() + { + NewName = newName, + }; + return await _httpClient.Post(_httpClient.Routes.Collection + "/fork", request, requestParams, cancellationToken); + }); + + /// + /// How many forks the collection has: Chroma Cloud only. + /// + /// The token that cancels the operation. + /// The number of forks. + public virtual Task ForkCountAsync(CancellationToken cancellationToken = default) + => Operation("fork_count", cancellationToken, async () => + { + var requestParams = new RequestQueryParams() + .Insert("{tenant}", _tenant) + .Insert("{database}", _database) + .Insert("{collection_id}", _collection.Id); + return (await _httpClient.Get(_httpClient.Routes.Collection + "/fork_count", requestParams, cancellationToken)).Count; + }); + + /// + /// How far the writes to the collection are indexed: Chroma Cloud only. + /// + /// The token that cancels the operation. + /// How many writes are indexed. + public virtual Task GetIndexingStatusAsync(CancellationToken cancellationToken = default) + => Operation("get_indexing_status", cancellationToken, async () => + { + var requestParams = new RequestQueryParams() + .Insert("{tenant}", _tenant) + .Insert("{database}", _database) + .Insert("{collection_id}", _collection.Id); + return await _httpClient.Get(_httpClient.Routes.Collection + "/indexing_status", requestParams, cancellationToken); + }); + + /// + /// Attaches a function of Chroma Cloud, like ChromaFunctions.Statistics, under a name of its own; its + /// results go to the output collection. Created is false when a function with that name was already + /// attached. + /// + /// The function to attach, like ChromaFunctions.Statistics. + /// The name of the attached function. + /// The collection the function writes to. + /// The parameters of the function, or null for none. + /// The token that cancels the operation. + /// The attached function, and whether it was created now. + public virtual Task<(ChromaAttachedFunction AttachedFunction, bool Created)> AttachFunctionAsync(string function, string name, string outputCollection, IReadOnlyDictionary? parameters = null, CancellationToken cancellationToken = default) + => Operation("attach_function", cancellationToken, async () => + { + var requestParams = new RequestQueryParams() + .Insert("{tenant}", _tenant) + .Insert("{database}", _database) + .Insert("{collection_id}", _collection.Id); + var request = new AttachFunctionRequest() + { + Name = name, + FunctionId = function, + OutputCollection = outputCollection, + Params = parameters, + }; + var response = await _httpClient.Post(_httpClient.Routes.Collection + "/functions/attach", request, requestParams, cancellationToken); + return (response.AttachedFunction, response.Created ?? true); + }); + + /// + /// Gets the function attached to the collection under the name: Chroma Cloud only. + /// + /// The name of the attached function. + /// The token that cancels the operation. + /// The attached function. + public virtual Task GetAttachedFunctionAsync(string name, CancellationToken cancellationToken = default) + => Operation("get_attached_function", cancellationToken, async () => + { + var requestParams = new RequestQueryParams() + .Insert("{tenant}", _tenant) + .Insert("{database}", _database) + .Insert("{collection_id}", _collection.Id) + .Insert("{function_name}", name); + return (await _httpClient.Get(_httpClient.Routes.Collection + "/functions/{function_name}", requestParams, cancellationToken)).AttachedFunction; + }); + + /// + /// Detaches the function attached under the name, and deletes its output collection when + /// deleteOutputCollection is true: Chroma Cloud only. Returns the success of the answer of the + /// server. + /// + /// The name of the attached function. + /// Whether the output collection of the function is deleted too. + /// The token that cancels the operation. + /// Whether the function was detached. + public virtual Task DetachFunctionAsync(string name, bool deleteOutputCollection = false, CancellationToken cancellationToken = default) + => Operation("detach_function", cancellationToken, async () => + { + var requestParams = new RequestQueryParams() + .Insert("{tenant}", _tenant) + .Insert("{database}", _database) + .Insert("{collection_id}", _collection.Id) + .Insert("{function_name}", name); + var request = new DetachFunctionRequest() + { + DeleteOutput = deleteOutputCollection, + }; + return (await _httpClient.Post(_httpClient.Routes.Collection + "/attached_functions/{function_name}/detach", request, requestParams, cancellationToken)).Success; + }); + + /// + /// Gets up to limit records of the collection, with a get that sets only the limit. + /// + /// How many records to return. + /// The token that cancels the operation. + /// The first records. + public virtual Task> PeekAsync(int limit = 10, CancellationToken cancellationToken = default) + => Operation>("peek", cancellationToken, async () => + { + var requestParams = new RequestQueryParams() + .Insert("{tenant}", _tenant) + .Insert("{database}", _database) + .Insert("{collection_id}", _collection.Id); + var request = new CollectionPeekRequest() + { + Limit = limit, + }; + var response = await Read(async () => (await _httpClient.Post(_httpClient.Routes.Collection + "/get", request, requestParams, cancellationToken)).Checked("peek"), cancellationToken); + return response.Map() ?? []; + }); + + /// + /// Changes the name or the metadata of the collection. + /// + /// The new name of the collection, or null to keep it. + /// The new metadata of the collection, or null to keep it. + /// The token that cancels the operation. + public virtual Task ModifyAsync(string? name = null, IReadOnlyDictionary? metadata = null, CancellationToken cancellationToken = default) + => Operation("modify", cancellationToken, async () => + { + var requestParams = new RequestQueryParams() + .Insert("{tenant}", _tenant) + .Insert("{database}", _database) + .Insert("{collection_id}", _collection.Id); + ChromaRequestChecks.NoLists(metadata, nameof(metadata)); + var request = new CollectionModifyRequest() + { + Name = name, + Metadata = metadata, + }; + await _httpClient.Put(_httpClient.Routes.Collection, request, requestParams, cancellationToken); + }); + + /// + /// Changes the settings of the index, like ef_search. Chroma 1.0.6 and later apply them; the earlier + /// versions answer without applying them, so on them the client throws a ChromaException before sending the + /// request. The settings must be those of the index of the collection: HNSW on a single server, SPANN on Chroma + /// Cloud. Chroma Cloud answers 500 to HNSW settings, and a single server answers without applying SPANN settings: + /// the client throws before both. + /// + /// The settings of the index to change. + /// The token that cancels the operation. + public virtual Task ModifyConfigurationAsync(ChromaCollectionConfigurationUpdate configuration, CancellationToken cancellationToken = default) + => Operation("modify", cancellationToken, async () => + { + ChromaHnswConfiguration.CheckMaxNeighbors(configuration.Hnsw?.MaxNeighbors, nameof(configuration)); + var current = await CurrentConfiguration(cancellationToken); + if (current is not { ValueKind: System.Text.Json.JsonValueKind.Object } value + || !value.TryGetProperty("hnsw", out var hnsw) && !value.TryGetProperty("spann", out _)) + { + throw new ChromaException("The server answers without applying a new configuration: Chroma 1.0.6 and later apply it."); + } + var hasHnsw = hnsw.ValueKind == System.Text.Json.JsonValueKind.Object; + var hasSpann = value.TryGetProperty("spann", out var spann) && spann.ValueKind == System.Text.Json.JsonValueKind.Object; + // Works around KD-25 (docs/COMPATIBILITY.md) + if (configuration.Hnsw is not null && !hasHnsw && hasSpann) + { + throw new ChromaException("The collection has a SPANN index, as on Chroma Cloud, which rejects HNSW settings: set Spann instead."); + } + if (configuration.Spann is not null && !hasSpann && hasHnsw) + { + throw new ChromaException("The collection has an HNSW index, as on a single Chroma server, which answers without applying SPANN settings: set Hnsw instead."); + } + var requestParams = new RequestQueryParams() + .Insert("{tenant}", _tenant) + .Insert("{database}", _database) + .Insert("{collection_id}", _collection.Id); + var request = new CollectionModifyRequest() + { + Configuration = new CollectionConfigurationUpdateRequest() + { + Hnsw = configuration.Hnsw, + Spann = configuration.Spann, + EmbeddingFunction = configuration.EmbeddingFunction?.ToJson(), + }, + }; + await _httpClient.Put(_httpClient.Routes.Collection, request, requestParams, cancellationToken); + }); + + // Chroma 1.0.6 and later, Chroma Cloud too, send the configuration with "hnsw" and "spann", one of them null; 0.5.4 to 1.0.5 + // with "hnsw_configuration", and 0.4.10 to 0.5.3 send none. Without the configuration at hand, the collection is read by its name. + private async Task CurrentConfiguration(CancellationToken cancellationToken) + { + var configuration = _collection.ConfigurationJson; + if (configuration is null) + { + var requestParams = new RequestQueryParams() + .Insert("{collectionName}", _collection.Name) + .Insert("{tenant}", _tenant) + .Insert("{database}", _database); + configuration = (await _httpClient.Get(_httpClient.Routes.CollectionByName, requestParams, cancellationToken)).ConfigurationJson; + } + return configuration; } } diff --git a/ChromaDB.Client/ChromaCollectionConfiguration.cs b/ChromaDB.Client/ChromaCollectionConfiguration.cs new file mode 100644 index 0000000..e0c30a8 --- /dev/null +++ b/ChromaDB.Client/ChromaCollectionConfiguration.cs @@ -0,0 +1,34 @@ +namespace ChromaDB.Client.Models; + +/// +/// The settings of a new collection. A new setting of Chroma becomes a new property here, without changing the methods. +/// +public class ChromaCollectionConfiguration +{ + /// + /// The distance function of the collection; L2 is the default of Chroma. The client sends it as the + /// hnsw:space metadata, which every tested Chroma applies; with Spann, in the SPANN settings, and with a schema, in + /// the schema. + /// + public ChromaSpace? Space { get; init; } + + /// + /// The settings of the HNSW index, the index of a single Chroma server. The client sends them as the hnsw: metadata, which + /// every tested Chroma applies, or in the schema when there is one. Chroma Cloud ignores them, and Chroma rejects them together with + /// Spann: then it throws. + /// + public ChromaHnswConfiguration? Hnsw { get; init; } + + /// + /// The settings of the SPANN index, the index of Chroma Cloud, in the configuration of the request, which Chroma 1.0.0 and + /// later take, or in the schema for the settings that only a schema takes. A single server ignores them, and Chroma rejects them + /// together with Hnsw: then it throws. + /// + public ChromaSpannConfiguration? Spann { get; init; } + + /// + /// The embedding function the collection declares, so that the clients of Chroma that know it compute the embeddings, in the + /// configuration of the request, which Chroma 1.0.0 and later take, or in the schema when there is one. The client only declares it. + /// + public ChromaEmbeddingFunctionReference? EmbeddingFunction { get; init; } +} diff --git a/ChromaDB.Client/ChromaCollectionConfigurationUpdate.cs b/ChromaDB.Client/ChromaCollectionConfigurationUpdate.cs new file mode 100644 index 0000000..2e0d59e --- /dev/null +++ b/ChromaDB.Client/ChromaCollectionConfigurationUpdate.cs @@ -0,0 +1,32 @@ +using System.Text.Json.Serialization; + +namespace ChromaDB.Client.Models; + +/// +/// The settings of a collection that can change after it is created, new_configuration in Chroma: the HNSW index +/// of a single server, the SPANN index of Chroma Cloud, and the embedding function the collection declares. Chroma 1.0.6 and later +/// apply them. The space cannot change. +/// +public class ChromaCollectionConfigurationUpdate +{ + /// + /// The settings of the HNSW index, for a collection on a single Chroma server. + /// + [JsonPropertyName("hnsw")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public ChromaHnswConfigurationUpdate? Hnsw { get; init; } + + /// + /// The settings of the SPANN index, for a collection on Chroma Cloud. + /// + [JsonPropertyName("spann")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public ChromaSpannConfigurationUpdate? Spann { get; init; } + + /// + /// The embedding function the collection declares, so that the clients of Chroma that know it compute the embeddings. The client + /// only declares it. + /// + [JsonIgnore] + public ChromaEmbeddingFunctionReference? EmbeddingFunction { get; init; } +} diff --git a/ChromaDB.Client/ChromaCollectionDefinition.cs b/ChromaDB.Client/ChromaCollectionDefinition.cs new file mode 100644 index 0000000..f43f386 --- /dev/null +++ b/ChromaDB.Client/ChromaCollectionDefinition.cs @@ -0,0 +1,200 @@ +using ChromaDB.Client.Common; +#if !NETSTANDARD2_0 +using Microsoft.Extensions.AI; +#endif + +namespace ChromaDB.Client.Models; + +/// +/// A collection to create: its name, metadata and configuration. +/// +public class ChromaCollectionDefinition +{ + /// + /// The name of the collection. + /// + public string Name { get; } + /// + /// The metadata of the collection. + /// + public IReadOnlyDictionary? Metadata { get; init; } + /// + /// The settings of the collection: its space, the settings of its vector index and its embedding function. The space and the HNSW + /// settings go as the hnsw: metadata, which every tested Chroma applies; the SPANN settings and the embedding function in the + /// configuration of the request; with a schema, all of them in the schema. + /// + public ChromaCollectionConfiguration? Configuration { get; init; } + + /// + /// The indexes of the keys of the collection, like a sparse vector index for BM25. Chroma 1.3.0 and later apply a schema, but + /// sparse vector indexes only on Chroma Cloud: a single server rejects them, and does not get the ones added with ifSupported. + /// + public ChromaCollectionSchema? Schema { get; init; } + +#if !NETSTANDARD2_0 + /// + /// The generator of the embeddings of the collection. With the generator of the embedding function of Chroma, whose metadata has the + /// provider chroma and the model all-MiniLM-L6-v2, a new collection declares the function default, as the Python + /// client of Chroma does, unless + /// Configuration.EmbeddingFunction declares another: the clients of Chroma that read it compute the embeddings with that function. + /// Chroma 0.x takes no embedding function, so there the collection is created without. The client of the collection gets the generator + /// with WithEmbeddingGenerator. The builds for .NET 8 and .NET Framework have this property; the one for .NET Standard 2.0 does not. + /// + public IEmbeddingGenerator>? EmbeddingGenerator { get; init; } +#endif + + /// + /// Creates the definition of a collection with the name. + /// + /// The name of the collection. + public ChromaCollectionDefinition(string name) + { + Name = name; + } + + // Where the settings go. With a schema, or with SPANN settings that only a schema takes, they all go in the schema: Chroma rejects + // a configuration together with a schema ("Cannot set both collection config and schema simultaneously"). Otherwise the space and the + // HNSW settings go in the "hnsw:" metadata, which every tested Chroma applies, while the configuration field of the request is ignored + // by 0.4.10 to 0.5.3 and fails on 0.5.4 to 0.6.3; the SPANN settings and the embedding function, which only the configuration takes + // besides a schema, go in the configuration, with the space in the SPANN settings: Chroma ignores the "hnsw:space" metadata of a + // request with SPANN settings. + internal bool SettingsInSchema => Schema is not null || Configuration?.Spann?.HasSchemaOnlySettings == true; + + internal bool SettingsInConfiguration => !SettingsInSchema && (Configuration?.Spann is not null || Configuration?.EmbeddingFunction is not null); + + // Chroma rejects the two indexes together: "Multiple vector index configurations provided". + internal void Validate() + { + // As the requests by name: Chroma takes neither as a name. + if (Name is "." or "..") + { + throw new ArgumentException($"\"{Name}\" cannot be the name of a collection.", nameof(Name)); + } + if (Configuration is { Hnsw: not null, Spann: not null }) + { + throw new ArgumentException("A collection has one vector index: set Hnsw for a single Chroma server or Spann for Chroma Cloud, not both, as Chroma rejects them together.", nameof(Configuration)); + } + ChromaHnswConfiguration.CheckMaxNeighbors(Configuration?.Hnsw?.MaxNeighbors, nameof(Configuration)); + ChromaHnswConfiguration.CheckMaxNeighbors(IntegerSettings()?.TryGetValue("hnsw:M", out var m) == true ? m : null, nameof(Metadata)); + // Chroma ignores the hnsw:space metadata next to SPANN settings, but ChromaCollection.Space would read it back. + if (Configuration?.Spann is not null && !SettingsInSchema && Metadata is not null && Metadata.ContainsKey(ChromaSpaceNames.MetadataKey)) + { + throw new ArgumentException($"Chroma ignores the {ChromaSpaceNames.MetadataKey} metadata next to SPANN settings: set the space in Configuration.Space instead.", nameof(Metadata)); + } + } + + // The definition as the server gets it, without the indexes added with ifSupported that it does not take: all of them on a server + // other than Chroma Cloud, and on Chroma Cloud the ones whose key is beyond its quota of bytes. + internal ChromaCollectionDefinition ForServer(bool chromaCloud) + { + var schema = Schema?.WithoutIndexesIfSupported(key => !chromaCloud || System.Text.Encoding.UTF8.GetByteCount(key) > ChromaCloudQuotas.MaxMetadataKeyBytes); + return schema == Schema ? this : new(Name) + { + Metadata = Metadata, + Configuration = Configuration, + Schema = schema, +#if !NETSTANDARD2_0 + EmbeddingGenerator = EmbeddingGenerator, +#endif + }; + } + +#if !NETSTANDARD2_0 + // The definition with the embedding function that its generator declares: the one of Chroma declares "default". The function of the configuration stays, and so do the other settings. + internal ChromaCollectionDefinition WithDeclaredEmbeddingFunction() + { + if (Configuration?.EmbeddingFunction is not null || ChromaEmbeddingFunctionReference.Declared(EmbeddingGenerator) is not { } reference) + { + return this; + } + return new(Name) + { + Metadata = Metadata, + Configuration = new ChromaCollectionConfiguration + { + Space = Configuration?.Space, + Hnsw = Configuration?.Hnsw, + Spann = Configuration?.Spann, + EmbeddingFunction = reference, + }, + Schema = Schema, + EmbeddingGenerator = EmbeddingGenerator, + }; + } +#endif + + internal Dictionary? ToRequestSchema() + => SettingsInSchema ? (Schema ?? new ChromaCollectionSchema()).ToSchema(Configuration) : null; + + internal Dictionary? ToRequestConfiguration() + { + if (!SettingsInConfiguration) + { + return null; + } + var configuration = new Dictionary(); + if (Configuration!.EmbeddingFunction is { } embeddingFunction) + { + configuration["embedding_function"] = embeddingFunction.ToJson(); + } + if (Configuration.Spann is { } spann) + { + var settings = spann.ToJson(); + if (Configuration.Space is { } space) + { + settings["space"] = ChromaSpaceNames.ToName(space); + } + configuration["spann"] = settings; + } + return configuration; + } + + internal IReadOnlyDictionary? ToRequestMetadata() + { + Common.ChromaRequestChecks.NoLists(Metadata, nameof(Metadata)); + var integerSettings = IntegerSettings(); + if (SettingsInSchema) + { + return integerSettings; + } + var settings = new List>(); + // Works around KD-44 (docs/COMPATIBILITY.md) + if (Configuration?.Space is { } space && Configuration.Spann is null) + { + settings.Add(new(ChromaSpaceNames.MetadataKey, ChromaSpaceNames.ToName(space))); + } + if (Configuration?.Hnsw is { } hnsw) + { + settings.AddRange(hnsw.ToMetadata()); + } + if (settings.Count == 0) + { + return integerSettings; + } + var metadata = integerSettings?.ToDictionary(x => x.Key, x => x.Value) ?? []; + foreach (var setting in settings) + { + if (metadata.TryGetValue(setting.Key, out var existing) && !SameValue(existing, setting.Value)) + { + throw new ArgumentException($"The metadata sets {setting.Key} to '{existing}', the configuration to '{setting.Value}'.", nameof(Metadata)); + } + metadata[setting.Key] = setting.Value; + } + return metadata; + } + + // The metadata with the "hnsw:" settings that Chroma takes only as integers written as integers. + private IReadOnlyDictionary? IntegerSettings() + => Metadata is not null && ChromaHnswConfiguration.IntegerMetadataKeys.Any(Metadata.ContainsKey) + ? Metadata.ToDictionary(x => x.Key, x => ChromaHnswConfiguration.IntegerMetadataKeys.Contains(x.Key) ? ChromaHnswConfiguration.IntegerMetadata(x.Key, x.Value, nameof(Metadata)) : x.Value) + : Metadata; + + // 2 and 2L, or 1.5f and 1.5, are the same setting, also in a JsonElement. + private static bool SameValue(object existing, object value) + { + var scalar = ChromaRequestChecks.Scalar(existing); + return Equals(scalar, value) + || scalar is IConvertible && value is IConvertible && scalar is not string && value is not string + && Convert.ToDouble(scalar, System.Globalization.CultureInfo.InvariantCulture) == Convert.ToDouble(value, System.Globalization.CultureInfo.InvariantCulture); + } +} diff --git a/ChromaDB.Client/ChromaCollectionEntry.cs b/ChromaDB.Client/ChromaCollectionEntry.cs index aa20823..1088c7e 100644 --- a/ChromaDB.Client/ChromaCollectionEntry.cs +++ b/ChromaDB.Client/ChromaCollectionEntry.cs @@ -1,14 +1,38 @@ namespace ChromaDB.Client.Models; +/// +/// A record returned by a get. +/// public class ChromaCollectionEntry { + /// + /// The id of the record. + /// public string Id { get; } - public ReadOnlyMemory? Embeddings { get; init; } - public Dictionary? Metadata { get; init; } + /// + /// The embedding of the record, when the get included the embeddings. + /// A value is NaN where Chroma sent null: Chroma 1.x does for a float it cannot write, like an embedding beyond + /// the range of a float in a collection with the cosine space. + /// + public ReadOnlyMemory? Embedding { get; init; } + /// + /// The metadata of the record, when the get included the metadatas: empty for a record without metadata keys, which every + /// tested Chroma returns as null. Null when the get did not include the metadatas. + /// + public IReadOnlyDictionary? Metadata { get; init; } + /// + /// The document of the record, when the get included the documents. + /// public string? Document { get; init; } - public List? Uris { get; init; } - public dynamic? Data { get; init; } + /// + /// The URI of the record. Null when the get did not include the URIs. + /// + public string? Uri { get; init; } + /// + /// Creates the entry of the record with the id. + /// + /// The id of the record. public ChromaCollectionEntry(string id) { Id = id; diff --git a/ChromaDB.Client/ChromaCollectionQueryEntry.cs b/ChromaDB.Client/ChromaCollectionQueryEntry.cs index 1a0c444..d943126 100644 --- a/ChromaDB.Client/ChromaCollectionQueryEntry.cs +++ b/ChromaDB.Client/ChromaCollectionQueryEntry.cs @@ -1,15 +1,44 @@ namespace ChromaDB.Client.Models; +/// +/// A result of a query: a record and its distance from the query embedding. +/// public class ChromaCollectionQueryEntry { + /// + /// The id of the record. + /// public string Id { get; } - public float Distance { get; init; } - public Dictionary? Metadata { get; init; } - public ReadOnlyMemory? Embeddings { get; init; } + /// + /// The distance of the record from the query embedding. Null when the query did not include + /// ChromaQueryInclude.Distances. NaN where Chroma sent null, like the distance to an embedding beyond the + /// range of a float in an l2 or ip collection on Chroma 1.x, and infinite where it sent a number beyond that range. + /// + public float? Distance { get; init; } + /// + /// The metadata of the record, when the query included the metadatas: empty for a record without metadata keys, which every + /// tested Chroma returns as null. Null when the query did not include the metadatas. + /// + public IReadOnlyDictionary? Metadata { get; init; } + /// + /// The embedding of the record, when the query included the embeddings. + /// A value is NaN where Chroma sent null: Chroma 1.x does for a float it cannot write, like an embedding beyond + /// the range of a float in a collection with the cosine space. + /// + public ReadOnlyMemory? Embedding { get; init; } + /// + /// The document of the record, when the query included the documents. + /// public string? Document { get; init; } - public List? Uris { get; init; } - public dynamic? Data { get; init; } + /// + /// The URI of the record. Null when the query did not include the URIs. + /// + public string? Uri { get; init; } + /// + /// Creates the entry of the record with the id. + /// + /// The id of the record. public ChromaCollectionQueryEntry(string id) { Id = id; diff --git a/ChromaDB.Client/ChromaCollectionSchema.cs b/ChromaDB.Client/ChromaCollectionSchema.cs new file mode 100644 index 0000000..7fbf1a2 --- /dev/null +++ b/ChromaDB.Client/ChromaCollectionSchema.cs @@ -0,0 +1,258 @@ +namespace ChromaDB.Client.Models; + +/// +/// The schema of a new collection: the indexes of its keys, schema in Chroma. Only what is set here is sent; the server fills +/// in the rest. Chroma 1.3.0 and later apply it; 1.0.0 to 1.2.2 create the collection without it, and then the client throws a +/// ChromaException. +/// +public sealed class ChromaCollectionSchema +{ + // Key, then value type, then index: { "title": { "string": { "string_inverted_index": { "enabled": false, "config": {} } } } }. + private readonly Dictionary>> _keys; + // Value type, then index, for the keys not named in _keys. + private readonly Dictionary> _defaults; + private readonly string? _gcpCmek; + // The keys of the indexes left out on a server without sparse vector indexes. + private readonly HashSet _keysIfSupported; + + /// + /// An empty schema. + /// + public ChromaCollectionSchema() + : this([], [], null, []) + { } + + private ChromaCollectionSchema(Dictionary>> keys, Dictionary> defaults, string? gcpCmek, HashSet keysIfSupported) + { + _keys = keys; + _defaults = defaults; + _gcpCmek = gcpCmek; + _keysIfSupported = keysIfSupported; + } + + /// + /// A copy of the schema with a sparse vector index on the metadata key, which holds the sparse vectors of the records, like + /// their BM25 vectors. sourceKey is the key of the text they come from, like ChromaSearchKeys.Document; with + /// bm25 the server applies the inverse document frequency of BM25 to them. sourceKey needs embeddingFunction, as + /// Chroma Cloud rejects one without the other: then it throws an ArgumentException. Only Chroma Cloud has sparse vector indexes: + /// a single server rejects them. + /// + /// The metadata key that holds the sparse vectors. + /// The key of the text the vectors come from, like #document, or null for none. + /// Whether the server applies the inverse document frequency of BM25. + /// The function that computes the vectors, or null for none; a source key needs one. + /// The new schema; this one does not change. + public ChromaCollectionSchema WithSparseVectorIndex(string key, string? sourceKey = null, bool bm25 = false, ChromaEmbeddingFunctionReference? embeddingFunction = null) + => WithSparseVectorIndex(key, ChromaSparseIndexAlgorithm.Wand, sourceKey, bm25, embeddingFunction); + + /// + /// A copy of the schema with a BM25 index on the text of the source key, a metadata key or ChromaSearchKeys.Document, with + /// the chroma_bm25 function, whose vectors the client computes as it writes the records. The index is on the metadata key + /// named after the source, like title_bm25, or document_bm25 for the documents; a write that gives that key another + /// value than a sparse vector throws an ArgumentException. Only Chroma Cloud has sparse vector indexes: with ifSupported + /// the client leaves the index out on the other servers, which reject it, and on Chroma Cloud when its key is longer than + /// ChromaCloudQuotas.MaxMetadataKeyBytes. + /// + /// The key of the text: a metadata key, or ChromaSearchKeys.Document. + /// Whether the client leaves the index out where the server does not take it. + /// The new schema; this one does not change. + public ChromaCollectionSchema WithBm25Index(string sourceKey, bool ifSupported = false) + { + var key = (sourceKey ?? throw new ArgumentNullException(nameof(sourceKey))) == ChromaSearchKeys.Document ? "document_bm25" : sourceKey + "_bm25"; + var schema = WithSparseVectorIndex(key, sourceKey, bm25: true, ChromaEmbeddingFunctionReference.ChromaBm25()); + return ifSupported ? new(schema._keys, schema._defaults, schema._gcpCmek, [.. schema._keysIfSupported, key]) : schema; + } + + /// + /// The same as the overload without algorithm, with the algorithm of the index: MaxScore is on Chroma Cloud for the + /// tenants that have it, Wand is the default. + /// + /// The metadata key that holds the sparse vectors. + /// The algorithm of the index. + /// The key of the text the vectors come from, like #document, or null for none. + /// Whether the server applies the inverse document frequency of BM25. + /// The function that computes the vectors, or null for none; a source key needs one. + /// The new schema; this one does not change. + public ChromaCollectionSchema WithSparseVectorIndex(string key, ChromaSparseIndexAlgorithm algorithm, string? sourceKey = null, bool bm25 = false, ChromaEmbeddingFunctionReference? embeddingFunction = null) + { + if (sourceKey is not null && embeddingFunction is null) + { + throw new ArgumentException("A source key needs an embedding function: Chroma Cloud rejects one without the other.", nameof(embeddingFunction)); + } + var config = new Dictionary(); + if (embeddingFunction is not null) + { + config["embedding_function"] = embeddingFunction.ToJson(); + } + if (sourceKey is not null) + { + config["source_key"] = sourceKey; + } + if (bm25) + { + config["bm25"] = true; + } + // As the Python client of Chroma: the default algorithm is left out, which the servers that do not know it read too. + if (algorithm != ChromaSparseIndexAlgorithm.Wand) + { + config["algorithm"] = algorithm switch + { + ChromaSparseIndexAlgorithm.MaxScore => "max_score", + _ => throw new ArgumentOutOfRangeException(nameof(algorithm)), + }; + } + return WithKeyIndex(key, "sparse_vector", "sparse_vector_index", true, config); + } + + /// + /// A copy of the schema with the index turned on, as create_index of the Python client of Chroma: on the metadata key, or on + /// every key without one. The full-text search index is on the documents only, #document: another key throws an + /// ArgumentException. + /// + /// The index. + /// The metadata key, or null for every key. + /// The new schema; this one does not change. + public ChromaCollectionSchema WithIndex(ChromaSchemaIndex index, string? key = null) + => WithValueIndex(index, key, true); + + /// + /// A copy of the schema with the index turned off, as delete_index of the Python client of Chroma: on the metadata key, or on + /// every key without one. A filter on a key without its index finds nothing. The full-text search index is on the documents only, + /// #document: another key throws an ArgumentException. + /// + /// The index. + /// The metadata key, or null for every key. + /// The new schema; this one does not change. + public ChromaCollectionSchema WithoutIndex(ChromaSchemaIndex index, string? key = null) + => WithValueIndex(index, key, false); + + /// + /// A copy of the schema whose collection is encrypted with a customer-managed key of Google Cloud KMS, as set_cmek of the + /// Python client of Chroma: Chroma Cloud only. A resource that is not + /// projects/{project}/locations/{location}/keyRings/{key ring}/cryptoKeys/{key} throws an ArgumentException. + /// + /// The resource name of the key. + /// The new schema; this one does not change. + public ChromaCollectionSchema WithGcpCmek(string resource) + { + if (!GcpKey.IsMatch(resource)) + { + throw new ArgumentException("A key of Google Cloud KMS is projects/{project}/locations/{location}/keyRings/{key ring}/cryptoKeys/{key}.", nameof(resource)); + } + return new(_keys, _defaults, resource, _keysIfSupported); + } + + // The pattern of the Python client, with one segment for each part and nothing after the key, not even a key version. + private static readonly System.Text.RegularExpressions.Regex GcpKey = new(@"\Aprojects/[^/\s]+/locations/[^/\s]+/keyRings/[^/\s]+/cryptoKeys/[^/\s]+\z"); + + /// + /// The JSON of the schema, as the client sends it. With the space, the index settings or the embedding function of + /// ChromaCollectionConfiguration in the definition of the collection, the client adds them to it. + /// + /// The JSON. + public override string ToString() + => System.Text.Json.JsonSerializer.Serialize(ToSchema(null), Common.HttpClientHelpers.TypeInfo>(Common.HttpClientHelpers.PostJsonSerializerOptions)); + + private ChromaCollectionSchema WithValueIndex(ChromaSchemaIndex index, string? key, bool enabled) + { + var (valueType, name) = index switch + { + ChromaSchemaIndex.FullTextSearch => ("string", "fts_index"), + ChromaSchemaIndex.StringInverted => ("string", "string_inverted_index"), + ChromaSchemaIndex.IntInverted => ("int", "int_inverted_index"), + ChromaSchemaIndex.FloatInverted => ("float", "float_inverted_index"), + ChromaSchemaIndex.BoolInverted => ("bool", "bool_inverted_index"), + _ => throw new ArgumentOutOfRangeException(nameof(index)), + }; + // As the Python client: the full-text search index is on #document, the default of Chroma, and only there. + if (index == ChromaSchemaIndex.FullTextSearch) + { + if (key is not null && key != ChromaSearchKeys.Document) + { + throw new ArgumentException($"The full-text search index is on the documents only, {ChromaSearchKeys.Document}.", nameof(key)); + } + key = ChromaSearchKeys.Document; + } + if (key is null) + { + var defaults = Copy(_defaults); + defaults[valueType] = new Dictionary(defaults.TryGetValue(valueType, out var indexes) ? indexes : []) { [name] = Index(enabled, []) }; + return new(_keys, defaults, _gcpCmek, _keysIfSupported); + } + return WithKeyIndex(key, valueType, name, enabled, []); + } + + private ChromaCollectionSchema WithKeyIndex(string key, string valueType, string name, bool enabled, Dictionary config) + { + var keys = _keys.ToDictionary(x => x.Key, x => Copy(x.Value)); + var types = keys.TryGetValue(key, out var existing) ? existing : []; + types[valueType] = new Dictionary(types.TryGetValue(valueType, out var indexes) ? indexes : []) { [name] = Index(enabled, config) }; + keys[key] = types; + // An index added later on the key keeps the key on every server. + return new(keys, _defaults, _gcpCmek, [.. _keysIfSupported.Where(x => x != key)]); + } + + private static Dictionary Index(bool enabled, Dictionary config) + => new() { ["enabled"] = enabled, ["config"] = config }; + + private static Dictionary> Copy(Dictionary> types) + => types.ToDictionary(x => x.Key, x => new Dictionary(x.Value)); + + // The metadata keys the schema names. + internal IEnumerable Keys => _keys.Keys; + + // The schema without the indexes added with ifSupported whose keys are left out, or null when nothing else is in it. + internal ChromaCollectionSchema? WithoutIndexesIfSupported(Func leftOut) + { + var left = new HashSet(_keysIfSupported.Where(leftOut)); + if (left.Count == 0) + { + return this; + } + var keys = _keys.Where(x => !left.Contains(x.Key)).ToDictionary(x => x.Key, x => Copy(x.Value)); + return keys.Count == 0 && _defaults.Count == 0 && _gcpCmek is null ? null : new(keys, _defaults, _gcpCmek, [.. _keysIfSupported.Except(left)]); + } + + // The settings of the vector index go as create_index(VectorIndexConfig(...)) of the Python client writes them: in the defaults + // and on #embedding. Chroma rejects a configuration, like the hnsw:space metadata, together with a schema. + + internal Dictionary ToSchema(ChromaCollectionConfiguration? configuration) + { + var defaults = Copy(_defaults); + var keys = _keys.ToDictionary(x => x.Key, x => Copy(x.Value)); + var vector = new Dictionary(); + if (configuration?.Space is { } space) + { + vector["space"] = Common.ChromaSpaceNames.ToName(space); + } + if (configuration?.Hnsw is { } hnsw) + { + vector["hnsw"] = hnsw.ToJson(); + } + if (configuration?.Spann is { } spann) + { + vector["spann"] = spann.ToJson(); + } + if (configuration?.EmbeddingFunction is { } embeddingFunction) + { + vector["embedding_function"] = embeddingFunction.ToJson(); + } + if (vector.Count > 0) + { + defaults["float_list"] = new() { ["vector_index"] = Index(false, vector) }; + var embedding = keys.TryGetValue(ChromaSearchKeys.Embedding, out var existing) ? existing : []; + embedding["float_list"] = new() { ["vector_index"] = Index(true, new Dictionary(vector)) }; + keys[ChromaSearchKeys.Embedding] = embedding; + } + var schema = new Dictionary + { + ["defaults"] = defaults.ToDictionary(x => x.Key, x => (object)x.Value), + ["keys"] = keys.ToDictionary(x => x.Key, x => (object)x.Value.ToDictionary(y => y.Key, y => (object)y.Value)), + }; + if (_gcpCmek is not null) + { + schema["cmek"] = new Dictionary { ["gcp"] = _gcpCmek }; + } + return schema; + } +} diff --git a/ChromaDB.Client/ChromaConfigurationOptions.cs b/ChromaDB.Client/ChromaConfigurationOptions.cs index e8de60b..843351c 100644 --- a/ChromaDB.Client/ChromaConfigurationOptions.cs +++ b/ChromaDB.Client/ChromaConfigurationOptions.cs @@ -2,41 +2,330 @@ namespace ChromaDB.Client; +/// +/// The options of a client: the server, the default tenant and database, the credentials, the API version, how metadata values +/// are read and how records are split in batches. Each With method returns a copy with one setting changed. +/// public class ChromaConfigurationOptions { + /// + /// The URI of the server. Just the address, like http://localhost:8000, gets the path of the API version, like + /// /api/v2/; a URI with a path is used as it is, with or without the trailing slash. + /// public Uri Uri { get; init; } + /// + /// Whether the server is Chroma Cloud: Uri under trychroma.com, or the options say so with WithChromaCloud, + /// as for Chroma Cloud behind a proxy or another address. + /// + public bool IsChromaCloud => _chromaCloud ?? Uri.Host.EndsWith(".trychroma.com", StringComparison.OrdinalIgnoreCase); + + private bool? _chromaCloud; + /// + /// The tenant of the requests that are not given one; when null or empty, default_tenant. + /// public string? Tenant { get; init; } + /// + /// The database of the requests that are not given one; when null or empty, default_database. + /// public string? Database { get; init; } + /// + /// The token sent with each request, in the header that ChromaTokenTransportHeader names; none when null or empty. + /// public string? ChromaToken { get; init; } + /// + /// The header that carries the token: X-Chroma-Token by default, or Authorization: Bearer. + /// + public ChromaTokenTransportHeader ChromaTokenTransportHeader { get; init; } + /// + /// The user name for basic authentication, sent in the Authorization header of each request; none when null. + /// It cannot be used together with a token in the Authorization header. + /// + public string? BasicAuthUsername { get; init; } + /// + /// The password for basic authentication, sent with BasicAuthUsername. + /// + public string? BasicAuthPassword { get; init; } + /// + /// The version of the Chroma API the client uses: v2 by default, v1 for Chroma 0.5.15 and earlier. + /// + public ChromaApiVersion ApiVersion { get; init; } + /// + /// How the client reads the values of the metadata: Exact by default, or Inferred. + /// + public ChromaMetadataValues MetadataValues { get; init; } = ChromaMetadataValues.Exact; + /// + /// Whether AddAsync, UpdateAsync, UpsertAsync and DeleteAsync send their records in batches of the + /// max_batch_size of the server, and GetAsync reads in pages of it; on by default. + /// + public bool BatchSplitting { get; init; } = true; + /// + /// How UpdateAsync and UpsertAsync write the embeddings of records that exist: ChromaUpsertStrategy.Server, the + /// update and the upsert of the server, by default. ChromaUpsertStrategy.SkipUnchangedEmbeddings works around a defect of + /// Chroma 1.0.21 to 1.5.9 for the records whose embedding does not change. + /// + public ChromaUpsertStrategy UpsertStrategy { get; init; } + /// + /// Whether UpdateAsync and UpsertAsync on Chroma Cloud read a record first when a write with nulls would take it beyond + /// the quota of 32 metadata keys, and send only the nulls of the keys it has; false by default, when such a write throws an + /// ArgumentException before it goes, without a read. A record with more than 32 values and no null goes either way, and Chroma + /// Cloud rejects it. The read and the write are two requests, with two risks: a write + /// of another client of the same record between them can mix with this one, and when an id comes more than once in the same write, a + /// key that a later occurrence deletes can stay if an earlier one wrote it. + /// + public bool ReadsBeyondKeyQuota { get; init; } + /// + /// How a read goes again after an error that can pass, as an answer 500 of Chroma 1.5.9 to a get while other clients write and + /// delete the same records: ChromaReadRetries.Default by default, and ChromaReadRetries.None never goes again. A write + /// never goes again. + /// + public ChromaReadRetries ReadRetries + { + get => _readRetries; + init => _readRetries = value ?? throw new ArgumentNullException(nameof(ReadRetries)); + } + private readonly ChromaReadRetries _readRetries = ChromaReadRetries.Default; + private readonly int? _maxBatchSize; + + /// + /// The largest batch, in records, when the records are sent in batches: the smaller of this and the max_batch_size + /// of the server, or this alone where the server declares none. At least one record, also when set directly. + /// + public int? MaxBatchSize + { + get => _maxBatchSize; + init => _maxBatchSize = value is null or > 0 ? value : throw new ArgumentOutOfRangeException(nameof(MaxBatchSize), value, "The batches need at least one record."); + } - public ChromaConfigurationOptions(Uri uri, string? defaultTenant = null, string? defaultDatabase = null, string? chromaToken = null) + /// + /// Options for the server at the given URI, with the tenant, the database and the token, when given. + /// + /// The URI of the server, like http://localhost:8000. + /// The tenant, or null for the one of the options. + /// The database, or null for the one of the options. + /// The token, sent in X-Chroma-Token by default, or null for none. + public ChromaConfigurationOptions(Uri uri, string? tenant = null, string? database = null, string? chromaToken = null) { Uri = uri; - Tenant = defaultTenant; - Database = defaultDatabase; + Tenant = tenant; + Database = database; ChromaToken = chromaToken; } - public ChromaConfigurationOptions(string uri, string? defaultTenant = null, string? defaultDatabase = null, string? chromaToken = null) - : this(new Uri(uri), defaultTenant, defaultDatabase, chromaToken) + /// + /// Options for the server at the given URI, as a string, with the tenant, the database and the token, when given. + /// + /// The URI of the server, like http://localhost:8000. + /// The tenant, or null for the one of the options. + /// The database, or null for the one of the options. + /// The token, sent in X-Chroma-Token by default, or null for none. + public ChromaConfigurationOptions(string uri, string? tenant = null, string? database = null, string? chromaToken = null) + : this(new Uri(uri), tenant, database, chromaToken) { } + /// + /// Options for the server at http://localhost:8000. + /// public ChromaConfigurationOptions() : this(ClientConstants.DefaultUri) { } + /// + /// Options from a connection string, as an application keeps it in its settings: + /// Endpoint=https://api.trychroma.com;Token=...;Tenant=...;Database=.... Endpoint is the URI of the server, and a + /// connection string that is just an http or https URI is the endpoint alone. Token goes in the X-Chroma-Token + /// header, as Chroma Cloud takes its API keys; Tenant and Database are the ones of the requests. The keys are + /// case-insensitive, a value with ; goes in quotes, and an empty value is no value. Another key, no endpoint, or an endpoint + /// that is not an http or https URI throws an ArgumentException, each with its message. + /// + /// The connection string. + /// The options. + public static ChromaConfigurationOptions FromConnectionString(string connectionString) + { + if (Uri.TryCreate(connectionString, UriKind.Absolute, out var uri) && uri.Scheme is "http" or "https") + { + return new ChromaConfigurationOptions(uri); + } + var builder = new System.Data.Common.DbConnectionStringBuilder { ConnectionString = connectionString }; + string? endpoint = null, token = null, tenant = null, database = null; + foreach (string key in builder.Keys) + { + var value = builder[key]?.ToString() is { Length: > 0 } text ? text : null; + switch (key.ToLowerInvariant()) + { + case "endpoint": + endpoint = value; + break; + case "token": + token = value; + break; + case "tenant": + tenant = value; + break; + case "database": + database = value; + break; + default: + throw new ArgumentException($"The connection string has the key \"{key}\": it takes Endpoint, Token, Tenant and Database.", nameof(connectionString)); + } + } + if (endpoint is null) + { + throw new ArgumentException("The connection string needs Endpoint, the URI of the server.", nameof(connectionString)); + } + // Without the value, which may hold a password. + if (!Uri.TryCreate(endpoint, UriKind.Absolute, out var endpointUri) || endpointUri.Scheme is not ("http" or "https")) + { + throw new ArgumentException("The Endpoint of the connection string is not an http or https URI: it takes the URI of the server, like http://localhost:8000.", nameof(connectionString)); + } + var options = new ChromaConfigurationOptions(endpointUri, tenant, database); + return token is null ? options : options.WithChromaToken(token); + } + + private ChromaConfigurationOptions(ChromaConfigurationOptions options) + : this(options.Uri, options.Tenant, options.Database, options.ChromaToken) + { + ChromaTokenTransportHeader = options.ChromaTokenTransportHeader; + BasicAuthUsername = options.BasicAuthUsername; + BasicAuthPassword = options.BasicAuthPassword; + ApiVersion = options.ApiVersion; + MetadataValues = options.MetadataValues; + BatchSplitting = options.BatchSplitting; + MaxBatchSize = options.MaxBatchSize; + UpsertStrategy = options.UpsertStrategy; + ReadsBeyondKeyQuota = options.ReadsBeyondKeyQuota; + ReadRetries = options.ReadRetries; + _chromaCloud = options._chromaCloud; + } + + /// + /// A copy of these options with the given URI. + /// + /// The URI of the server, like http://localhost:8000. + /// The new options; these do not change. public ChromaConfigurationOptions WithUri(Uri uri) - => new(uri, Tenant, Database, ChromaToken); + => new(this) { Uri = uri }; + /// + /// A copy of these options with the given URI, as a string. + /// + /// The URI of the server, like http://localhost:8000. + /// The new options; these do not change. public ChromaConfigurationOptions WithUri(string uri) - => new(uri, Tenant, Database, ChromaToken); + => new(this) { Uri = new Uri(uri) }; + /// + /// A copy of these options with the given default tenant. + /// + /// The tenant, or null for the one of the options. + /// The new options; these do not change. public ChromaConfigurationOptions WithTenant(string tenant) - => new(Uri, tenant, Database, ChromaToken); + => new(this) { Tenant = tenant }; + /// + /// A copy of these options with the given default database. + /// + /// The database, or null for the one of the options. + /// The new options; these do not change. public ChromaConfigurationOptions WithDatabase(string database) - => new(Uri, Tenant, database, ChromaToken); + => new(this) { Database = database }; + /// + /// A copy of these options with the given token, in the header these options already name: X-Chroma-Token by default. + /// + /// The token, sent in X-Chroma-Token by default, or null for none. + /// The new options; these do not change. public ChromaConfigurationOptions WithChromaToken(string chromaToken) - => new(Uri, Tenant, Database, chromaToken); + => new(this) { ChromaToken = chromaToken }; + + /// + /// A copy of these options with the given token, in the given header. + /// + /// The token, sent in X-Chroma-Token by default, or null for none. + /// The header the token goes in. + /// The new options; these do not change. + public ChromaConfigurationOptions WithChromaToken(string chromaToken, ChromaTokenTransportHeader transportHeader) + => new(this) { ChromaToken = chromaToken, ChromaTokenTransportHeader = transportHeader }; + + /// + /// A copy of these options with the user name and the password for basic authentication, for the Chroma 0.x servers with + /// basic authentication. + /// + /// The user name. + /// The password. + /// The new options; these do not change. + public ChromaConfigurationOptions WithBasicAuth(string username, string password) + => new(this) { BasicAuthUsername = username, BasicAuthPassword = password }; + + /// + /// A copy of these options that says whether the server is Chroma Cloud, whatever its address: for Chroma Cloud behind a proxy + /// or another address. On Chroma Cloud the client writes in batches of 300 from the start, checks the keys of a schema before the + /// creation of a collection, and deletes a collection or a database in one request. + /// + /// Whether the server is Chroma Cloud. + /// The new options; these do not change. + public ChromaConfigurationOptions WithChromaCloud(bool chromaCloud = true) + => new(this) { _chromaCloud = chromaCloud }; + + /// + /// A copy of these options with the given strategy for the embeddings of records that exist in UpdateAsync and + /// UpsertAsync: see ChromaUpsertStrategy. + /// + /// The strategy. + /// The new options; these do not change. + public ChromaConfigurationOptions WithUpsertStrategy(ChromaUpsertStrategy strategy) + => new(this) { UpsertStrategy = strategy }; + + /// + /// A copy of these options that, on Chroma Cloud, reads a record first when the values and the nulls of an update or an upsert would + /// take it beyond the quota of 32 metadata keys, instead of throwing: see ReadsBeyondKeyQuota, and its two risks. + /// + /// Whether the client reads such a record first; false throws. + /// The new options; these do not change. + public ChromaConfigurationOptions WithReadsBeyondKeyQuota(bool readsBeyondKeyQuota = true) + => new(this) { ReadsBeyondKeyQuota = readsBeyondKeyQuota }; + + /// + /// A copy of these options with the given attempts and delays of the reads after an error that can pass: see ChromaReadRetries. + /// + /// The attempts and the delays; ChromaReadRetries.None never goes again. + /// The new options; these do not change. + public ChromaConfigurationOptions WithReadRetries(ChromaReadRetries readRetries) + => new(this) { ReadRetries = readRetries ?? throw new ArgumentNullException(nameof(readRetries)) }; + + /// + /// A copy of these options with the given version of the Chroma API. + /// + /// The version of the Chroma API. + /// The new options; these do not change. + public ChromaConfigurationOptions WithApiVersion(ChromaApiVersion apiVersion) + => new(this) { ApiVersion = apiVersion }; + + /// + /// A copy of these options that reads the values of the metadata the given way. + /// + /// How the client reads the values of the metadata. + /// The new options; these do not change. + public ChromaConfigurationOptions WithMetadataValues(ChromaMetadataValues metadataValues) + => new(this) { MetadataValues = metadataValues }; + + /// + /// AddAsync, UpdateAsync, UpsertAsync and DeleteAsync send their records in batches of the max_batch_size + /// of the server, one request after the other, as by default. If a batch fails, the earlier ones stay written. With + /// false the records go in one request. + /// + /// Whether the records go in batches; false sends them in one request. + /// The new options; these do not change. + public ChromaConfigurationOptions WithBatchSplitting(bool batchSplitting = true) + => new(this) { BatchSplitting = batchSplitting }; + + /// + /// Batches of at most maxBatchSize records, or of the max_batch_size of the server if smaller. For a server whose + /// limit differs from the one it declares, like Chroma Cloud, which declares 1000 but takes 300 records per write by default. + /// + /// The most records in a request. + /// The new options; these do not change. + public ChromaConfigurationOptions WithBatchSplitting(int maxBatchSize) + => maxBatchSize > 0 + ? new(this) { BatchSplitting = true, MaxBatchSize = maxBatchSize } + : throw new ArgumentOutOfRangeException(nameof(maxBatchSize), maxBatchSize, "The batches need at least one record."); } diff --git a/ChromaDB.Client/ChromaDB.Client.csproj b/ChromaDB.Client/ChromaDB.Client.csproj index 279698d..a3ce869 100644 --- a/ChromaDB.Client/ChromaDB.Client.csproj +++ b/ChromaDB.Client/ChromaDB.Client.csproj @@ -1,31 +1,82 @@ - + - netstandard2.0;net8.0 + netstandard2.0;net462;net8.0 latest enable enable true - 1.0.1 + + true + 2.18.0 ChromaDB.Client - .NET SDK for Chroma database - chroma chromadb vector database + + $(PackageIdPrefix).Client + ssone95, cincuranet, ChromaDotNet contributors + .NET client for Chroma and Chroma Cloud, tested with Chroma 0.4.10 to 1.5.9. Community project, not affiliated with Chroma. + chroma chromadb vector-database chroma-cloud ai embeddings rag README.md + + icon.png MIT git https://github.com/ssone95/ChromaDB.Client https://github.com/ssone95/ChromaDB.Client + + https://github.com/$(GITHUB_REPOSITORY) + https://github.com/$(GITHUB_REPOSITORY) true + + true + $(NoWarn);RS0026;RS0027 snupkg + + Release notes: $(PackageProjectUrl)/releases/tag/v$(VersionPrefix). Tested versions: $(PackageProjectUrl)/blob/main/docs/COMPATIBILITY.md. Migration from 1.x: $(PackageProjectUrl)#migrating-from-chromadbclient-1x + + + + + + + - + all runtime; build; native; contentfiles; analyzers + + + - + + + + + + + + + + + + + + + + + + + + + + diff --git a/ChromaDB.Client/ChromaDatabase.cs b/ChromaDB.Client/ChromaDatabase.cs index 29f5f2d..7f1bdbd 100644 --- a/ChromaDB.Client/ChromaDatabase.cs +++ b/ChromaDB.Client/ChromaDatabase.cs @@ -2,17 +2,33 @@ namespace ChromaDB.Client.Models; +/// +/// A database of Chroma, in a tenant. +/// public class ChromaDatabase { + /// + /// The id of the database. + /// [JsonPropertyName("id")] public Guid Id { get; init; } + /// + /// The name of the database. + /// [JsonPropertyName("name")] public string Name { get; } + /// + /// The tenant of the database. + /// [JsonPropertyName("tenant")] public string? Tenant { get; init; } + /// + /// Creates the object for a database with the given name; it sends no request. + /// + /// The name of the database. public ChromaDatabase(string name) { Name = name; diff --git a/ChromaDB.Client/ChromaDelete.cs b/ChromaDB.Client/ChromaDelete.cs new file mode 100644 index 0000000..4c0456f --- /dev/null +++ b/ChromaDB.Client/ChromaDelete.cs @@ -0,0 +1,27 @@ +namespace ChromaDB.Client.Models; + +/// +/// The records to delete: the ones with the ids, the ones the filters match, or both. A new field of Chroma becomes a +/// new property here. +/// +public class ChromaDelete +{ + /// + /// The ids of the records to delete. Null to delete by the filters only; not empty. + /// + public IReadOnlyList? Ids { get; init; } + /// + /// The filter on the metadata. + /// + public ChromaWhereOperator? Where { get; init; } + /// + /// The filter on the documents. + /// + public ChromaWhereDocumentOperator? WhereDocument { get; init; } + /// + /// At most this many records, with a where or where document filter. Chroma 1.5.3 and later apply it; the earlier + /// versions ignore it and delete every matching record, so on them the client throws a ChromaException + /// before sending the request. + /// + public int? Limit { get; init; } +} diff --git a/ChromaDB.Client/ChromaEmbeddingFunctionReference.cs b/ChromaDB.Client/ChromaEmbeddingFunctionReference.cs new file mode 100644 index 0000000..4aac5c5 --- /dev/null +++ b/ChromaDB.Client/ChromaEmbeddingFunctionReference.cs @@ -0,0 +1,63 @@ +#if !NETSTANDARD2_0 +using Microsoft.Extensions.AI; +#endif + +namespace ChromaDB.Client.Models; + +/// +/// The embedding function that a collection declares for a key, embedding_function in its schema: the clients of Chroma that +/// know the function compute the embeddings with it. The client only declares it. +/// +public sealed class ChromaEmbeddingFunctionReference +{ + private readonly string _name; + private readonly Dictionary _config; + + private ChromaEmbeddingFunctionReference(string name, Dictionary config) + { + _name = name; + _config = config; + } + + /// + /// A function known to the clients of Chroma by its name, with its settings. + /// + /// The name of the function, like chroma_bm25. + /// The settings of the function, or null for none. + /// The reference to the function. + public static ChromaEmbeddingFunctionReference Known(string name, IReadOnlyDictionary? config = null) + => new(name, config is null ? [] : config.ToDictionary(x => x.Key, x => x.Value)); // a copy: later changes to config do not reach the schemas + + /// + /// The BM25 function of Chroma, chroma_bm25, with the settings the Python client of Chroma 1.5.9 uses by default: + /// k 1.2, b 0.75, avg_doc_length 256, token_max_length 40, include_tokens false. + /// + /// The saturation of the term frequency. + /// How much the length of the text weighs. + /// The length of an average text, in tokens. + /// The longest token kept, in characters; the longer ones are dropped. + /// Whether the vectors hold their tokens. + /// The reference to the function. + public static ChromaEmbeddingFunctionReference ChromaBm25(double k = 1.2, double b = 0.75, double avgDocLength = 256, int tokenMaxLength = 40, bool includeTokens = false) + => new("chroma_bm25", new() + { + ["k"] = k, + ["b"] = b, + ["avg_doc_length"] = avgDocLength, + ["token_max_length"] = tokenMaxLength, + ["include_tokens"] = includeTokens, + }); + +#if !NETSTANDARD2_0 + // The generator of the embedding function of Chroma has the provider "chroma" and the model all-MiniLM-L6-v2: the function "default" + // of the Python client, which stores it with no settings. The client recognizes it by its metadata, so that it needs no package of + // the generator. Another generator declares nothing. + internal static ChromaEmbeddingFunctionReference? Declared(IEmbeddingGenerator>? generator) + => generator?.GetService(typeof(EmbeddingGeneratorMetadata)) is EmbeddingGeneratorMetadata { ProviderName: "chroma", DefaultModelId: "all-MiniLM-L6-v2" } + ? Known("default") + : null; +#endif + + internal Dictionary ToJson() + => new() { ["type"] = "known", ["name"] = _name, ["config"] = _config }; +} diff --git a/ChromaDB.Client/ChromaException.cs b/ChromaDB.Client/ChromaException.cs index cf2d617..cc26858 100644 --- a/ChromaDB.Client/ChromaException.cs +++ b/ChromaDB.Client/ChromaException.cs @@ -1,8 +1,52 @@ -namespace ChromaDB.Client; +using System.Net; +namespace ChromaDB.Client; + +/// +/// The error of a failed request, with the status code and the kind of error from the answer of the server, when there is one. +/// public class ChromaException : Exception { + /// + /// An exception without a message. + /// public ChromaException() { } + /// + /// An exception with the given message. + /// + /// The message. public ChromaException(string? message) : base(message) { } + /// + /// An exception with the given message and the exception that caused it. + /// + /// The message. + /// The exception that caused this one. public ChromaException(string? message, Exception? inner) : base(message, inner) { } + + /// + /// The status code of the answer of the server; null when there was no answer, like on a timeout, or when the client found the error. + /// + public HttpStatusCode? StatusCode { get; init; } + + /// + /// The kind of error that the server names, like NotFoundError or InvalidArgumentError from Chroma 1.x, + /// InvalidCollection from Chroma 0.5 and 0.6, ValueError from the v1 API of Chroma 0.4; null when it names none. + /// + public string? ErrorType { get; init; } + + // Works around KD-42 and KD-43 (docs/COMPATIBILITY.md) + // A missing collection: 404 from Chroma 1.x, 400 or 500 from the 0.x servers, always with "does not exist" in the message, also when + // the tenant or the database is missing. A bare 404, like the one of a wrong address, is not one. + // Part of the operation went before the error, as the first batches of a write: it is not run again. + internal bool PartlyDone { get; init; } + + // How many records of a write in batches went before the error, which is the inner exception. + internal int? RecordsWent { get; init; } + + // An answer to a read whose lists do not have one item for every record (AnswerChecks): the read goes again. + internal bool UnevenAnswer { get; init; } + + internal bool IsMissingCollection + => StatusCode is HttpStatusCode.NotFound or HttpStatusCode.BadRequest or HttpStatusCode.InternalServerError + && (Message.Contains("does not exist") || ErrorType == "NotFoundError" && Message.StartsWith("Collection", StringComparison.Ordinal)); } \ No newline at end of file diff --git a/ChromaDB.Client/ChromaFunctions.cs b/ChromaDB.Client/ChromaFunctions.cs new file mode 100644 index 0000000..cb9a793 --- /dev/null +++ b/ChromaDB.Client/ChromaFunctions.cs @@ -0,0 +1,16 @@ +namespace ChromaDB.Client; + +/// +/// The functions of Chroma Cloud that can be attached to a collection, as the Python client of Chroma names them. +/// +public static class ChromaFunctions +{ + /// + /// Counts how often each metadata value occurs. + /// + public const string Statistics = "statistics"; + /// + /// Counts the records. + /// + public const string RecordCounter = "record_counter"; +} diff --git a/ChromaDB.Client/ChromaGetInclude.cs b/ChromaDB.Client/ChromaGetInclude.cs index 269bd65..8e95fde 100644 --- a/ChromaDB.Client/ChromaGetInclude.cs +++ b/ChromaDB.Client/ChromaGetInclude.cs @@ -1,12 +1,31 @@ namespace ChromaDB.Client; +/// +/// What a get returns besides the ids of the records; the values can be combined. +/// [Flags] public enum ChromaGetInclude { + /// + /// Nothing besides the ids. + /// None = 0, + /// + /// The embeddings of the records. + /// Embeddings = 1 << 0, + /// + /// The metadatas of the records. + /// Metadatas = 1 << 1, + /// + /// The documents of the records. + /// Documents = 1 << 2, + /// + /// The URIs of the records. + /// + Uris = 1 << 3, } internal static class ChromaGetIncludeExt @@ -26,6 +45,10 @@ public static List ToInclude(this ChromaGetInclude include) { result.Add("documents"); } + if (include.HasFlag(ChromaGetInclude.Uris)) + { + result.Add("uris"); + } return result; } } diff --git a/ChromaDB.Client/ChromaHealthcheck.cs b/ChromaDB.Client/ChromaHealthcheck.cs new file mode 100644 index 0000000..e91113f --- /dev/null +++ b/ChromaDB.Client/ChromaHealthcheck.cs @@ -0,0 +1,21 @@ +using System.Text.Json.Serialization; + +namespace ChromaDB.Client.Models; + +/// +/// The state of the server, from Chroma 1.0.0. +/// +public class ChromaHealthcheck +{ + /// + /// Whether the executor of the server is ready. + /// + [JsonPropertyName("is_executor_ready")] + public bool? IsExecutorReady { get; init; } + + /// + /// Whether the log client of the server is ready. + /// + [JsonPropertyName("is_log_client_ready")] + public bool? IsLogClientReady { get; init; } +} diff --git a/ChromaDB.Client/ChromaHeartbeat.cs b/ChromaDB.Client/ChromaHeartbeat.cs index 146f4e9..b6544d6 100644 --- a/ChromaDB.Client/ChromaHeartbeat.cs +++ b/ChromaDB.Client/ChromaHeartbeat.cs @@ -2,8 +2,14 @@ namespace ChromaDB.Client.Models; +/// +/// The answer of the server to a heartbeat. +/// public class ChromaHeartbeat { + /// + /// The value of nanosecond heartbeat in the answer of the server. + /// [JsonPropertyName("nanosecond heartbeat")] public long NanosecondHeartbeat { get; set; } } diff --git a/ChromaDB.Client/ChromaHnswConfiguration.cs b/ChromaDB.Client/ChromaHnswConfiguration.cs new file mode 100644 index 0000000..9298e7a --- /dev/null +++ b/ChromaDB.Client/ChromaHnswConfiguration.cs @@ -0,0 +1,100 @@ +namespace ChromaDB.Client.Models; + +/// +/// The settings of the HNSW index of a new collection, the index of a single Chroma server; the ones left null take the default of +/// the server. Chroma Cloud uses a SPANN index and ignores them: then CreateCollectionAsync throws a ChromaException. +/// +public class ChromaHnswConfiguration +{ + /// + /// The ef_construction setting: how many neighbors are looked at when a record is added. + /// + public int? EfConstruction { get; init; } + + /// + /// The ef_search setting: how many neighbors are looked at in a query. + /// + public int? EfSearch { get; init; } + + /// + /// The max_neighbors setting, M of HNSW: how many neighbors each record links to, at least 2. Chroma crashes on the + /// first write with 0 and misses the nearest records with 1: the client throws an ArgumentException for them. + /// + public int? MaxNeighbors { get; init; } + + /// + /// The resize_factor setting: how much the index grows when it is full. + /// + public double? ResizeFactor { get; init; } + + /// + /// The sync_threshold setting: how many records are added before the index is written to disk. + /// + public int? SyncThreshold { get; init; } + + /// + /// The batch_size setting: how many records are indexed together. + /// + public int? BatchSize { get; init; } + + /// + /// The num_threads setting: how many threads build the index. + /// + public int? NumThreads { get; init; } + + // Chroma 1.5.9 crashes on the first write to an HNSW index with no neighbors, and an index with one neighbor misses the nearest + // records: the client rejects them before the request, wherever they come from. + internal static void CheckMaxNeighbors(object? maxNeighbors, string paramName) + { + if (maxNeighbors is IConvertible and not string and not bool && System.Convert.ToDouble(maxNeighbors, System.Globalization.CultureInfo.InvariantCulture) < 2) + { + throw new ArgumentException($"The HNSW index needs at least 2 neighbors, not {maxNeighbors}: Chroma crashes on the first write with 0 and misses the nearest records with 1.", paramName); + } + } + + // The bounds of a long as doubles: -2^63 is long.MinValue, and long.MaxValue as a double rounds up to 2^63. + private const double TwoTo63 = 9223372036854775808.0; + + // The "hnsw:" metadata that Chroma takes only as integers: 0.6.3 and 1.5.9 reject 100.0 for them. + internal static readonly string[] IntegerMetadataKeys = ["hnsw:M", "hnsw:construction_ef", "hnsw:search_ef", "hnsw:num_threads", "hnsw:batch_size", "hnsw:sync_threshold"]; + + // A whole double, float or decimal goes as an integer, which the client would write as 100.0. A JsonElement goes as it is + // written, so it must hold an integer. Any other number throws here rather than on the server. + internal static object IntegerMetadata(string key, object value, string paramName) + => value switch + { + double d when Math.Floor(d) == d && d >= -TwoTo63 && d < TwoTo63 => (long)d, + float f when Math.Floor(f) == f && f >= -TwoTo63 && f < TwoTo63 => (long)f, + decimal m when decimal.Truncate(m) == m && m >= long.MinValue && m <= long.MaxValue => (long)m, + System.Text.Json.JsonElement { ValueKind: System.Text.Json.JsonValueKind.Number } element when element.TryGetInt64(out var integer) => integer, + double or float or decimal or System.Text.Json.JsonElement { ValueKind: System.Text.Json.JsonValueKind.Number } + => throw new ArgumentException($"Chroma takes {key} only as an integer, not {value}.", paramName), + _ => value, + }; + + // The "hnsw:" metadata of the settings, which every tested Chroma applies, as the space. + internal IEnumerable> ToMetadata() + { + if (EfConstruction is { } efConstruction) yield return new("hnsw:construction_ef", efConstruction); + if (EfSearch is { } efSearch) yield return new("hnsw:search_ef", efSearch); + if (MaxNeighbors is { } maxNeighbors) yield return new("hnsw:M", maxNeighbors); + if (ResizeFactor is { } resizeFactor) yield return new("hnsw:resize_factor", resizeFactor); + if (SyncThreshold is { } syncThreshold) yield return new("hnsw:sync_threshold", syncThreshold); + if (BatchSize is { } batchSize) yield return new("hnsw:batch_size", batchSize); + if (NumThreads is { } numThreads) yield return new("hnsw:num_threads", numThreads); + } + + // The settings in a vector index of a schema. + internal Dictionary ToJson() + { + var json = new Dictionary(); + if (EfConstruction is { } efConstruction) json["ef_construction"] = efConstruction; + if (EfSearch is { } efSearch) json["ef_search"] = efSearch; + if (MaxNeighbors is { } maxNeighbors) json["max_neighbors"] = maxNeighbors; + if (ResizeFactor is { } resizeFactor) json["resize_factor"] = resizeFactor; + if (SyncThreshold is { } syncThreshold) json["sync_threshold"] = syncThreshold; + if (BatchSize is { } batchSize) json["batch_size"] = batchSize; + if (NumThreads is { } numThreads) json["num_threads"] = numThreads; + return json; + } +} diff --git a/ChromaDB.Client/ChromaHnswConfigurationUpdate.cs b/ChromaDB.Client/ChromaHnswConfigurationUpdate.cs new file mode 100644 index 0000000..176e8a8 --- /dev/null +++ b/ChromaDB.Client/ChromaHnswConfigurationUpdate.cs @@ -0,0 +1,52 @@ +using System.Text.Json.Serialization; + +namespace ChromaDB.Client.Models; + +/// +/// The settings of the HNSW index that can change; the ones left null keep their value. +/// +public class ChromaHnswConfigurationUpdate +{ + /// + /// The ef_search setting of the HNSW index. + /// + [JsonPropertyName("ef_search")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public int? EfSearch { get; init; } + + /// + /// The max_neighbors setting of the HNSW index, at least 2: ModifyConfigurationAsync throws an + /// ArgumentException for less, as Chroma crashes on the next write with 0 and misses the nearest records with 1. + /// + [JsonPropertyName("max_neighbors")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public int? MaxNeighbors { get; init; } + + /// + /// The num_threads setting of the HNSW index. + /// + [JsonPropertyName("num_threads")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public int? NumThreads { get; init; } + + /// + /// The batch_size setting of the HNSW index. + /// + [JsonPropertyName("batch_size")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public int? BatchSize { get; init; } + + /// + /// The sync_threshold setting of the HNSW index. + /// + [JsonPropertyName("sync_threshold")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public int? SyncThreshold { get; init; } + + /// + /// The resize_factor setting of the HNSW index. + /// + [JsonPropertyName("resize_factor")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public double? ResizeFactor { get; init; } +} diff --git a/ChromaDB.Client/ChromaIndexingStatus.cs b/ChromaDB.Client/ChromaIndexingStatus.cs new file mode 100644 index 0000000..bb50421 --- /dev/null +++ b/ChromaDB.Client/ChromaIndexingStatus.cs @@ -0,0 +1,33 @@ +using System.Text.Json.Serialization; + +namespace ChromaDB.Client.Models; + +/// +/// How far Chroma Cloud has indexed the writes to a collection. +/// +public class ChromaIndexingStatus +{ + /// + /// The indexing progress of the writes, from 0 to 1. + /// + [JsonPropertyName("op_indexing_progress")] + public float OpIndexingProgress { get; init; } + + /// + /// How many writes are indexed. + /// + [JsonPropertyName("num_indexed_ops")] + public long NumIndexedOps { get; init; } + + /// + /// How many writes are not indexed yet. + /// + [JsonPropertyName("num_unindexed_ops")] + public long NumUnindexedOps { get; init; } + + /// + /// The total number of writes. + /// + [JsonPropertyName("total_ops")] + public long TotalOps { get; init; } +} diff --git a/ChromaDB.Client/ChromaMetadataConvert.cs b/ChromaDB.Client/ChromaMetadataConvert.cs new file mode 100644 index 0000000..b661e32 --- /dev/null +++ b/ChromaDB.Client/ChromaMetadataConvert.cs @@ -0,0 +1,168 @@ +using System.Collections; +using System.Globalization; + +namespace ChromaDB.Client; + +/// +/// Converts .NET values to metadata values and back, always in the same form, so that a value is read back as it was written and a +/// filter with the converted value finds it. Chroma metadata holds strings, integers, floating-point numbers, Booleans, and lists of +/// them from Chroma 1.5.0: dates go as text. The client does not convert the values of a metadata dictionary by itself. +/// +public static class ChromaMetadataConvert +{ + /// + /// The metadata value of a .NET value: + /// + /// a string, a bool, an int, a long, a float or a double as it is; + /// a DateTimeOffset as round-trip text ("O") in UTC, so that equal instants are equal text, as == compares them; + /// a DateTime as round-trip text with its Kind, which it reads back with; + /// a DateOnly as yyyy-MM-dd; + /// a sequence of one of these as a list; + /// null, and an empty sequence, as null: Chroma stores neither, so the key has no value. + /// + /// + /// The .NET value. + /// The metadata value, or null for no value. + /// The value has another type, or a sequence holds a null, a value of another type, or values of + /// more than one of the types of Chroma: strings, integers, floating-point numbers and Booleans. + public static object? ToMetadataValue(object? value) + => value switch + { + null => null, + string => value, + IEnumerable values => ToList(values), + _ => ToScalar(value), + }; + + /// + /// The metadata of a record from .NET values, each converted with ToMetadataValue. A value that converts to null, as null and + /// an empty sequence do, stays in the metadata as null: in UpdateAsync and UpsertAsync it deletes the key. + /// + /// The keys and the .NET values. + /// The metadata, or null without values. + /// A value has a type that ToMetadataValue does not take, or a key comes twice. + public static IReadOnlyDictionary? ToMetadata(IEnumerable> values) + { + Dictionary? metadata = null; + foreach (var pair in values ?? throw new ArgumentNullException(nameof(values))) + { + (metadata ??= []).Add(pair.Key, ToMetadataValue(pair.Value)!); + } + return metadata; + } + + // A list of Chroma holds values of one type: an int and a long are both integers, a float and a double both floating-point numbers. + private static List? ToList(IEnumerable values) + { + var list = new List(); + Type? listType = null; + foreach (var item in values) + { + var value = item is null ? throw new ArgumentException("A list in Chroma metadata cannot hold null.", nameof(values)) : ToScalar(item); + var type = value switch { int or long => typeof(long), float or double => typeof(double), _ => value.GetType() }; + if ((listType ??= type) != type) + { + throw new ArgumentException("A list in Chroma metadata holds values of one type: strings, integers, floating-point numbers or Booleans.", nameof(values)); + } + list.Add(value); + } + return list.Count == 0 ? null : list; + } + + private static object ToScalar(object value) + => value switch + { + string or bool or int or long or float or double => value, + DateTimeOffset dateTimeOffset => dateTimeOffset.ToUniversalTime().ToString("O", CultureInfo.InvariantCulture), + DateTime dateTime => dateTime.ToString("O", CultureInfo.InvariantCulture), +#if NET + DateOnly date => date.ToString("O", CultureInfo.InvariantCulture), +#endif + _ => throw new ArgumentException($"Chroma metadata has no value of type {value.GetType().Name}.", nameof(value)), + }; + + /// + /// The .NET value of the given type for a metadata value, as ChromaMetadataValues.Exact reads it: a long, a + /// double, a bool, a string, or a list of them. The types are the ones of ToMetadataValue, nullable or not, and + /// arrays and List<T> of them. An integer reads as any number type, checked for an int; a floating-point number + /// as a double or a float; a date from its text. A null value is null. + /// + /// The metadata value. + /// The type to read it as. + /// The value of the type, or null. + /// The value does not convert to the type: another type, a date that does not parse, or an integer beyond an int. + public static object? FromMetadataValue(object? value, Type type) + { + type = Nullable.GetUnderlyingType(type) ?? type; + return value switch + { + null => null, + IList list => FromList(list, type), + _ => FromScalar(value, type), + }; + } + + private static object FromList(IList list, Type type) + => Collection(list, type) + ?? Collection(list, type) + ?? Collection(list, type) + ?? Collection(list, type) + ?? Collection(list, type) + ?? Collection(list, type) + ?? Collection(list, type) + ?? Collection(list, type) +#if NET + ?? Collection(list, type) +#endif + ?? throw CannotRead(list, type); + + // An array or a list of T, or null for another type. + private static object? Collection(IList list, Type type) + { + if (type != typeof(T[]) && type != typeof(List)) + { + return null; + } + var items = new List(list.Count); + foreach (var item in list) + { + items.Add(item is null ? throw CannotRead(list, type) : (T)FromScalar(item, typeof(T))); + } + return type == typeof(T[]) ? items.ToArray() : items; + } + + private static object FromScalar(object value, Type type) + { + try + { + return (value, type) switch + { + (string text, var t) when t == typeof(string) => text, + (string text, var t) when t == typeof(DateTimeOffset) => DateTimeOffset.Parse(text, CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind), + (string text, var t) when t == typeof(DateTime) => DateTime.Parse(text, CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind), +#if NET + (string text, var t) when t == typeof(DateOnly) => DateOnly.Parse(text, CultureInfo.InvariantCulture), +#endif + (bool flag, var t) when t == typeof(bool) => flag, + (long number, var t) when t == typeof(long) => number, + (long number, var t) when t == typeof(int) => checked((int)number), + (long number, var t) when t == typeof(double) => (double)number, + (long number, var t) when t == typeof(float) => (float)number, + (double number, var t) when t == typeof(double) => number, + (double number, var t) when t == typeof(float) => (float)number, + _ => throw CannotRead(value, type), + }; + } + catch (FormatException ex) + { + throw CannotRead(value, type, ex); + } + catch (OverflowException ex) + { + throw CannotRead(value, type, ex); + } + } + + private static InvalidCastException CannotRead(object value, Type type, Exception? inner = null) + => new($"Cannot read the metadata value of type {value.GetType().Name} as {type.Name}.", inner); +} diff --git a/ChromaDB.Client/ChromaMetadataValues.cs b/ChromaDB.Client/ChromaMetadataValues.cs new file mode 100644 index 0000000..069feba --- /dev/null +++ b/ChromaDB.Client/ChromaMetadataValues.cs @@ -0,0 +1,17 @@ +namespace ChromaDB.Client; + +/// +/// How the client reads the values of the metadata of collections and records. +/// +public enum ChromaMetadataValues +{ + /// + /// A string that looks like a date becomes a DateTime, and a list stays a JsonElement. + /// + Inferred, + /// + /// A string stays a string, and a list becomes a List<object> of string, long, double + /// and bool, like the single values, and a sparse vector a ChromaSparseVector. The default. + /// + Exact, +} diff --git a/ChromaDB.Client/ChromaPreFlightChecks.cs b/ChromaDB.Client/ChromaPreFlightChecks.cs new file mode 100644 index 0000000..15e18a5 --- /dev/null +++ b/ChromaDB.Client/ChromaPreFlightChecks.cs @@ -0,0 +1,22 @@ +using System.Text.Json.Serialization; + +namespace ChromaDB.Client.Models; + +/// +/// The limits of the server, to check before sending large requests. +/// +public class ChromaPreFlightChecks +{ + /// + /// The most records a single add, update, upsert or delete can carry. + /// + [JsonPropertyName("max_batch_size")] + public int MaxBatchSize { get; init; } + + /// + /// Whether the server takes embeddings encoded in base64, which AddAsync, UpdateAsync and UpsertAsync then send. + /// Null when the server does not send it, like Chroma 1.0.12 and earlier. + /// + [JsonPropertyName("supports_base64_encoding")] + public bool? SupportsBase64Encoding { get; init; } +} diff --git a/ChromaDB.Client/ChromaQuery.cs b/ChromaDB.Client/ChromaQuery.cs new file mode 100644 index 0000000..0a0a648 --- /dev/null +++ b/ChromaDB.Client/ChromaQuery.cs @@ -0,0 +1,63 @@ +namespace ChromaDB.Client.Models; + +/// +/// The query to run. A new field of Chroma becomes a new property here, without changing the methods. +/// +public class ChromaQuery +{ + /// + /// The embeddings to search for; the results come in one list per embedding. + /// + public IReadOnlyList> QueryEmbeddings { get; } + /// + /// How many results to return for each query embedding, 10 by default. + /// + public int NResults + { + get => _nResults; + init => _nResults = value >= 0 ? value : throw new ArgumentOutOfRangeException(nameof(NResults), value, "The number of results of a query cannot be negative."); + } + + private int _nResults = 10; + /// + /// How many of the nearest records to skip for each query embedding before the results, 0 by default. Chroma has no offset in + /// queries: the client asks for NResults plus the offset, and leaves out the first ones. + /// + public int Offset + { + get => _offset; + init => _offset = value >= 0 ? value : throw new ArgumentOutOfRangeException(nameof(Offset), value, "The offset of a query cannot be negative."); + } + private readonly int _offset; + /// + /// The filter on the metadata. + /// + public ChromaWhereOperator? Where { get; init; } + /// + /// The filter on the documents. + /// + public ChromaWhereDocumentOperator? WhereDocument { get; init; } + /// + /// What the results include. When null, the metadatas, the documents and the distances. + /// + public ChromaQueryInclude? Include { get; init; } + /// + /// Searches only the records with these ids. Chroma 0.x ignores them: then QueryAsync throws a + /// ChromaException. + /// + public IReadOnlyList? Ids { get; init; } + /// + /// The space the distances of the results are expected in: QueryAsync throws an InvalidOperationException, before + /// the query, when the collection has another one. A collection whose space the server does not report is taken. Null for any. + /// + public ChromaSpace? ExpectedSpace { get; init; } + + /// + /// Creates a query for the embeddings. + /// + /// The query embeddings: the results are one list for each. + public ChromaQuery(IReadOnlyList> queryEmbeddings) + { + QueryEmbeddings = queryEmbeddings; + } +} diff --git a/ChromaDB.Client/ChromaQueryInclude.cs b/ChromaDB.Client/ChromaQueryInclude.cs index 2b13670..3aa775b 100644 --- a/ChromaDB.Client/ChromaQueryInclude.cs +++ b/ChromaDB.Client/ChromaQueryInclude.cs @@ -1,13 +1,35 @@ namespace ChromaDB.Client; +/// +/// What a query returns besides the ids of the results; the values can be combined. +/// [Flags] public enum ChromaQueryInclude { + /// + /// Nothing besides the ids. + /// None = 0, + /// + /// The embeddings of the results. + /// Embeddings = 1 << 0, + /// + /// The metadatas of the results. + /// Metadatas = 1 << 1, + /// + /// The documents of the results. + /// Documents = 1 << 2, + /// + /// The distances of the results from the query embedding. + /// Distances = 1 << 3, + /// + /// The URIs of the results. + /// + Uris = 1 << 4, } internal static class ChromaQueryIncludeExt @@ -31,6 +53,10 @@ public static List ToInclude(this ChromaQueryInclude include) { result.Add("distances"); } + if (include.HasFlag(ChromaQueryInclude.Uris)) + { + result.Add("uris"); + } return result; } } diff --git a/ChromaDB.Client/ChromaRank.cs b/ChromaDB.Client/ChromaRank.cs new file mode 100644 index 0000000..b9616ba --- /dev/null +++ b/ChromaDB.Client/ChromaRank.cs @@ -0,0 +1,314 @@ +namespace ChromaDB.Client; + +/// +/// An expression that ranks the records of a search, rank in the Search API of Chroma: the records with the lowest score +/// come first. The operators +, -, *, / and the unary - combine expressions as the Python client of +/// Chroma does, and a number converts to Value. ToString() gives the JSON. +/// +public abstract class ChromaRank +{ + private protected ChromaRank() + { } + + internal Dictionary ToRank() + => ToRank(null); + + // embedText gives the sparse vector of a text query on a key; without it, the text goes as it is. + internal abstract Dictionary ToRank(Func? embedText); + + /// + /// The JSON of the expression, as the client sends it in rank. A text query of SparseKnn is in it as the text: + /// ChromaCollectionClient.SearchAsync sends its sparse vector instead. + /// + /// The JSON. + public override string ToString() + => System.Text.Json.JsonSerializer.Serialize(ToRank(), Common.HttpClientHelpers.TypeInfo>(Common.HttpClientHelpers.PostJsonSerializerOptions)); + + /// + /// The distance of each record from the query, with $knn, among the limit nearest records of the key, #embedding + /// by default. The other records get defaultScore, or are left out when it is null. With returnRank the score is the + /// position of the record, 0 for the nearest, as Rrf needs. + /// + /// The query embedding. + /// The key of the index: #embedding for the embeddings, or a metadata key with a sparse vector index. + /// How many nearest records the expression ranks. + /// The score of the records beyond the limit, or null to leave them out. + /// Whether the score is the position of the record, 0 for the nearest, as Rrf needs. + /// The expression. + public static ChromaRank Knn(ReadOnlyMemory query, string key = ChromaSearchKeys.Embedding, int limit = 16, double? defaultScore = null, bool returnRank = false) + => new ChromaKnnRank(query.ToArray(), key, limit, defaultScore, returnRank); + + /// + /// The same as Knn with a sparse vector as the query, on a metadata key that has a sparse vector index, like the BM25 vectors + /// of a text: $knn with the sparse vector in query. A name of its own, so that Knn(new(...), key) stays unambiguous. + /// + /// The query: a sparse vector, or a text that ChromaCollectionClient.SearchAsync turns into one with the function of the index. + /// The key of the index: #embedding for the embeddings, or a metadata key with a sparse vector index. + /// How many nearest records the expression ranks. + /// The score of the records beyond the limit, or null to leave them out. + /// Whether the score is the position of the record, 0 for the nearest, as Rrf needs. + /// The expression. + public static ChromaRank SparseKnn(Models.ChromaSparseVector query, string key, int limit = 16, double? defaultScore = null, bool returnRank = false) + => new ChromaKnnRank(query, key, limit, defaultScore, returnRank); + + /// + /// The same as SparseKnn with the sparse vector of a text, which ChromaCollectionClient.SearchAsync computes with the function + /// of the sparse vector index of the key, chroma_bm25, as the Python client of Chroma does. The collection of the client needs + /// its schema, as GetCollectionAsync and CreateCollectionAsync return it. + /// + /// The query: a sparse vector, or a text that ChromaCollectionClient.SearchAsync turns into one with the function of the index. + /// The key of the index: #embedding for the embeddings, or a metadata key with a sparse vector index. + /// How many nearest records the expression ranks. + /// The score of the records beyond the limit, or null to leave them out. + /// Whether the score is the position of the record, 0 for the nearest, as Rrf needs. + /// The expression. + public static ChromaRank SparseKnn(string query, string key, int limit = 16, double? defaultScore = null, bool returnRank = false) + => new ChromaKnnRank(query, key, limit, defaultScore, returnRank); + + /// + /// A constant, with $val. + /// + /// The constant. + /// The expression. + public static ChromaRank Value(double value) + => new ChromaValueRank(value); + + /// + /// A number as a constant, with $val. + /// + /// The constant. + public static implicit operator ChromaRank(double value) + => Value(value); + + /// + /// The sum of the expressions, with $sum. + /// + /// The expressions. + /// The expression. + public static ChromaRank Sum(params ChromaRank[] ranks) + => new ChromaListRank("$sum", ranks); + + /// + /// The product of the expressions, with $mul. + /// + /// The expressions. + /// The expression. + public static ChromaRank Multiply(params ChromaRank[] ranks) + => new ChromaListRank("$mul", ranks); + + /// + /// The largest of the expressions, with $max. + /// + /// The expressions. + /// The expression. + public static ChromaRank Max(params ChromaRank[] ranks) + => new ChromaListRank("$max", ranks); + + /// + /// The smallest of the expressions, with $min. + /// + /// The expressions. + /// The expression. + public static ChromaRank Min(params ChromaRank[] ranks) + => new ChromaListRank("$min", ranks); + + /// + /// The absolute value, with $abs. + /// + /// The expression. + /// The expression. + public static ChromaRank Abs(ChromaRank rank) + => new ChromaUnaryRank("$abs", rank); + + /// + /// The exponential, with $exp. + /// + /// The expression. + /// The expression. + public static ChromaRank Exp(ChromaRank rank) + => new ChromaUnaryRank("$exp", rank); + + /// + /// The natural logarithm, with $log. + /// + /// The expression. + /// The expression. + public static ChromaRank Log(ChromaRank rank) + => new ChromaUnaryRank("$log", rank); + + /// + /// Reciprocal rank fusion of the expressions, built as the Python client of Chroma builds it: -(w1 / (k + rank1) + w2 / (k + rank2) + ...). + /// The expressions are usually Knn with returnRank. The weights are 1 by default, must be as many as the expressions and + /// not negative; with normalize they are scaled to sum to 1. k must be positive. + /// + /// The expressions to fuse, usually Knn with returnRank. + /// The constant added to each rank; positive. + /// The weight of each expression, not negative, or null for 1 each. + /// Whether the weights are scaled to sum to 1. + /// The expression. + public static ChromaRank Rrf(IReadOnlyList ranks, double k = 60, IReadOnlyList? weights = null, bool normalize = false) + { + if (ranks is not { Count: > 0 }) + { + throw new ArgumentException("RRF needs at least one rank.", nameof(ranks)); + } + if (k <= 0) + { + throw new ArgumentOutOfRangeException(nameof(k), "The k of RRF must be positive."); + } + if (weights is not null && weights.Count != ranks.Count) + { + throw new ArgumentException("RRF needs one weight for each rank.", nameof(weights)); + } + if (weights is not null && weights.Any(weight => weight < 0)) + { + throw new ArgumentException("The weights of RRF cannot be negative.", nameof(weights)); + } + var actual = weights?.ToList() ?? ranks.Select(_ => 1.0).ToList(); + if (normalize) + { + var total = actual.Sum(); + if (total == 0) + { + throw new ArgumentException("The weights of RRF must have a positive sum to be normalized.", nameof(weights)); + } + actual = actual.Select(weight => weight / total).ToList(); + } + var sum = ranks.Select((rank, i) => Value(actual[i]) / (Value(k) + rank)).Aggregate((left, right) => left + right); + return -sum; + } + + /// + /// Reciprocal rank fusion of the records nearest to the embedding and of a BM25 search of the text, as Rrf builds it, where + /// only the records with a term of the text get points from the text. A sparse search of Chroma ranks every record, at the score 1, + /// one minus the dot product, for a record without any term: here a record counts for the text only with a dot product above one + /// millionth. Each search ranks its first limit records, and gives the others the rank limit. Chroma ranks the lowest + /// score first, so the client sends the opposite of the fused score, and gives the results of a search ranked by it with the fused + /// score, the highest first. + /// + /// The query embedding. + /// The text whose terms the BM25 search looks for; the client computes its vector with the function of the index. + /// The key of the BM25 index, like the one that ChromaCollection.FindBm25Index returns. + /// How many records each search ranks: at least the results and the records the search skips. + /// The constant added to each rank; positive. + /// The key of the embeddings. + /// The expression. + public static ChromaRank HybridRrf(ReadOnlyMemory embedding, string text, string sparseKey, int limit, double k = 60, string embeddingKey = ChromaSearchKeys.Embedding) + { + if (limit <= 0) + { + throw new ArgumentOutOfRangeException(nameof(limit), "The limit of a hybrid search must be positive."); + } + if (k <= 0) + { + throw new ArgumentOutOfRangeException(nameof(k), "The k of RRF must be positive."); + } + var embeddingRank = Knn(embedding, embeddingKey, limit, defaultScore: limit, returnRank: true); + var textRank = SparseKnn(text, sparseKey, limit, defaultScore: limit, returnRank: true); + var textScore = SparseKnn(text, sparseKey, limit, defaultScore: 1); + // 1 for a record with a term of the text, whose dot product is positive, and 0 for the others, never below. + var hasTerm = Max(0, Min(1, (1 - textScore) * 1_000_000)); + return new ChromaOppositeScoreRank(-(1 / (k + embeddingRank) + hasTerm / (k + textRank))); + } + + /// + /// The sum, with $sum; sums are flattened into one, as in the Python client of Chroma. + /// + /// The left expression. + /// The right expression. + /// The expression. + public static ChromaRank operator +(ChromaRank left, ChromaRank right) + => new ChromaListRank("$sum", Flatten("$sum", left).Concat(Flatten("$sum", right)).ToArray()); + + /// + /// The difference, with $sub. + /// + /// The left expression. + /// The right expression. + /// The expression. + public static ChromaRank operator -(ChromaRank left, ChromaRank right) + => new ChromaBinaryRank("$sub", left, right); + + /// + /// The product, with $mul; products are flattened into one, as in the Python client of Chroma. + /// + /// The left expression. + /// The right expression. + /// The expression. + public static ChromaRank operator *(ChromaRank left, ChromaRank right) + => new ChromaListRank("$mul", Flatten("$mul", left).Concat(Flatten("$mul", right)).ToArray()); + + /// + /// The quotient, with $div. + /// + /// The left expression. + /// The right expression. + /// The expression. + public static ChromaRank operator /(ChromaRank left, ChromaRank right) + => new ChromaBinaryRank("$div", left, right); + + /// + /// The opposite, as the product with -1. + /// + /// The expression. + /// The expression. + public static ChromaRank operator -(ChromaRank rank) + => new ChromaListRank("$mul", [Value(-1), rank]); + + private static IEnumerable Flatten(string @operator, ChromaRank rank) + => rank is ChromaListRank list && list.Operator == @operator ? list.Ranks : [rank]; +} + +// The query is a float[], a ChromaSparseVector or a text. +internal sealed class ChromaKnnRank(object query, string key, int limit, double? defaultScore, bool returnRank) : ChromaRank +{ + // Like the Python client of Chroma: "default" and "return_rank" only when set. + internal override Dictionary ToRank(Func? embedText) + { + var vector = query is string text && embedText is not null ? embedText(key, text) : query; + var knn = new Dictionary { ["query"] = vector, ["key"] = key, ["limit"] = limit }; + if (defaultScore is { } score) + { + knn["default"] = score; + } + if (returnRank) + { + knn["return_rank"] = true; + } + return new() { ["$knn"] = knn }; + } +} + +// The rank of HybridRrf, the opposite of the fused score: the results of a search ranked by it come back with the fused score. +internal sealed class ChromaOppositeScoreRank(ChromaRank rank) : ChromaRank +{ + internal override Dictionary ToRank(Func? embedText) + => rank.ToRank(embedText); +} + +internal sealed class ChromaValueRank(double value) : ChromaRank +{ + internal override Dictionary ToRank(Func? embedText) + => new() { ["$val"] = value }; +} + +internal sealed class ChromaListRank(string @operator, ChromaRank[] ranks) : ChromaRank +{ + public string Operator { get; } = @operator; + public ChromaRank[] Ranks { get; } = ranks; + + internal override Dictionary ToRank(Func? embedText) + => new() { [Operator] = Ranks.Select(rank => (object)rank.ToRank(embedText)).ToArray() }; +} + +internal sealed class ChromaUnaryRank(string @operator, ChromaRank rank) : ChromaRank +{ + internal override Dictionary ToRank(Func? embedText) + => new() { [@operator] = rank.ToRank(embedText) }; +} + +internal sealed class ChromaBinaryRank(string @operator, ChromaRank left, ChromaRank right) : ChromaRank +{ + internal override Dictionary ToRank(Func? embedText) + => new() { [@operator] = new Dictionary { ["left"] = left.ToRank(embedText), ["right"] = right.ToRank(embedText) } }; +} diff --git a/ChromaDB.Client/ChromaReadLevel.cs b/ChromaDB.Client/ChromaReadLevel.cs new file mode 100644 index 0000000..bfb47d1 --- /dev/null +++ b/ChromaDB.Client/ChromaReadLevel.cs @@ -0,0 +1,21 @@ +namespace ChromaDB.Client; + +/// +/// What a read looks at, read_level in Chroma. It matters on Chroma Cloud, where new records are indexed later; +/// a single server indexes them at once and accepts it without a difference. +/// +public enum ChromaReadLevel +{ + /// + /// The index and the records not yet indexed, the default of Chroma. + /// + IndexAndWal, + /// + /// Only the records already indexed: faster, without the latest writes. + /// + IndexOnly, + /// + /// The index and a bounded part of the records not yet indexed. + /// + IndexAndBoundedWal, +} diff --git a/ChromaDB.Client/ChromaReadRetries.cs b/ChromaDB.Client/ChromaReadRetries.cs new file mode 100644 index 0000000..759bcf5 --- /dev/null +++ b/ChromaDB.Client/ChromaReadRetries.cs @@ -0,0 +1,102 @@ +namespace ChromaDB.Client; + +/// +/// How a read goes again after an error that can pass, with an exponential backoff with jitter: before each new attempt it waits a +/// random time between MinDelay and the smaller of MaxDelay and BaseDelay times 2 to the number of the attempts +/// that went again before. MinDelay keeps a read from going again at once; without it, it is 0.8 times BaseDelay, the +/// lower bound of the jitter of Azure.Core. The reads are each request of GetAsync, PeekAsync, +/// QueryAsync, SearchAsync and CountAsync, and the reads that a write makes first, as the get of +/// ChromaUpsertStrategy.SkipUnchangedEmbeddings, the get of ChromaConfigurationOptions.ReadsBeyondKeyQuota and the +/// pages of ids of DeleteCollectionAsync with deleteRecordsFirst. The errors that can pass are an answer 429, +/// 500, 502, 503 or 504, other than a missing collection, a request that fails on the network, and an +/// answer whose lists do not have one item for every record. After the last attempt the error stands: for such an answer, a +/// ChromaException that says it. A timeout of the HttpClient does not go again. A write never goes again: one that +/// failed may have gone. +/// +public sealed class ChromaReadRetries +{ + /// + /// The attempts and the delays, with a smallest delay of 0.8 times baseDelay. + /// + /// How many times a read goes at most, the first included: 1 never goes again. + /// The largest delay before the second attempt; each following one can double it. + /// The largest delay before any attempt, at least baseDelay and at most Int32.MaxValue + /// milliseconds, the longest delay of Task.Delay. + public ChromaReadRetries(int maxAttempts, TimeSpan baseDelay, TimeSpan maxDelay) + : this(maxAttempts, baseDelay < TimeSpan.Zero ? TimeSpan.Zero : TimeSpan.FromTicks(baseDelay.Ticks / 5 * 4), baseDelay, maxDelay) + { + } + + /// + /// The attempts and the delays. + /// + /// How many times a read goes at most, the first included: 1 never goes again. + /// The smallest delay before any attempt, from zero to baseDelay. + /// The largest delay before the second attempt; each following one can double it. + /// The largest delay before any attempt, at least baseDelay and at most Int32.MaxValue + /// milliseconds, the longest delay of Task.Delay. + public ChromaReadRetries(int maxAttempts, TimeSpan minDelay, TimeSpan baseDelay, TimeSpan maxDelay) + { + if (maxAttempts < 1) + { + throw new ArgumentOutOfRangeException(nameof(maxAttempts), "A read goes at least once."); + } + // Beyond Int32.MaxValue milliseconds, the longest delay of Task.Delay on every platform of the client, Task.Delay would throw in + // the middle of a read. + if (baseDelay < TimeSpan.Zero || maxDelay < baseDelay || maxDelay.TotalMilliseconds > int.MaxValue) + { + throw new ArgumentOutOfRangeException(baseDelay < TimeSpan.Zero ? nameof(baseDelay) : nameof(maxDelay), "The delays cannot be negative, the largest one is at least the base, and it is at most Int32.MaxValue milliseconds, the longest delay of Task.Delay."); + } + if (minDelay < TimeSpan.Zero || minDelay > baseDelay) + { + throw new ArgumentOutOfRangeException(nameof(minDelay), "The smallest delay is from zero to the base."); + } + MaxAttempts = maxAttempts; + MinDelay = minDelay; + BaseDelay = baseDelay; + MaxDelay = maxDelay; + } + + /// + /// How many times a read goes at most, the first included. + /// + public int MaxAttempts { get; } + + /// + /// The smallest delay before any attempt. + /// + public TimeSpan MinDelay { get; } + + /// + /// The largest delay before the second attempt; each following one can double it. + /// + public TimeSpan BaseDelay { get; } + + /// + /// The largest delay before any attempt. + /// + public TimeSpan MaxDelay { get; } + + /// + /// The default of ChromaConfigurationOptions.ReadRetries. + /// + public static ChromaReadRetries Default { get; } = new(4, TimeSpan.FromMilliseconds(50), TimeSpan.FromSeconds(1)); + + /// + /// A read goes once, and its error stands. + /// + public static ChromaReadRetries None { get; } = new(1, TimeSpan.Zero, TimeSpan.Zero); + + // The random number from 0 to 1 of each delay; null takes the shared Random of the client. Only the tests set it, to make a delay + // certain. + internal Func? Jitter { get; init; } + + // The delay before the attempt after the given number of attempts that went again, for a random number from 0 to 1. + internal TimeSpan Delay(int retry, double random) + { + // The base times 2 to the retry, up to the largest delay, without going beyond a long; never under the smallest delay, which is + // at most the base. + var bound = retry >= 62 || BaseDelay.Ticks > MaxDelay.Ticks >> retry ? MaxDelay.Ticks : BaseDelay.Ticks << retry; + return TimeSpan.FromTicks(MinDelay.Ticks + (long)((bound - MinDelay.Ticks) * random)); + } +} diff --git a/ChromaDB.Client/ChromaRecords.cs b/ChromaDB.Client/ChromaRecords.cs new file mode 100644 index 0000000..3e4ec03 --- /dev/null +++ b/ChromaDB.Client/ChromaRecords.cs @@ -0,0 +1,52 @@ +namespace ChromaDB.Client.Models; + +/// +/// The records to add, update or upsert. A new field of Chroma becomes a new property here, without changing the +/// methods. +/// +public class ChromaRecords +{ + /// + /// The ids of the records. + /// + public IReadOnlyList Ids { get; } + /// + /// The embeddings of the records, in the order of the ids. + /// + public IReadOnlyList>? Embeddings { get; init; } + /// + /// The metadatas of the records, in the order of the ids; null for a record without metadata. An empty metadata is sent as no + /// metadata, null: every tested Chroma, 0.4.10 to 1.5.9 and Chroma Cloud, treats the two the same. In UpdateAsync and + /// UpsertAsync a null value, written null! as the type does not allow it, or an empty list deletes the key, and the + /// sparse vectors the client computes from its text; the keys the metadata does not have stay. AddAsync rejects both. + /// ChromaMetadataConvert.ToMetadata builds a metadata with null values from .NET values. + /// + public IReadOnlyList?>? Metadatas { get; init; } + /// + /// The documents of the records, in the order of the ids; null for a record without a document. In UpdateAsync and + /// UpsertAsync a null document keeps the stored one, as in Chroma 1.x and Chroma Cloud, unless NullDocumentsDelete; the + /// 0.x servers delete it. + /// + public IReadOnlyList? Documents { get; init; } + /// + /// Whether a null document deletes the stored document in UpdateAsync and UpsertAsync, and the sparse vectors the + /// client computes from it, instead of keeping it as Chroma 1.x does. Chroma 1.x has no deletion of a document: the client writes an empty + /// one, which reads back as null from a client with WithDocumentCopyKey or WithDocumentDeletionKey, whose copy or mark + /// tells it from an empty text, and as an empty string from a client with WithEmptyDocumentDeletions. A client without one of + /// them throws an ArgumentException for a null document, before any request. + /// + public bool NullDocumentsDelete { get; init; } + /// + /// The URIs of the records, in the order of the ids. A record without a URI has null at its position. + /// + public IReadOnlyList? Uris { get; init; } + + /// + /// Creates the records with the ids. + /// + /// The ids of the records. + public ChromaRecords(IReadOnlyList ids) + { + Ids = ids; + } +} diff --git a/ChromaDB.Client/ChromaSchemaIndex.cs b/ChromaDB.Client/ChromaSchemaIndex.cs new file mode 100644 index 0000000..c1f0375 --- /dev/null +++ b/ChromaDB.Client/ChromaSchemaIndex.cs @@ -0,0 +1,30 @@ +namespace ChromaDB.Client.Models; + +/// +/// An index of the values of a collection that a schema turns on or off, with ChromaCollectionSchema.WithIndex and +/// WithoutIndex. +/// +public enum ChromaSchemaIndex +{ + /// + /// The full-text search index of the documents, fts_index, for ChromaWhereDocumentOperator.Contains and the regular + /// expressions. On by default. + /// + FullTextSearch, + /// + /// The index of the string values of the metadata, string_inverted_index, for the filters on them. On by default. + /// + StringInverted, + /// + /// The index of the integer values of the metadata, int_inverted_index, for the filters on them. On by default. + /// + IntInverted, + /// + /// The index of the floating-point values of the metadata, float_inverted_index, for the filters on them. On by default. + /// + FloatInverted, + /// + /// The index of the Boolean values of the metadata, bool_inverted_index, for the filters on them. On by default. + /// + BoolInverted, +} diff --git a/ChromaDB.Client/ChromaSearch.cs b/ChromaDB.Client/ChromaSearch.cs new file mode 100644 index 0000000..fa00c4b --- /dev/null +++ b/ChromaDB.Client/ChromaSearch.cs @@ -0,0 +1,50 @@ +namespace ChromaDB.Client.Models; + +/// +/// One search of the Search API of Chroma, which only Chroma Cloud serves. The filters combine with $and; without +/// Rank the records come in the order of the server. A new field of Chroma becomes a new property here. +/// +public class ChromaSearch +{ + /// + /// A filter on the metadata; with the keys of ChromaSearchKeys it also filters ids and documents, like + /// ChromaWhereOperator.Contains(ChromaSearchKeys.Document, "apple"). + /// + public ChromaWhereOperator? Where { get; init; } + + /// + /// A filter on the documents, sent on the #document key. + /// + public ChromaWhereDocumentOperator? WhereDocument { get; init; } + + /// + /// Only the records with these ids, sent as $in on the #id key; not empty. + /// + public IReadOnlyList? Ids { get; init; } + + /// + /// How the records are ranked: the lowest score first. + /// + public ChromaRank? Rank { get; init; } + + /// + /// The number of records to return; when null, the server decides. Positive. + /// + public int? Limit { get; init; } + + /// + /// The number of records to skip. Not negative. + /// + public int Offset { get; init; } + + /// + /// The fields to return besides the id: the keys of ChromaSearchKeys, like ChromaSearchKeys.Document and + /// ChromaSearchKeys.Score, and metadata fields by name. When null or empty, only the ids. + /// + public IReadOnlyList? Select { get; init; } + + /// + /// Groups the results by metadata keys and keeps some records in each group. + /// + public ChromaSearchGroupBy? GroupBy { get; init; } +} diff --git a/ChromaDB.Client/ChromaSearchEntry.cs b/ChromaDB.Client/ChromaSearchEntry.cs new file mode 100644 index 0000000..a052eee --- /dev/null +++ b/ChromaDB.Client/ChromaSearchEntry.cs @@ -0,0 +1,45 @@ +namespace ChromaDB.Client.Models; + +/// +/// A record found by a search, with the fields that ChromaSearch.Select asked for; the others are null. +/// +public class ChromaSearchEntry +{ + /// + /// Creates the entry for the record with the given id; it sends no request. + /// + /// The id of the record. + public ChromaSearchEntry(string id) + { + Id = id; + } + + /// + /// The id of the record. + /// + public string Id { get; } + + /// + /// The document, when selected. + /// + public string? Document { get; init; } + + /// + /// The embedding, when selected. + /// A value is NaN where Chroma sent null: Chroma 1.x does for a float it cannot write, like an embedding beyond + /// the range of a float in a collection with the cosine space. + /// + public ReadOnlyMemory? Embedding { get; init; } + + /// + /// The metadata: all of it with ChromaSearchKeys.Metadata, or only the selected fields; empty for a record without + /// them, and null when the search selects no metadata. + /// + public IReadOnlyDictionary? Metadata { get; init; } + + /// + /// The score of the ranking, when selected: the lower, the better; for a search whose whole rank is ChromaRank.HybridRrf, the + /// fused score, the higher, the better. Inside an expression, the score comes as Chroma sends it. + /// + public float? Score { get; init; } +} diff --git a/ChromaDB.Client/ChromaSearchGroupBy.cs b/ChromaDB.Client/ChromaSearchGroupBy.cs new file mode 100644 index 0000000..24e5750 --- /dev/null +++ b/ChromaDB.Client/ChromaSearchGroupBy.cs @@ -0,0 +1,76 @@ +namespace ChromaDB.Client; + +/// +/// Groups the results of a search by the values of metadata keys, and keeps in each group the records the aggregate chooses, +/// group_by in the Search API of Chroma. +/// +public sealed class ChromaSearchGroupBy +{ + private readonly string[] _keys; + private readonly ChromaSearchAggregate _aggregate; + + /// + /// Groups by the metadata keys, like category, and keeps the records the aggregate chooses in each group. + /// + /// The records of each group that are kept. + /// The metadata keys whose values make the groups. + public ChromaSearchGroupBy(ChromaSearchAggregate aggregate, params string[] keys) + { + if (keys is not { Length: > 0 }) + { + throw new ArgumentException("A group by needs at least one key.", nameof(keys)); + } + _keys = keys; + _aggregate = aggregate; + } + + internal Dictionary ToGroupBy() + => new() { ["keys"] = _keys, ["aggregate"] = _aggregate.ToAggregate() }; +} + +/// +/// Which records a group keeps, in ChromaSearchGroupBy. +/// +public sealed class ChromaSearchAggregate +{ + private readonly string _operator; + private readonly string[] _keys; + private readonly int _k; + + private ChromaSearchAggregate(string @operator, int k, string[] keys) + { + if (k <= 0) + { + throw new ArgumentOutOfRangeException(nameof(k), "The k of an aggregate must be positive."); + } + if (keys is not { Length: > 0 }) + { + throw new ArgumentException("An aggregate needs at least one key.", nameof(keys)); + } + _operator = @operator; + _k = k; + _keys = keys; + } + + /// + /// The k records with the lowest values of the keys, compared in order, with $min_k; with ChromaSearchKeys.Score, + /// the best ranked. + /// + /// How many records each group keeps. + /// The keys that order the records of a group, like #score. + /// The aggregate. + public static ChromaSearchAggregate MinK(int k, params string[] keys) + => new("$min_k", k, keys); + + /// + /// The k records with the highest values of the keys, compared in order, with $max_k. + /// + /// How many records each group keeps. + /// The keys that order the records of a group, like #score. + /// The aggregate. + public static ChromaSearchAggregate MaxK(int k, params string[] keys) + => new("$max_k", k, keys); + + internal Dictionary ToAggregate() + => new() { [_operator] = new Dictionary { ["keys"] = _keys, ["k"] = _k } }; +} diff --git a/ChromaDB.Client/ChromaSearchKeys.cs b/ChromaDB.Client/ChromaSearchKeys.cs new file mode 100644 index 0000000..a1e9911 --- /dev/null +++ b/ChromaDB.Client/ChromaSearchKeys.cs @@ -0,0 +1,33 @@ +namespace ChromaDB.Client; + +/// +/// The special keys of the Search API of Chroma, for ChromaSearch.Select, filters and ChromaRank.Knn; any other key is a +/// metadata field. +/// +public static class ChromaSearchKeys +{ + /// + /// The id of the record, #id. + /// + public const string Id = "#id"; + + /// + /// The document of the record, #document. + /// + public const string Document = "#document"; + + /// + /// The embedding of the record, #embedding. + /// + public const string Embedding = "#embedding"; + + /// + /// All the metadata of the record, #metadata. + /// + public const string Metadata = "#metadata"; + + /// + /// The score of the record in the ranking, #score. + /// + public const string Score = "#score"; +} diff --git a/ChromaDB.Client/ChromaSpace.cs b/ChromaDB.Client/ChromaSpace.cs new file mode 100644 index 0000000..929697e --- /dev/null +++ b/ChromaDB.Client/ChromaSpace.cs @@ -0,0 +1,20 @@ +namespace ChromaDB.Client; + +/// +/// The distance function of a collection, hnsw:space in Chroma. +/// +public enum ChromaSpace +{ + /// + /// Squared Euclidean distance, the default of Chroma. + /// + L2, + /// + /// 1 - cosine similarity. + /// + Cosine, + /// + /// 1 - inner product. + /// + InnerProduct, +} diff --git a/ChromaDB.Client/ChromaSpannConfiguration.cs b/ChromaDB.Client/ChromaSpannConfiguration.cs new file mode 100644 index 0000000..9ca0db4 --- /dev/null +++ b/ChromaDB.Client/ChromaSpannConfiguration.cs @@ -0,0 +1,107 @@ +namespace ChromaDB.Client.Models; + +/// +/// The settings of the SPANN index of a new collection, the index of Chroma Cloud; the ones left null take the default of the server. +/// A single Chroma server uses an HNSW index and ignores them: then CreateCollectionAsync throws a ChromaException. +/// +public class ChromaSpannConfiguration +{ + /// + /// The search_nprobe setting: how many clusters a query looks into. + /// + public int? SearchNprobe { get; init; } + + /// + /// The write_nprobe setting: how many clusters a write looks into. + /// + public int? WriteNprobe { get; init; } + + /// + /// The ef_construction setting of the graph of the cluster centers. + /// + public int? EfConstruction { get; init; } + + /// + /// The ef_search setting of the graph of the cluster centers. + /// + public int? EfSearch { get; init; } + + /// + /// The max_neighbors setting of the graph of the cluster centers. + /// + public int? MaxNeighbors { get; init; } + + /// + /// The split_threshold setting: the size above which a cluster is split. + /// + public int? SplitThreshold { get; init; } + + /// + /// The merge_threshold setting: the size below which a cluster is merged. + /// + public int? MergeThreshold { get; init; } + + /// + /// The reassign_neighbor_count setting: how many neighboring clusters are checked when a cluster is split. + /// + public int? ReassignNeighborCount { get; init; } + + /// + /// The search_rng_epsilon setting. It goes in the schema of the collection, the only place Chroma takes it: Chroma Cloud, + /// or Chroma 1.3.0 and later. + /// + public double? SearchRngEpsilon { get; init; } + + /// + /// The write_rng_epsilon setting, from 5 to 10 on Chroma Cloud. It goes in the schema of the collection, as + /// SearchRngEpsilon. + /// + public double? WriteRngEpsilon { get; init; } + + /// + /// The nreplica_count setting: in how many clusters a record is written. It goes in the schema of the collection, as + /// SearchRngEpsilon. + /// + public int? NreplicaCount { get; init; } + + /// + /// The num_samples_kmeans setting: how many records the clustering samples. It goes in the schema of the collection, as + /// SearchRngEpsilon. + /// + public int? NumSamplesKmeans { get; init; } + + /// + /// The num_centers_to_merge_to setting. It goes in the schema of the collection, as SearchRngEpsilon. + /// + public int? NumCentersToMergeTo { get; init; } + + /// + /// The center_drift_threshold setting. It goes in the schema of the collection, as SearchRngEpsilon. + /// + public double? CenterDriftThreshold { get; init; } + + // The settings that Chroma takes only in a schema, not in the configuration of a collection. + internal bool HasSchemaOnlySettings + => SearchRngEpsilon is not null || WriteRngEpsilon is not null || NreplicaCount is not null || NumSamplesKmeans is not null + || NumCentersToMergeTo is not null || CenterDriftThreshold is not null; + + internal Dictionary ToJson() + { + var json = new Dictionary(); + if (SearchNprobe is { } searchNprobe) json["search_nprobe"] = searchNprobe; + if (WriteNprobe is { } writeNprobe) json["write_nprobe"] = writeNprobe; + if (EfConstruction is { } efConstruction) json["ef_construction"] = efConstruction; + if (EfSearch is { } efSearch) json["ef_search"] = efSearch; + if (MaxNeighbors is { } maxNeighbors) json["max_neighbors"] = maxNeighbors; + if (SplitThreshold is { } splitThreshold) json["split_threshold"] = splitThreshold; + if (MergeThreshold is { } mergeThreshold) json["merge_threshold"] = mergeThreshold; + if (ReassignNeighborCount is { } reassignNeighborCount) json["reassign_neighbor_count"] = reassignNeighborCount; + if (SearchRngEpsilon is { } searchRngEpsilon) json["search_rng_epsilon"] = searchRngEpsilon; + if (WriteRngEpsilon is { } writeRngEpsilon) json["write_rng_epsilon"] = writeRngEpsilon; + if (NreplicaCount is { } nreplicaCount) json["nreplica_count"] = nreplicaCount; + if (NumSamplesKmeans is { } numSamplesKmeans) json["num_samples_kmeans"] = numSamplesKmeans; + if (NumCentersToMergeTo is { } numCentersToMergeTo) json["num_centers_to_merge_to"] = numCentersToMergeTo; + if (CenterDriftThreshold is { } centerDriftThreshold) json["center_drift_threshold"] = centerDriftThreshold; + return json; + } +} diff --git a/ChromaDB.Client/ChromaSpannConfigurationUpdate.cs b/ChromaDB.Client/ChromaSpannConfigurationUpdate.cs new file mode 100644 index 0000000..eadb19e --- /dev/null +++ b/ChromaDB.Client/ChromaSpannConfigurationUpdate.cs @@ -0,0 +1,23 @@ +using System.Text.Json.Serialization; + +namespace ChromaDB.Client.Models; + +/// +/// The settings of the SPANN index of Chroma Cloud that can change; the ones left null keep their value. +/// +public class ChromaSpannConfigurationUpdate +{ + /// + /// The ef_search setting of the SPANN index. + /// + [JsonPropertyName("ef_search")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public int? EfSearch { get; init; } + + /// + /// The search_nprobe setting of the SPANN index. + /// + [JsonPropertyName("search_nprobe")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public int? SearchNprobe { get; init; } +} diff --git a/ChromaDB.Client/ChromaSparseIndexAlgorithm.cs b/ChromaDB.Client/ChromaSparseIndexAlgorithm.cs new file mode 100644 index 0000000..ba79bca --- /dev/null +++ b/ChromaDB.Client/ChromaSparseIndexAlgorithm.cs @@ -0,0 +1,16 @@ +namespace ChromaDB.Client.Models; + +/// +/// The algorithm of a sparse vector index of Chroma Cloud, algorithm in its schema. +/// +public enum ChromaSparseIndexAlgorithm +{ + /// + /// WAND, wand, the default. + /// + Wand, + /// + /// MaxScore, max_score, on Chroma Cloud for the tenants that have it. + /// + MaxScore, +} diff --git a/ChromaDB.Client/ChromaSparseVector.cs b/ChromaDB.Client/ChromaSparseVector.cs new file mode 100644 index 0000000..0958c4e --- /dev/null +++ b/ChromaDB.Client/ChromaSparseVector.cs @@ -0,0 +1,65 @@ +using System.Text.Json.Serialization; +using ChromaDB.Client.Common; + +namespace ChromaDB.Client.Models; + +/// +/// A sparse vector: the values of some dimensions only, like the BM25 or SPLADE embedding of a text. It is a metadata value of a record, +/// written as {"#type": "sparse_vector", "indices": [...], "values": [...]} as the Python client of Chroma writes it, and the query +/// of ChromaRank.SparseKnn on a key with a sparse vector index, which only Chroma Cloud searches. +/// +[JsonConverter(typeof(ChromaSparseVectorConverter))] +public sealed class ChromaSparseVector +{ + /// + /// The vector with the values of the dimensions in indices, which must be in strictly ascending order, as many as the values; + /// the tokens, when given, too. The indices are unsigned 32-bit integers, as Chroma stores them. + /// + /// The dimensions that have a value, in strictly ascending order. + /// The value of each dimension of the indices. + /// The token of each dimension, or null for none. + public ChromaSparseVector(IReadOnlyList indices, IReadOnlyList values, IReadOnlyList? tokens = null) + { + if (indices.Count != values.Count) + { + throw new ArgumentException($"A sparse vector needs as many indices as values: {indices.Count} indices and {values.Count} values.", nameof(values)); + } + if (tokens is not null && tokens.Count != indices.Count) + { + throw new ArgumentException($"A sparse vector needs as many tokens as indices: {tokens.Count} tokens and {indices.Count} indices.", nameof(tokens)); + } + for (var i = 0; i < indices.Count; i++) + { + if (i > 0 && indices[i] <= indices[i - 1]) + { + throw new ArgumentException($"The indices of a sparse vector must be in strictly ascending order: {indices[i]} after {indices[i - 1]} at position {i}.", nameof(indices)); + } + } + // Read-only copies: a cast cannot change them after the checks. + Indices = Array.AsReadOnly(indices.ToArray()); + Values = Array.AsReadOnly(values.ToArray()); + Tokens = tokens is null ? null : Array.AsReadOnly(tokens.ToArray()); + } + + /// + /// The dimensions that have a value, in ascending order. + /// + public IReadOnlyList Indices { get; } + + /// + /// The value of each dimension in Indices. + /// + public IReadOnlyList Values { get; } + + /// + /// The token of each dimension, like the words of a BM25 vector, when known; tokens in the JSON. + /// + public IReadOnlyList? Tokens { get; } + + /// + /// The JSON of the vector, as the client sends it. + /// + /// The JSON. + public override string ToString() + => System.Text.Json.JsonSerializer.Serialize(this, HttpClientHelpers.TypeInfo(HttpClientHelpers.PostJsonSerializerOptions)); +} diff --git a/ChromaDB.Client/ChromaSparseVectorIndex.cs b/ChromaDB.Client/ChromaSparseVectorIndex.cs new file mode 100644 index 0000000..3cd21fc --- /dev/null +++ b/ChromaDB.Client/ChromaSparseVectorIndex.cs @@ -0,0 +1,57 @@ +using System.Text.Json; + +namespace ChromaDB.Client.Models; + +/// +/// A sparse vector index of a collection, read from its schema. +/// +public sealed class ChromaSparseVectorIndex +{ + internal ChromaSparseVectorIndex(string key, string? sourceKey, bool bm25, string? embeddingFunction, JsonElement? embeddingFunctionConfig, ChromaSparseIndexAlgorithm algorithm = ChromaSparseIndexAlgorithm.Wand) + { + Algorithm = algorithm; + Key = key; + SourceKey = sourceKey; + Bm25 = bm25; + EmbeddingFunction = embeddingFunction; + EmbeddingFunctionConfig = embeddingFunctionConfig; + Bm25Function = embeddingFunction == ChromaBm25.Name ? ChromaBm25.FromConfig(embeddingFunctionConfig) : null; + } + + /// + /// The metadata key that holds the sparse vectors. + /// + public string Key { get; } + + /// + /// The key of the text the sparse vectors come from, like #document, when the schema names one. + /// + public string? SourceKey { get; } + + /// + /// Whether the server applies the inverse document frequency of BM25 to the vectors. + /// + public bool Bm25 { get; } + + /// + /// The algorithm of the index: Wand, the default, when the schema names none. + /// + public ChromaSparseIndexAlgorithm Algorithm { get; } + + /// + /// The name of the embedding function the schema declares, like chroma_bm25, when it declares a known one. + /// + public string? EmbeddingFunction { get; } + + /// + /// The settings of the embedding function, config in the schema, as the server sends them; null when the schema has none. + /// + public JsonElement? EmbeddingFunctionConfig { get; } + + /// + /// The BM25 function with the settings of the schema, as the Python client of Chroma builds it, when the function is + /// chroma_bm25: the client computes the vectors of the index with it. Null for the other functions, and for settings + /// of the wrong type. + /// + public ChromaBm25? Bm25Function { get; } +} diff --git a/ChromaDB.Client/ChromaTelemetry.cs b/ChromaDB.Client/ChromaTelemetry.cs new file mode 100644 index 0000000..d2fc4b3 --- /dev/null +++ b/ChromaDB.Client/ChromaTelemetry.cs @@ -0,0 +1,26 @@ +namespace ChromaDB.Client; + +/// +/// The names of the traces and of the metrics of the client, for OpenTelemetry: AddSource(ChromaTelemetry.ActivitySourceName) +/// and AddMeter(ChromaTelemetry.MeterName). Each operation, like query or add, is a client span named after the +/// operation and the collection, like query articles, with the attributes of the OpenTelemetry semantic conventions for database +/// clients: db.system.name chroma, db.operation.name, db.collection.name, db.namespace (the tenant +/// and the database, as tenant|database), server.address, server.port, and on a failure error.type and +/// db.response.status_code, the HTTP status. The duration of each operation goes in the histogram +/// db.client.operation.duration, in seconds, with the same attributes, and with the bucket boundaries that the semantic +/// conventions advise, from 0.001 to 10 seconds, which OpenTelemetry 1.10 and later apply. The netstandard2.0 build cannot advise +/// them: there the application adds a view with them to its MeterProvider, as the README shows. Without a listener nothing is +/// measured. +/// +public static class ChromaTelemetry +{ + /// + /// The name of the ActivitySource of the spans of the operations. + /// + public const string ActivitySourceName = "ChromaDB.Client"; + + /// + /// The name of the Meter of db.client.operation.duration. + /// + public const string MeterName = "ChromaDB.Client"; +} diff --git a/ChromaDB.Client/ChromaTenant.cs b/ChromaDB.Client/ChromaTenant.cs index 8582413..5f663c5 100644 --- a/ChromaDB.Client/ChromaTenant.cs +++ b/ChromaDB.Client/ChromaTenant.cs @@ -2,11 +2,28 @@ namespace ChromaDB.Client.Models; +/// +/// A tenant of Chroma. +/// public class ChromaTenant { + /// + /// The name of the tenant. + /// [JsonPropertyName("name")] public string Name { get; } + /// + /// The name of the tenant in the resource names of Chroma Cloud, like the CRN of a collection; null when it has none. + /// UpdateTenantAsync sets it. + /// + [JsonPropertyName("resource_name")] + public string? ResourceName { get; init; } + + /// + /// Creates the object for a tenant with the given name; it sends no request. + /// + /// The name of the tenant. public ChromaTenant(string name) { Name = name; diff --git a/ChromaDB.Client/ChromaTokenTransportHeader.cs b/ChromaDB.Client/ChromaTokenTransportHeader.cs new file mode 100644 index 0000000..0206bed --- /dev/null +++ b/ChromaDB.Client/ChromaTokenTransportHeader.cs @@ -0,0 +1,16 @@ +namespace ChromaDB.Client; + +/// +/// The header that carries the token, like chroma_auth_token_transport_header in the Python client. +/// +public enum ChromaTokenTransportHeader +{ + /// + /// X-Chroma-Token: <token>, the header of Chroma Cloud. The default. + /// + XChromaToken, + /// + /// Authorization: Bearer <token>, the default of the token authentication of the Chroma 0.x servers. + /// + Authorization, +} diff --git a/ChromaDB.Client/ChromaUpsertStrategy.cs b/ChromaDB.Client/ChromaUpsertStrategy.cs new file mode 100644 index 0000000..edda4eb --- /dev/null +++ b/ChromaDB.Client/ChromaUpsertStrategy.cs @@ -0,0 +1,26 @@ +namespace ChromaDB.Client; + +/// +/// How UpdateAsync and UpsertAsync write the embeddings of records that exist. Chroma 1.0.21 to 1.5.9, installed on your +/// own servers, may lose a record from the vector index after an update or an upsert with embeddings of records that exist, also with +/// the embeddings they had: a query no longer finds it, while a get does (KD-49 in docs/COMPATIBILITY.md). Chroma Cloud has not shown +/// it, and on Chroma 0.x, which has not the defect, the writes go as they are whatever the strategy. On the server, the environment +/// variable RAYON_NUM_THREADS=1 made Chroma 1.5.9 lose no record in our measurements, whatever the strategy. +/// +public enum ChromaUpsertStrategy +{ + /// + /// The update and the upsert of the server, as they are: the default. + /// + Server, + + /// + /// A record whose embedding does not change is updated without it, which does not touch the vector index; the other records go by + /// the update or the upsert of the server. No record is deleted, so there is no risk of data loss, but a record whose embedding + /// changes goes by the upsert of the server, and the vector index can still lose a record then, often another one. So does an id given + /// more than once in the same write, in the order given, as Chroma applies the writes in order. It costs a get of + /// the records first. In a cosine collection, where Chroma 1.x gives an embedding back 1 or 2 ulp off, values within 4 ulp count as + /// the same; an embedding computed again by a model can differ by more, and then it goes as changed. + /// + SkipUnchangedEmbeddings, +} diff --git a/ChromaDB.Client/ChromaUserIdentity.cs b/ChromaDB.Client/ChromaUserIdentity.cs new file mode 100644 index 0000000..7ddf0e6 --- /dev/null +++ b/ChromaDB.Client/ChromaUserIdentity.cs @@ -0,0 +1,27 @@ +using System.Text.Json.Serialization; + +namespace ChromaDB.Client.Models; + +/// +/// The user the server sees for the credentials of the client, with its tenant and databases. +/// +public class ChromaUserIdentity +{ + /// + /// The id of the user. + /// + [JsonPropertyName("user_id")] + public string? UserId { get; init; } + + /// + /// The tenant of the user. + /// + [JsonPropertyName("tenant")] + public string? Tenant { get; init; } + + /// + /// The databases of the user. + /// + [JsonPropertyName("databases")] + public IReadOnlyList? Databases { get; init; } +} diff --git a/ChromaDB.Client/ChromaWhere.cs b/ChromaDB.Client/ChromaWhere.cs deleted file mode 100644 index 15b4e07..0000000 --- a/ChromaDB.Client/ChromaWhere.cs +++ /dev/null @@ -1,86 +0,0 @@ -namespace ChromaDB.Client; - -public abstract class ChromaWhere -{ - protected string Operator { get; } - - protected ChromaWhere(string @operator) - { - Operator = @operator; - } - - internal abstract Dictionary ToWhere(); - - public static ChromaWhere In(string field, params object[] values) - => new ChromaWhereValue(field, "$in", values); - - public static ChromaWhere NotIn(string field, params object[] values) - => new ChromaWhereValue(field, "$nin", values); - - public static ChromaWhere GreaterThan(string field, object value) - => new ChromaWhereValue(field, "$gt", value); - - public static ChromaWhere GreaterThanOrEqual(string field, object value) - => new ChromaWhereValue(field, "$gte", value); - - public static ChromaWhere LessThan(string field, object value) - => new ChromaWhereValue(field, "$lt", value); - - public static ChromaWhere LessThanOrEqual(string field, object value) - => new ChromaWhereValue(field, "$lte", value); - - public static ChromaWhere Equal(string field, object value) - => new ChromaWhereValue(field, "$eq", value); - - public static ChromaWhere NotEqual(string field, object value) - => new ChromaWhereValue(field, "$ne", value); - - public static bool operator true(ChromaWhere _) - => false; - public static bool operator false(ChromaWhere _) - => false; - - public static ChromaWhere operator &(ChromaWhere lhs, ChromaWhere rhs) - => new ChromaWhereLogical("$and", lhs, rhs); - - public static ChromaWhere operator |(ChromaWhere lhs, ChromaWhere rhs) - => new ChromaWhereLogical("$or", lhs, rhs); -} - -internal class ChromaWhereLogical : ChromaWhere -{ - protected ChromaWhere Lhs { get; } - protected ChromaWhere Rhs { get; } - - internal ChromaWhereLogical(string @operator, ChromaWhere lhs, ChromaWhere rhs) - : base(@operator) - { - Lhs = lhs; - Rhs = rhs; - } - - internal override Dictionary ToWhere() - => new() - { - { Operator, new object[] { Lhs.ToWhere(), Rhs.ToWhere() } } - }; -} - -internal class ChromaWhereValue : ChromaWhere -{ - protected string Field { get; } - protected T Value { get; } - - internal ChromaWhereValue(string field, string @operator, T value) - : base(@operator) - { - Field = field; - Value = value; - } - - internal override Dictionary ToWhere() - => new() - { - { Field, new Dictionary { { Operator, Value } } } - }; -} diff --git a/ChromaDB.Client/ChromaWhereDocument.cs b/ChromaDB.Client/ChromaWhereDocument.cs deleted file mode 100644 index bdc3bd7..0000000 --- a/ChromaDB.Client/ChromaWhereDocument.cs +++ /dev/null @@ -1,70 +0,0 @@ -namespace ChromaDB.Client; - -public abstract class ChromaWhereDocument -{ - protected string Operator { get; } - - protected ChromaWhereDocument(string @operator) - { - Operator = @operator; - } - - internal abstract Dictionary ToWhereDocument(); - - public static ChromaWhereDocument Contains(char ch) - => Contains(ch.ToString()); - public static ChromaWhereDocument Contains(string str) - => new ChromaWhereDocumentStr("$contains", str); - - public static ChromaWhereDocument NotContains(char ch) - => NotContains(ch.ToString()); - public static ChromaWhereDocument NotContains(string str) - => new ChromaWhereDocumentStr("$not_contains", str); - - public static bool operator true(ChromaWhereDocument _) - => false; - public static bool operator false(ChromaWhereDocument _) - => false; - - public static ChromaWhereDocument operator &(ChromaWhereDocument lhs, ChromaWhereDocument rhs) - => new ChromaWhereDocumentLogical("$and", lhs, rhs); - - public static ChromaWhereDocument operator |(ChromaWhereDocument lhs, ChromaWhereDocument rhs) - => new ChromaWhereDocumentLogical("$or", lhs, rhs); -} - -internal class ChromaWhereDocumentLogical : ChromaWhereDocument -{ - protected ChromaWhereDocument Lhs { get; } - protected ChromaWhereDocument Rhs { get; } - - internal ChromaWhereDocumentLogical(string @operator, ChromaWhereDocument lhs, ChromaWhereDocument rhs) - : base(@operator) - { - Lhs = lhs; - Rhs = rhs; - } - - internal override Dictionary ToWhereDocument() - => new() - { - { Operator, new object[] { Lhs.ToWhereDocument(), Rhs.ToWhereDocument() } } - }; -} - -internal class ChromaWhereDocumentStr : ChromaWhereDocument -{ - protected string Str { get; } - - internal ChromaWhereDocumentStr(string @operator, string str) - : base(@operator) - { - Str = str; - } - - internal override Dictionary ToWhereDocument() - => new() - { - { Operator, Str } - }; -} diff --git a/ChromaDB.Client/ChromaWhereDocumentOperator.cs b/ChromaDB.Client/ChromaWhereDocumentOperator.cs index 151c142..9a9da88 100644 --- a/ChromaDB.Client/ChromaWhereDocumentOperator.cs +++ b/ChromaDB.Client/ChromaWhereDocumentOperator.cs @@ -1,9 +1,20 @@ namespace ChromaDB.Client; +/// +/// A filter on the documents of the records, sent as where_document. +/// The & and | operators combine filters with $and and $or. +/// public abstract class ChromaWhereDocumentOperator { + /// + /// The Chroma operator of the filter, like $contains or $and. + /// protected string Operator { get; } + /// + /// Creates a filter with the given Chroma operator. + /// + /// The Chroma operator, like $eq or $and. protected ChromaWhereDocumentOperator(string @operator) { Operator = @operator; @@ -11,24 +22,135 @@ protected ChromaWhereDocumentOperator(string @operator) internal abstract Dictionary ToWhereDocument(); + internal abstract ChromaWhereDocumentOperator Negate(); + + /// + /// A filter written as the dictionary of the where_document of the Chroma API, as the Python client takes it, like + /// {"$contains": "text"} or {"$and": [...]}: nested filters as Dictionary<string, object>, lists as arrays. + /// Get, query and delete send it as it is, with very long $and and $or lists split as for the other filters, and the + /// server checks it. The Search API takes the filters on the documents in its where + /// clause, on #document: there the client writes it as it writes the other filters, and for what it cannot write, like an + /// operator other than $contains, $not_contains, $regex, $not_regex, $and and $or, throws a + /// NotSupportedException without sending the search. & and | join it to other filters; Not throws a + /// NotSupportedException. The client writes the dictionary to JSON when it gets it: a value it cannot write throws there; later + /// changes to it do not change the filter. + /// + /// The filter, as a dictionary. + /// The filter. + public static ChromaWhereDocumentOperator FromDictionary(IReadOnlyDictionary whereDocument) + => new ChromaWhereDocumentDictionaryOperator(Common.ChromaFilterLists.ToJson(whereDocument ?? throw new ArgumentNullException(nameof(whereDocument)))); + + /// + /// The documents that the filter does not match: Chroma has no $not, so the negation goes into the operators, + /// $contains to $not_contains, $regex to $not_regex, and $and to $or of the negations. + /// + /// The filter to negate. + /// The negated filter. + public static ChromaWhereDocumentOperator Not(ChromaWhereDocumentOperator filter) + => filter.Negate(); + + // The filter as it goes to the server, with the long lists split as the server takes them. + internal Dictionary ToRequestWhereDocument() => Common.ChromaFilterLists.Shape(ToWhereDocument()); + + // The same filter in the where clause of the Search API, on the #document key: {"#document": {"$contains": "..."}}. + internal abstract Dictionary ToSearchWhere(); + + internal Dictionary ToRequestSearchWhere() => Common.ChromaFilterLists.Shape(ToSearchWhere()); + + /// + /// The JSON of the filter, as the client sends it in where_document. + /// + /// The JSON. + public override string ToString() + => System.Text.Json.JsonSerializer.Serialize(ToRequestWhereDocument(), Common.HttpClientHelpers.TypeInfo>(Common.HttpClientHelpers.PostJsonSerializerOptions)); + + /// + /// The documents that contain the character, with $contains. On Chroma Cloud the client throws an ArgumentException, + /// as a text of one character gives the wrong records there (KD-57 in docs/COMPATIBILITY.md). + /// + /// The text the documents contain. + /// The filter. public static ChromaWhereDocumentOperator Contains(char value) => Contains(value.ToString()); + /// + /// The documents that contain the text, with $contains. On Chroma Cloud a text of fewer than 3 characters gives the wrong + /// records once it has indexed them (KD-57 in docs/COMPATIBILITY.md), so the client throws an ArgumentException before the + /// request: a Regex with the text, its special characters escaped, is right. + /// + /// The text the documents contain. + /// The filter. public static ChromaWhereDocumentOperator Contains(string value) - => new ChromaWhereDocumentStringOperator("$contains", value); + => new ChromaWhereDocumentStringOperator("$contains", "$not_contains", value); + /// + /// The documents that do not contain the character, with $not_contains. On Chroma Cloud the client throws an + /// ArgumentException, as a text of one character gives the wrong records there (KD-57 in docs/COMPATIBILITY.md). + /// + /// The text the documents do not contain. + /// The filter. public static ChromaWhereDocumentOperator NotContains(char value) => NotContains(value.ToString()); + /// + /// The documents that do not contain the text, with $not_contains. On Chroma Cloud a text of fewer than 3 characters gives the + /// wrong records once it has indexed them (KD-57 in docs/COMPATIBILITY.md), so the client throws an ArgumentException before + /// the request: a NotRegex with the text, its special characters escaped, is right. + /// + /// The text the documents do not contain. + /// The filter. public static ChromaWhereDocumentOperator NotContains(string value) - => new ChromaWhereDocumentStringOperator("$not_contains", value); + => new ChromaWhereDocumentStringOperator("$not_contains", "$contains", value); + /// + /// The documents that match the regular expression, with $regex, from Chroma 1.0.12; the earlier versions reject it. On Chroma + /// Cloud a pattern that takes an empty text, like ^$, finds every record (KD-56 in docs/COMPATIBILITY.md), so the client throws + /// an ArgumentException before the request, also for a pattern it cannot read as Chroma does: Regex("(?s).") finds the + /// records with a document that is not empty. + /// + /// The regular expression. + /// The filter. + public static ChromaWhereDocumentOperator Regex(string pattern) + => new ChromaWhereDocumentStringOperator("$regex", "$not_regex", pattern); + /// + /// The documents that do not match the regular expression, with $not_regex, from Chroma 1.0.12; the earlier versions reject it. + /// On Chroma Cloud a pattern that takes an empty text finds no record (KD-56 in docs/COMPATIBILITY.md), so the client throws an + /// ArgumentException before the request, also for a pattern it cannot read as Chroma does: NotRegex("(?s).") finds the + /// records with an empty document or none. + /// + /// The regular expression. + /// The filter. + public static ChromaWhereDocumentOperator NotRegex(string pattern) + => new ChromaWhereDocumentStringOperator("$not_regex", "$regex", pattern); + + /// + /// Always false, so that || combines two filters with $or, like |. + /// + /// The filter. + /// Always false, so that && and || combine both filters. public static bool operator true(ChromaWhereDocumentOperator _) => false; + /// + /// Always false, so that && combines two filters with $and, like &. + /// + /// The filter. + /// Always false, so that && and || combine both filters. public static bool operator false(ChromaWhereDocumentOperator _) => false; + /// + /// The documents that match both filters, with $and. + /// + /// The first filter. + /// The second filter. + /// The filter that both filters pass, with $and. public static ChromaWhereDocumentOperator operator &(ChromaWhereDocumentOperator lhs, ChromaWhereDocumentOperator rhs) => new ChromaWhereDocumentLogicalOperator("$and", lhs, rhs); + /// + /// The documents that match either filter, with $or. + /// + /// The first filter. + /// The second filter. + /// The filter that either filter passes, with $or. public static ChromaWhereDocumentOperator operator |(ChromaWhereDocumentOperator lhs, ChromaWhereDocumentOperator rhs) => new ChromaWhereDocumentLogicalOperator("$or", lhs, rhs); } @@ -45,26 +167,112 @@ internal ChromaWhereDocumentLogicalOperator(string @operator, ChromaWhereDocumen Rhs = rhs; } + // a & b & c goes as {"$and":[a,b,c]}, as for the filters on the metadata. internal override Dictionary ToWhereDocument() => new() { - { Operator, new object[] { Lhs.ToWhereDocument(), Rhs.ToWhereDocument() } } + { Operator, Operands().Select(x => (object)x.ToWhereDocument()).ToArray() } + }; + + internal override Dictionary ToSearchWhere() + => new() + { + { Operator, Operands().Select(x => (object)x.ToSearchWhere()).ToArray() } }; + + // !(a & b) is !a | !b, and !(a | b) is !a & !b. + internal override ChromaWhereDocumentOperator Negate() + => Operands().Select(x => x.Negate()).Aggregate((left, right) => Operator == "$and" ? left | right : left & right); + + // The filters of a chain of the same operator, in their order, without recursion for a long chain. + private List Operands() + { + var operands = new List(); + var stack = new Stack([Rhs, Lhs]); + while (stack.Count > 0) + { + var filter = stack.Pop(); + if (filter is ChromaWhereDocumentLogicalOperator logical && logical.Operator == Operator) + { + stack.Push(logical.Rhs); + stack.Push(logical.Lhs); + } + else + { + operands.Add(filter); + } + } + return operands; + } } internal class ChromaWhereDocumentStringOperator : ChromaWhereDocumentOperator { protected string String { get; } + // The operator of the negation, like $not_contains for $contains: Chroma has no $not. + protected string NegatedOperator { get; } - internal ChromaWhereDocumentStringOperator(string @operator, string @string) + internal ChromaWhereDocumentStringOperator(string @operator, string negatedOperator, string @string) : base(@operator) { + NegatedOperator = negatedOperator; String = @string; } + internal override ChromaWhereDocumentOperator Negate() + => new ChromaWhereDocumentStringOperator(NegatedOperator, Operator, String); + internal override Dictionary ToWhereDocument() => new() { { Operator, String } }; + + internal override Dictionary ToSearchWhere() + => new() + { + { ChromaSearchKeys.Document, new Dictionary { { Operator, String } } } + }; +} + +// A filter on the documents given as the dictionary of the where_document of the Chroma API: sent as it is, and written for the +// Search API on #document, as the other filters are. +internal sealed class ChromaWhereDocumentDictionaryOperator(IReadOnlyDictionary whereDocument) : ChromaWhereDocumentOperator("dictionary") +{ + internal override Dictionary ToWhereDocument() => whereDocument.ToDictionary(x => x.Key, x => x.Value); + + internal override Dictionary ToSearchWhere() => ToSearchWhere(whereDocument); + + internal override ChromaWhereDocumentOperator Negate() + => throw new NotSupportedException("Not cannot negate a filter on the documents given as a dictionary: Chroma has no $not."); + + // {"$and"|"$or": [filters]} keeps its operator, and {"$contains"|"$not_contains"|"$regex"|"$not_regex": text} goes on #document. The + // dictionary at the top has the JSON of the values, which FromDictionary wrote, and below it is all JSON. + private static Dictionary ToSearchWhere(object node) + { + var pairs = (node switch + { + System.Text.Json.JsonElement { ValueKind: System.Text.Json.JsonValueKind.Object } json => json.EnumerateObject().Select(property => new KeyValuePair(property.Name, property.Value)), + IEnumerable> filter => filter, + _ => throw Unwritable("has a filter that is not a dictionary"), + }).Take(2).ToList(); + if (pairs.Count != 1) + { + throw Unwritable($"has a filter with {(pairs.Count == 0 ? "no operator" : "more than one operator")}: Chroma takes one in each"); + } + var pair = pairs[0]; + return pair.Key switch + { + "$and" or "$or" when pair.Value is System.Text.Json.JsonElement { ValueKind: System.Text.Json.JsonValueKind.Array } list + => new() { { pair.Key, list.EnumerateArray().Select(x => (object)ToSearchWhere(x)).ToArray() } }, + "$contains" or "$not_contains" or "$regex" or "$not_regex" when pair.Value is System.Text.Json.JsonElement { ValueKind: System.Text.Json.JsonValueKind.String } text + => new() { { ChromaSearchKeys.Document, new Dictionary { { pair.Key, text.GetString()! } } } }, + "$and" or "$or" => throw Unwritable($"has {pair.Key} with a value that is not a list of filters"), + "$contains" or "$not_contains" or "$regex" or "$not_regex" => throw Unwritable($"has {pair.Key} with a value that is not a text"), + _ => throw Unwritable($"has {pair.Key}, which the client cannot write for the Search API: it writes $contains, $not_contains, $regex, $not_regex, $and and $or. ChromaWhereOperator.FromDictionary sends a where clause of the Search API as it is, with #document"), + }; + } + + private static NotSupportedException Unwritable(string what) + => new($"The filter on the documents given as a dictionary {what}."); } diff --git a/ChromaDB.Client/ChromaWhereOperator.cs b/ChromaDB.Client/ChromaWhereOperator.cs index 61a082d..47158d4 100644 --- a/ChromaDB.Client/ChromaWhereOperator.cs +++ b/ChromaDB.Client/ChromaWhereOperator.cs @@ -1,9 +1,23 @@ namespace ChromaDB.Client; +/// +/// A filter on the metadata of the records, sent as where. +/// The & and | operators combine filters with $and and $or. +/// It can also filter the ids, with Equal and In on ChromaSearchKeys.Id, and the documents, with Document, +/// as the where clause of the Search API does. Get, query and delete take neither in their where clause: the client sends them as +/// the ids and the where_document of the request, which takes them only joined to the other conditions with &. +/// public abstract class ChromaWhereOperator { + /// + /// The Chroma operator of the filter, like $eq or $and. + /// protected string Operator { get; } + /// + /// Creates a filter with the given Chroma operator. + /// + /// The Chroma operator, like $eq or $and. protected ChromaWhereOperator(string @operator) { Operator = @operator; @@ -11,40 +25,258 @@ protected ChromaWhereOperator(string @operator) internal abstract Dictionary ToWhere(); + // The filter as it goes to the server, with the long lists split as the server takes them. + internal Dictionary ToRequestWhere() => Common.ChromaFilterLists.Shape(ToWhere()); + + // The where clause of a request: none for All, which matches every record. A request with None is not sent. + internal static Dictionary? ToRequestWhere(ChromaWhereOperator? where) + => where is null || where == All ? null : where.ToRequestWhere(); + + // The conditions joined with $and at the top of the filter: the filter itself for any other. + internal virtual IEnumerable Conjuncts() => [this]; + + // Whether the filter has a condition on the ids or on the documents, which only the Search API takes in its where clause. + internal virtual bool HasSearchOnlyCondition => false; + + // The filter as a filter on the documents, when it has only conditions on the documents; null otherwise. + internal virtual ChromaWhereDocumentOperator? AsDocumentFilter() => null; + + // For get, query and delete, whose where clause takes neither ids nor documents: the conditions on the ids, with Equal and In, and + // the ones on the documents, joined to the others with $and, go to the ids and to where_document of the request. The ids of the + // conditions and the given ones are the ones in all of them; with none left, the filter is None. + internal static (ChromaWhereOperator? Where, ChromaWhereDocumentOperator? WhereDocument, IReadOnlyList? Ids) Split( + ChromaWhereOperator? where, ChromaWhereDocumentOperator? whereDocument, IReadOnlyList? ids) + { + if (where is null || !where.HasSearchOnlyCondition) + { + return (where, whereDocument, ids); + } + var rest = All; + var keptIds = ids; + foreach (var condition in where.Conjuncts()) + { + if (condition is ChromaWhereValueOperator value && value.Ids() is { } conditionIds) + { + keptIds = keptIds is null ? conditionIds.Distinct().ToList() : keptIds.Intersect(conditionIds).ToList(); + } + else if (condition.AsDocumentFilter() is { } document) + { + whereDocument = whereDocument is null ? document : whereDocument & document; + } + else if (condition.HasSearchOnlyCondition) + { + throw new NotSupportedException( + "Get, query and delete take a condition on the ids, with Equal or In, or on the documents only joined to the other conditions with &: the Search API takes them anywhere."); + } + else + { + rest &= condition; + } + } + return keptIds is [] ? (None, null, null) : (rest, whereDocument, keptIds); + } + + /// + /// The records whose document the filter matches, as a condition of the where clause on ChromaSearchKeys.Document, which + /// the Search API takes. For get, query and delete the client sends it as where_document. + /// + /// The filter on the documents. + /// The filter. + public static ChromaWhereOperator Document(ChromaWhereDocumentOperator filter) + => new ChromaWhereDocumentFilter(filter ?? throw new ArgumentNullException(nameof(filter))); + + /// + /// A filter written as the dictionary of the where of the Chroma API, as the Python client takes it, like + /// {"year": {"$gte": 2020}} or {"$and": [...]}: nested filters as Dictionary<string, object>, lists as + /// arrays. The client sends it as it is, with very long $and and $or lists split as for the other filters, and does not + /// check it: the server does. & and | join it to other filters; Not throws a NotSupportedException. + /// The client writes the dictionary to JSON when it gets it: a value it cannot write throws there; later changes to it do not change + /// the filter. + /// + /// The filter, as a dictionary. + /// The filter. + public static ChromaWhereOperator FromDictionary(IReadOnlyDictionary where) + => new ChromaWhereDictionaryOperator(Common.ChromaFilterLists.ToJson(where ?? throw new ArgumentNullException(nameof(where)))); + + /// + /// The filter that matches every record: the client sends no where, which Chroma has no value for. It is a single instance, + /// which &, | and Not return when the filter they make matches every record. + /// + public static ChromaWhereOperator All { get; } = new ChromaWhereConstantOperator(matchesAll: true); + + /// + /// The filter that matches no record: a read with it returns nothing and a delete deletes nothing, without a request, as Chroma has + /// no value for it. It is a single instance, which &, |, Not and In without values return when the + /// filter they make matches no record. + /// + public static ChromaWhereOperator None { get; } = new ChromaWhereConstantOperator(matchesAll: false); + + /// + /// The records that the filter does not match, as Chroma can tell them: Chroma has no $not, so the negation goes into the + /// operators, $eq to $ne, $gt to $lte, $in to $nin, $contains to $not_contains, + /// and $and to $or of the negations. $ne, $nin and $not_contains match the records without the key, + /// $ne and $nin from Chroma 0.5.15, but a comparison like $lte does not: Not(GreaterThan(key, 5)) leaves out + /// the records without the key, as GreaterThan(key, 5) does. + /// + /// The filter to negate. + /// The negated filter. + public static ChromaWhereOperator Not(ChromaWhereOperator filter) + => filter.Negate(); + + internal abstract ChromaWhereOperator Negate(); + + // Works around KD-46 (docs/COMPATIBILITY.md) + /// + /// The records whose value for the key is one of the values, with $in. Without values it is None: every tested Chroma + /// rejects $in without values. + /// + /// The metadata key. + /// The values, one of which the key has. + /// The filter. public static ChromaWhereOperator In(string key, params object[] values) - => new ChromaWhereValueOperator(key, "$in", values); + => values is { Length: > 0 } ? new ChromaWhereValueOperator(key, "$in", "$nin", values) : None; + // Works around KD-46 (docs/COMPATIBILITY.md) + /// + /// The records whose value for the key is not one of the values, with $nin, which matches the records without the key too from + /// Chroma 0.5.15. + /// Without values it is All: every tested Chroma rejects $nin without values. + /// + /// The metadata key. + /// The values, none of which the key has. + /// The filter. public static ChromaWhereOperator NotIn(string key, params object[] values) - => new ChromaWhereValueOperator(key, "$nin", values); + => values is { Length: > 0 } ? new ChromaWhereValueOperator(key, "$nin", "$in", values) : All; + /// + /// The JSON of the filter, as the client sends it in where; true for All and false for None, + /// which the client sends no where for. + /// + /// The JSON. + public override string ToString() + => System.Text.Json.JsonSerializer.Serialize(ToRequestWhere(), Common.HttpClientHelpers.TypeInfo>(Common.HttpClientHelpers.PostJsonSerializerOptions)); + + /// + /// The records whose value for the key is greater than the value, with $gt. + /// + /// The metadata key. + /// The value to compare with. + /// The filter. public static ChromaWhereOperator GreaterThan(string key, object value) - => new ChromaWhereValueOperator(key, "$gt", value); + => new ChromaWhereValueOperator(key, "$gt", "$lte", value); + /// + /// The records whose value for the key is greater than or equal to the value, with $gte. + /// + /// The metadata key. + /// The value to compare with. + /// The filter. public static ChromaWhereOperator GreaterThanOrEqual(string key, object value) - => new ChromaWhereValueOperator(key, "$gte", value); + => new ChromaWhereValueOperator(key, "$gte", "$lt", value); + /// + /// The records whose value for the key is less than the value, with $lt. + /// + /// The metadata key. + /// The value to compare with. + /// The filter. public static ChromaWhereOperator LessThan(string key, object value) - => new ChromaWhereValueOperator(key, "$lt", value); + => new ChromaWhereValueOperator(key, "$lt", "$gte", value); + /// + /// The records whose value for the key is less than or equal to the value, with $lte. + /// + /// The metadata key. + /// The value to compare with. + /// The filter. public static ChromaWhereOperator LessThanOrEqual(string key, object value) - => new ChromaWhereValueOperator(key, "$lte", value); + => new ChromaWhereValueOperator(key, "$lte", "$gt", value); + /// + /// The records whose value for the key equals the value, with $eq. + /// + /// The metadata key. + /// The value to compare with. + /// The filter. public static ChromaWhereOperator Equal(string key, object value) - => new ChromaWhereValueOperator(key, "$eq", value); + => new ChromaWhereValueOperator(key, "$eq", "$ne", value); + /// + /// The records whose value for the key does not equal the value, with $ne. + /// + /// The metadata key. + /// The value to compare with. + /// The filter. public static ChromaWhereOperator NotEqual(string key, object value) - => new ChromaWhereValueOperator(key, "$ne", value); + => new ChromaWhereValueOperator(key, "$ne", "$eq", value); + + /// + /// The records whose list in the metadata contains the value, with $contains: Chroma 1.5.0 and later. + /// + /// The metadata key. + /// The value the list of the key holds. + /// The filter. + public static ChromaWhereOperator Contains(string key, object value) + => new ChromaWhereValueOperator(key, "$contains", "$not_contains", value); + + /// + /// The records whose list in the metadata does not contain the value, with $not_contains: Chroma 1.5.0 and later. + /// + /// The metadata key. + /// The value the list of the key does not hold. + /// The filter. + public static ChromaWhereOperator NotContains(string key, object value) + => new ChromaWhereValueOperator(key, "$not_contains", "$contains", value); + /// + /// Always false, so that || combines two filters with $or, like |. + /// + /// The filter. + /// Always false, so that && and || combine both filters. public static bool operator true(ChromaWhereOperator _) => false; + /// + /// Always false, so that && combines two filters with $and, like &. + /// + /// The filter. + /// Always false, so that && and || combine both filters. public static bool operator false(ChromaWhereOperator _) => false; + /// + /// The records that match both filters, with $and. With None it is None, and with All the other filter. + /// + /// The first filter. + /// The second filter. + /// The filter that both filters pass, with $and. public static ChromaWhereOperator operator &(ChromaWhereOperator lhs, ChromaWhereOperator rhs) - => new ChromaWhereLogicalOperator("$and", lhs, rhs); + => lhs == None || rhs == None ? None + : lhs == All ? rhs + : rhs == All ? lhs + : new ChromaWhereLogicalOperator("$and", lhs, rhs); + /// + /// The records that match either filter, with $or. With All it is All, and with None the other filter. + /// + /// The first filter. + /// The second filter. + /// The filter that either filter passes, with $or. public static ChromaWhereOperator operator |(ChromaWhereOperator lhs, ChromaWhereOperator rhs) - => new ChromaWhereLogicalOperator("$or", lhs, rhs); + => lhs == All || rhs == All ? All + : lhs == None ? rhs + : rhs == None ? lhs + : new ChromaWhereLogicalOperator("$or", lhs, rhs); +} + +// All and None: Chroma has no where clause for either, so the client sends none for All and no request for None. +internal sealed class ChromaWhereConstantOperator(bool matchesAll) : ChromaWhereOperator(matchesAll ? "true" : "false") +{ + internal override Dictionary ToWhere() + => throw new InvalidOperationException($"The client sends no where clause for {(matchesAll ? "All" : "None")}."); + + internal override ChromaWhereOperator Negate() => matchesAll ? None : All; + + public override string ToString() => Operator; } internal class ChromaWhereLogicalOperator : ChromaWhereOperator @@ -59,22 +291,64 @@ internal ChromaWhereLogicalOperator(string @operator, ChromaWhereOperator lhs, C Rhs = rhs; } + // a & b & c goes as {"$and":[a,b,c]}, as the Python client writes it: each & inside the next would add a level for each filter, + // and System.Text.Json stops at 64 levels, so a chain of 32 filters failed. internal override Dictionary ToWhere() => new() { - { Operator, new object[] { Lhs.ToWhere(), Rhs.ToWhere() } } + { Operator, Operands().Select(x => (object)x.ToWhere()).ToArray() } }; + + // !(a & b) is !a | !b, and !(a | b) is !a & !b. + internal override ChromaWhereOperator Negate() + => Operands().Select(x => x.Negate()).Aggregate((left, right) => Operator == "$and" ? left | right : left & right); + + internal override IEnumerable Conjuncts() + => Operator == "$and" ? Operands() : [this]; + + internal override bool HasSearchOnlyCondition + => Operands().Any(x => x.HasSearchOnlyCondition); + + internal override ChromaWhereDocumentOperator? AsDocumentFilter() + { + var filters = Operands().Select(x => x.AsDocumentFilter()).ToList(); + return filters.Contains(null) ? null : filters.Aggregate((left, right) => Operator == "$and" ? left! & right! : left! | right!); + } + + // The filters of a chain of the same operator, in their order, without recursion for a long chain. + private List Operands() + { + var operands = new List(); + var stack = new Stack([Rhs, Lhs]); + while (stack.Count > 0) + { + var filter = stack.Pop(); + if (filter is ChromaWhereLogicalOperator logical && logical.Operator == Operator) + { + stack.Push(logical.Rhs); + stack.Push(logical.Lhs); + } + else + { + operands.Add(filter); + } + } + return operands; + } } internal class ChromaWhereValueOperator : ChromaWhereOperator { protected string Key { get; } protected object Value { get; } + // The operator of the negation, like $ne for $eq: Chroma has no $not. + protected string NegatedOperator { get; } - internal ChromaWhereValueOperator(string key, string @operator, object value) + internal ChromaWhereValueOperator(string key, string @operator, string negatedOperator, object value) : base(@operator) { Key = key; + NegatedOperator = negatedOperator; Value = value; } @@ -83,4 +357,40 @@ internal override Dictionary ToWhere() { { Key, new Dictionary { { Operator, Value } } } }; + + internal override ChromaWhereOperator Negate() + => new ChromaWhereValueOperator(Key, NegatedOperator, Operator, Value); + + internal override bool HasSearchOnlyCondition => Key == ChromaSearchKeys.Id; + + // The ids of a condition on the ids with $eq or $in, which get, query and delete send as their ids; null for any other. + internal IReadOnlyList? Ids() + => Key != ChromaSearchKeys.Id ? null + : Operator == "$eq" ? [Id(Value)] + : Operator == "$in" ? ((IEnumerable)Value).Select(Id).ToList() + : null; + + private static string Id(object id) + => id as string ?? throw new ArgumentException("The ids of the records are strings.", nameof(id)); +} + +// A filter on the documents in the where clause, which the Search API takes on #document. +internal sealed class ChromaWhereDocumentFilter(ChromaWhereDocumentOperator filter) : ChromaWhereOperator(ChromaSearchKeys.Document) +{ + internal override Dictionary ToWhere() => filter.ToSearchWhere(); + + internal override ChromaWhereOperator Negate() => new ChromaWhereDocumentFilter(ChromaWhereDocumentOperator.Not(filter)); + + internal override bool HasSearchOnlyCondition => true; + + internal override ChromaWhereDocumentOperator? AsDocumentFilter() => filter; +} + +// A filter given as the dictionary of the where of the Chroma API, sent as it is. +internal sealed class ChromaWhereDictionaryOperator(IReadOnlyDictionary where) : ChromaWhereOperator("dictionary") +{ + internal override Dictionary ToWhere() => where.ToDictionary(x => x.Key, x => x.Value); + + internal override ChromaWhereOperator Negate() + => throw new NotSupportedException("Not cannot negate a filter given as a dictionary: Chroma has no $not."); } diff --git a/ChromaDB.Client/Common/AnswerChecks.cs b/ChromaDB.Client/Common/AnswerChecks.cs new file mode 100644 index 0000000..4ed15c7 --- /dev/null +++ b/ChromaDB.Client/Common/AnswerChecks.cs @@ -0,0 +1,85 @@ +using ChromaDB.Client.Models.Responses; + +namespace ChromaDB.Client.Common; + +// An answer to a get, a query or a search whose lists do not have one item for every record: Chroma 1.5.9 answers a get with embeddings +// without the embedding of a record while other clients write and delete the same records, and the same get right after is whole. The +// read goes again, as after an error that can pass (ChromaException.UnevenAnswer); after the last attempt the ChromaException stands, +// instead of an ArgumentOutOfRangeException from the lists. +internal static class AnswerChecks +{ + public static CollectionEntriesGetResponse Checked(this CollectionEntriesGetResponse answer, string operation) + { + var ids = answer.Ids.Count; + Check(operation, ids, "embeddings", answer.Embeddings?.Count); + Check(operation, ids, "metadatas", answer.Metadatas?.Count); + Check(operation, ids, "documents", answer.Documents?.Count); + Check(operation, ids, "uris", answer.Uris?.Count); + return answer; + } + + // A query has one list of each for every query embedding, the ids too. + public static CollectionEntriesQueryResponse Checked(this CollectionEntriesQueryResponse answer, int queryEmbeddings) + { + CheckIds("query", answer.Ids, queryEmbeddings); + CheckEach("query", answer.Ids, "distances", answer.Distances, list => list?.Count, optional: false); + CheckEach("query", answer.Ids, "metadatas", answer.Metadatas, list => list?.Count, optional: false); + CheckEach("query", answer.Ids, "embeddings", answer.Embeddings, list => list?.Count, optional: false); + CheckEach("query", answer.Ids, "documents", answer.Documents, list => list?.Count, optional: false); + CheckEach("query", answer.Ids, "uris", answer.Uris, list => list?.Count, optional: false); + return answer; + } + + // A search has one list of each for every search, the ids too; another list is null when the search does not select it. + public static CollectionSearchResponse Checked(this CollectionSearchResponse answer, int searches) + { + CheckIds("search", answer.Ids, searches); + CheckEach("search", answer.Ids, "documents", answer.Documents, list => list?.Count, optional: true); + CheckEach("search", answer.Ids, "embeddings", answer.Embeddings, list => list?.Count, optional: true); + CheckEach("search", answer.Ids, "metadatas", answer.Metadatas, list => list?.Count, optional: true); + CheckEach("search", answer.Ids, "scores", answer.Scores, list => list?.Count, optional: true); + return answer; + } + + private static void CheckIds(string operation, List> ids, int expected) + { + if (ids.Count != expected) + { + var each = operation == "query" ? "query embedding" : "search"; + throw Uneven($"The answer of the server to a {operation} has {ids.Count} {(ids.Count == 1 ? "list" : "lists")} of ids for {expected} {(expected == 1 ? each : operation == "query" ? "query embeddings" : "searches")}, while it has one list of each for every {each}."); + } + } + + private static void CheckEach(string operation, List> ids, string name, List? lists, Func count, bool optional) + { + if (lists is null) + { + return; + } + if (lists.Count != ids.Count) + { + throw Uneven($"The answer of the server to a {operation} has {ids.Count} lists of ids and {lists.Count} lists of {name}, while it has one list of each for every {(operation == "query" ? "query embedding" : "search")}."); + } + for (var i = 0; i < ids.Count; i++) + { + if (count(lists[i]) is { } items) + { + Check(operation, ids[i].Count, name, items); + } + else if (!optional) + { + Check(operation, ids[i].Count, name, 0); + } + } + } + + private static void Check(string operation, int ids, string name, int? count) + { + if (count is { } items && items != ids) + { + throw Uneven($"The answer of the server to a {operation} has {ids} {(ids == 1 ? "id" : "ids")} and {items} {name}, while it has one item for every record in each list."); + } + } + + private static ChromaException Uneven(string message) => new(message) { UnevenAnswer = true }; +} diff --git a/ChromaDB.Client/Common/Bm25Tokenizer.cs b/ChromaDB.Client/Common/Bm25Tokenizer.cs new file mode 100644 index 0000000..5c89c87 --- /dev/null +++ b/ChromaDB.Client/Common/Bm25Tokenizer.cs @@ -0,0 +1,227 @@ +using System.Text; + +namespace ChromaDB.Client.Common; + +// The tokenizer of the BM25 function of Chroma, as bm25_tokenizer.py of its Python client 1.5.9 does it: +// the runs of characters that are neither word characters nor spaces become a space, the text is lowercased and split on spaces, +// the stopwords and the tokens longer than the limit are dropped, and the others are stemmed with Snowball English. +// Word characters, spaces and lowercase follow the rules of Python, from the tables of Bm25Unicode, not those of the .NET runtime, +// which differ in some characters and know fewer of them on .NET Framework. +internal sealed class Bm25Tokenizer(SnowballEnglishStemmer stemmer, HashSet stopwords, int tokenMaxLength) +{ + public List Tokenize(string text) + { + var tokens = new List(); + foreach (var token in Split(PythonLower(RemoveNonAlphanumeric(text)))) + { + if (stopwords.Contains(token) || CodePoints(token) > tokenMaxLength) + { + continue; + } + var stemmed = PythonStrip(stemmer.Stem(token)); + if (stemmed.Length > 0) + { + tokens.Add(stemmed); + } + } + return tokens; + } + + // re.sub(r"[^\w\s]+", " ", text, flags=re.UNICODE) + static string RemoveNonAlphanumeric(string text) + { + var result = new StringBuilder(text.Length); + var inRun = false; + for (var i = 0; i < text.Length; i += CodePointLength(text, i)) + { + var codePoint = CodePointAt(text, i); + if (IsPythonWord(codePoint) || IsPythonSpace(codePoint)) + { + result.Append(text, i, CodePointLength(text, i)); + inRun = false; + } + else if (!inRun) + { + result.Append(' '); + inRun = true; + } + } + return result.ToString(); + } + + // str.split(): the runs of spaces separate the tokens. + static IEnumerable Split(string text) + { + var start = -1; + for (var i = 0; i < text.Length; i += CodePointLength(text, i)) + { + if (IsPythonSpace(CodePointAt(text, i))) + { + if (start >= 0) + { + yield return text.Substring(start, i - start); + start = -1; + } + } + else if (start < 0) + { + start = i; + } + } + if (start >= 0) + { + yield return text.Substring(start); + } + } + + // str.lower(): the lowercase of each character, and a capital sigma at the end of a word becomes a final sigma. + internal static string PythonLower(string text) + { + var codePoints = new List(text.Length); + for (var i = 0; i < text.Length; i += CodePointLength(text, i)) + { + codePoints.Add(CodePointAt(text, i)); + } + var result = new StringBuilder(text.Length); + for (var i = 0; i < codePoints.Count; i++) + { + var codePoint = codePoints[i]; + if (Bm25Unicode.LowercaseLonger.TryGetValue(codePoint, out var longer)) + { + result.Append(longer); + } + else if (codePoint == 0x03A3) + { + result.Append(IsFinalSigma(codePoints, i) ? 'ς' : 'σ'); + } + else + { + AppendCodePoint(result, Lowercase(codePoint)); + } + } + return result.ToString(); + } + + // The Final_Sigma context of Unicode, as Python checks it: a cased character before, skipping the case-ignorable ones, + // and no cased character after. + static bool IsFinalSigma(List codePoints, int index) + { + var before = index - 1; + while (before >= 0 && IsCaseIgnorable(codePoints[before])) + { + before--; + } + if (before < 0 || !IsCased(codePoints[before])) + { + return false; + } + var after = index + 1; + while (after < codePoints.Count && IsCaseIgnorable(codePoints[after])) + { + after++; + } + return after == codePoints.Count || !IsCased(codePoints[after]); + } + + static bool IsPythonWord(int codePoint) => InRanges(Bm25Unicode.WordRanges, codePoint); + + internal static bool IsPythonSpace(int codePoint) => InRanges(Bm25Unicode.SpaceRanges, codePoint); + + static bool IsCased(int codePoint) => InRanges(Bm25Unicode.CasedRanges, codePoint); + + static bool IsCaseIgnorable(int codePoint) => InRanges(Bm25Unicode.CaseIgnorableRanges, codePoint); + + // Binary search on pairs of first and last code point. + static bool InRanges(int[] ranges, int codePoint) + { + int low = 0, high = ranges.Length / 2 - 1; + while (low <= high) + { + var middle = (low + high) / 2; + if (codePoint < ranges[middle * 2]) + { + high = middle - 1; + } + else if (codePoint > ranges[middle * 2 + 1]) + { + low = middle + 1; + } + else + { + return true; + } + } + return false; + } + + // Binary search on pairs of code point and lowercase code point. + static int Lowercase(int codePoint) + { + var pairs = Bm25Unicode.Lowercase; + int low = 0, high = pairs.Length / 2 - 1; + while (low <= high) + { + var middle = (low + high) / 2; + var key = pairs[middle * 2]; + if (codePoint < key) + { + high = middle - 1; + } + else if (codePoint > key) + { + low = middle + 1; + } + else + { + return pairs[middle * 2 + 1]; + } + } + return codePoint; + } + + // str.strip() + static string PythonStrip(string text) + { + var start = 0; + while (start < text.Length && IsPythonSpace(text[start])) + { + start++; + } + var end = text.Length; + while (end > start && IsPythonSpace(text[end - 1])) + { + end--; + } + return text.Substring(start, end - start); + } + + // len() of Python counts code points. + internal static int CodePoints(string text) + { + var count = 0; + for (var i = 0; i < text.Length; i += CodePointLength(text, i)) + { + count++; + } + return count; + } + + // An unpaired surrogate counts as one code point, as in Python. + static int CodePointAt(string text, int index) + => CodePointLength(text, index) == 2 ? char.ConvertToUtf32(text[index], text[index + 1]) : text[index]; + + static int CodePointLength(string text, int index) + => char.IsHighSurrogate(text[index]) && index + 1 < text.Length && char.IsLowSurrogate(text[index + 1]) ? 2 : 1; + + static void AppendCodePoint(StringBuilder builder, int codePoint) + { + if (codePoint < 0x10000) + { + builder.Append((char)codePoint); + } + else + { + builder.Append(char.ConvertFromUtf32(codePoint)); + } + } +} diff --git a/ChromaDB.Client/Common/Bm25Unicode.cs b/ChromaDB.Client/Common/Bm25Unicode.cs new file mode 100644 index 0000000..c21032c --- /dev/null +++ b/ChromaDB.Client/Common/Bm25Unicode.cs @@ -0,0 +1,389 @@ +// +// Generated by tools/generate_bm25_unicode.py with Python 3.13.12, Unicode 15.1.0. +// + +namespace ChromaDB.Client.Common; + +// The Unicode rules of Python that the BM25 tokenizer follows. The ranges are pairs of first and last code point, in order. +internal static class Bm25Unicode +{ + // \w: str.isalnum() or "_". + internal static readonly int[] WordRanges = + [ + 0x30, 0x39, 0x41, 0x5A, 0x5F, 0x5F, 0x61, 0x7A, 0xAA, 0xAA, 0xB2, 0xB3, 0xB5, 0xB5, 0xB9, 0xBA, + 0xBC, 0xBE, 0xC0, 0xD6, 0xD8, 0xF6, 0xF8, 0x2C1, 0x2C6, 0x2D1, 0x2E0, 0x2E4, 0x2EC, 0x2EC, 0x2EE, 0x2EE, + 0x370, 0x374, 0x376, 0x377, 0x37A, 0x37D, 0x37F, 0x37F, 0x386, 0x386, 0x388, 0x38A, 0x38C, 0x38C, 0x38E, 0x3A1, + 0x3A3, 0x3F5, 0x3F7, 0x481, 0x48A, 0x52F, 0x531, 0x556, 0x559, 0x559, 0x560, 0x588, 0x5D0, 0x5EA, 0x5EF, 0x5F2, + 0x620, 0x64A, 0x660, 0x669, 0x66E, 0x66F, 0x671, 0x6D3, 0x6D5, 0x6D5, 0x6E5, 0x6E6, 0x6EE, 0x6FC, 0x6FF, 0x6FF, + 0x710, 0x710, 0x712, 0x72F, 0x74D, 0x7A5, 0x7B1, 0x7B1, 0x7C0, 0x7EA, 0x7F4, 0x7F5, 0x7FA, 0x7FA, 0x800, 0x815, + 0x81A, 0x81A, 0x824, 0x824, 0x828, 0x828, 0x840, 0x858, 0x860, 0x86A, 0x870, 0x887, 0x889, 0x88E, 0x8A0, 0x8C9, + 0x904, 0x939, 0x93D, 0x93D, 0x950, 0x950, 0x958, 0x961, 0x966, 0x96F, 0x971, 0x980, 0x985, 0x98C, 0x98F, 0x990, + 0x993, 0x9A8, 0x9AA, 0x9B0, 0x9B2, 0x9B2, 0x9B6, 0x9B9, 0x9BD, 0x9BD, 0x9CE, 0x9CE, 0x9DC, 0x9DD, 0x9DF, 0x9E1, + 0x9E6, 0x9F1, 0x9F4, 0x9F9, 0x9FC, 0x9FC, 0xA05, 0xA0A, 0xA0F, 0xA10, 0xA13, 0xA28, 0xA2A, 0xA30, 0xA32, 0xA33, + 0xA35, 0xA36, 0xA38, 0xA39, 0xA59, 0xA5C, 0xA5E, 0xA5E, 0xA66, 0xA6F, 0xA72, 0xA74, 0xA85, 0xA8D, 0xA8F, 0xA91, + 0xA93, 0xAA8, 0xAAA, 0xAB0, 0xAB2, 0xAB3, 0xAB5, 0xAB9, 0xABD, 0xABD, 0xAD0, 0xAD0, 0xAE0, 0xAE1, 0xAE6, 0xAEF, + 0xAF9, 0xAF9, 0xB05, 0xB0C, 0xB0F, 0xB10, 0xB13, 0xB28, 0xB2A, 0xB30, 0xB32, 0xB33, 0xB35, 0xB39, 0xB3D, 0xB3D, + 0xB5C, 0xB5D, 0xB5F, 0xB61, 0xB66, 0xB6F, 0xB71, 0xB77, 0xB83, 0xB83, 0xB85, 0xB8A, 0xB8E, 0xB90, 0xB92, 0xB95, + 0xB99, 0xB9A, 0xB9C, 0xB9C, 0xB9E, 0xB9F, 0xBA3, 0xBA4, 0xBA8, 0xBAA, 0xBAE, 0xBB9, 0xBD0, 0xBD0, 0xBE6, 0xBF2, + 0xC05, 0xC0C, 0xC0E, 0xC10, 0xC12, 0xC28, 0xC2A, 0xC39, 0xC3D, 0xC3D, 0xC58, 0xC5A, 0xC5D, 0xC5D, 0xC60, 0xC61, + 0xC66, 0xC6F, 0xC78, 0xC7E, 0xC80, 0xC80, 0xC85, 0xC8C, 0xC8E, 0xC90, 0xC92, 0xCA8, 0xCAA, 0xCB3, 0xCB5, 0xCB9, + 0xCBD, 0xCBD, 0xCDD, 0xCDE, 0xCE0, 0xCE1, 0xCE6, 0xCEF, 0xCF1, 0xCF2, 0xD04, 0xD0C, 0xD0E, 0xD10, 0xD12, 0xD3A, + 0xD3D, 0xD3D, 0xD4E, 0xD4E, 0xD54, 0xD56, 0xD58, 0xD61, 0xD66, 0xD78, 0xD7A, 0xD7F, 0xD85, 0xD96, 0xD9A, 0xDB1, + 0xDB3, 0xDBB, 0xDBD, 0xDBD, 0xDC0, 0xDC6, 0xDE6, 0xDEF, 0xE01, 0xE30, 0xE32, 0xE33, 0xE40, 0xE46, 0xE50, 0xE59, + 0xE81, 0xE82, 0xE84, 0xE84, 0xE86, 0xE8A, 0xE8C, 0xEA3, 0xEA5, 0xEA5, 0xEA7, 0xEB0, 0xEB2, 0xEB3, 0xEBD, 0xEBD, + 0xEC0, 0xEC4, 0xEC6, 0xEC6, 0xED0, 0xED9, 0xEDC, 0xEDF, 0xF00, 0xF00, 0xF20, 0xF33, 0xF40, 0xF47, 0xF49, 0xF6C, + 0xF88, 0xF8C, 0x1000, 0x102A, 0x103F, 0x1049, 0x1050, 0x1055, 0x105A, 0x105D, 0x1061, 0x1061, 0x1065, 0x1066, 0x106E, 0x1070, + 0x1075, 0x1081, 0x108E, 0x108E, 0x1090, 0x1099, 0x10A0, 0x10C5, 0x10C7, 0x10C7, 0x10CD, 0x10CD, 0x10D0, 0x10FA, 0x10FC, 0x1248, + 0x124A, 0x124D, 0x1250, 0x1256, 0x1258, 0x1258, 0x125A, 0x125D, 0x1260, 0x1288, 0x128A, 0x128D, 0x1290, 0x12B0, 0x12B2, 0x12B5, + 0x12B8, 0x12BE, 0x12C0, 0x12C0, 0x12C2, 0x12C5, 0x12C8, 0x12D6, 0x12D8, 0x1310, 0x1312, 0x1315, 0x1318, 0x135A, 0x1369, 0x137C, + 0x1380, 0x138F, 0x13A0, 0x13F5, 0x13F8, 0x13FD, 0x1401, 0x166C, 0x166F, 0x167F, 0x1681, 0x169A, 0x16A0, 0x16EA, 0x16EE, 0x16F8, + 0x1700, 0x1711, 0x171F, 0x1731, 0x1740, 0x1751, 0x1760, 0x176C, 0x176E, 0x1770, 0x1780, 0x17B3, 0x17D7, 0x17D7, 0x17DC, 0x17DC, + 0x17E0, 0x17E9, 0x17F0, 0x17F9, 0x1810, 0x1819, 0x1820, 0x1878, 0x1880, 0x1884, 0x1887, 0x18A8, 0x18AA, 0x18AA, 0x18B0, 0x18F5, + 0x1900, 0x191E, 0x1946, 0x196D, 0x1970, 0x1974, 0x1980, 0x19AB, 0x19B0, 0x19C9, 0x19D0, 0x19DA, 0x1A00, 0x1A16, 0x1A20, 0x1A54, + 0x1A80, 0x1A89, 0x1A90, 0x1A99, 0x1AA7, 0x1AA7, 0x1B05, 0x1B33, 0x1B45, 0x1B4C, 0x1B50, 0x1B59, 0x1B83, 0x1BA0, 0x1BAE, 0x1BE5, + 0x1C00, 0x1C23, 0x1C40, 0x1C49, 0x1C4D, 0x1C7D, 0x1C80, 0x1C88, 0x1C90, 0x1CBA, 0x1CBD, 0x1CBF, 0x1CE9, 0x1CEC, 0x1CEE, 0x1CF3, + 0x1CF5, 0x1CF6, 0x1CFA, 0x1CFA, 0x1D00, 0x1DBF, 0x1E00, 0x1F15, 0x1F18, 0x1F1D, 0x1F20, 0x1F45, 0x1F48, 0x1F4D, 0x1F50, 0x1F57, + 0x1F59, 0x1F59, 0x1F5B, 0x1F5B, 0x1F5D, 0x1F5D, 0x1F5F, 0x1F7D, 0x1F80, 0x1FB4, 0x1FB6, 0x1FBC, 0x1FBE, 0x1FBE, 0x1FC2, 0x1FC4, + 0x1FC6, 0x1FCC, 0x1FD0, 0x1FD3, 0x1FD6, 0x1FDB, 0x1FE0, 0x1FEC, 0x1FF2, 0x1FF4, 0x1FF6, 0x1FFC, 0x2070, 0x2071, 0x2074, 0x2079, + 0x207F, 0x2089, 0x2090, 0x209C, 0x2102, 0x2102, 0x2107, 0x2107, 0x210A, 0x2113, 0x2115, 0x2115, 0x2119, 0x211D, 0x2124, 0x2124, + 0x2126, 0x2126, 0x2128, 0x2128, 0x212A, 0x212D, 0x212F, 0x2139, 0x213C, 0x213F, 0x2145, 0x2149, 0x214E, 0x214E, 0x2150, 0x2189, + 0x2460, 0x249B, 0x24EA, 0x24FF, 0x2776, 0x2793, 0x2C00, 0x2CE4, 0x2CEB, 0x2CEE, 0x2CF2, 0x2CF3, 0x2CFD, 0x2CFD, 0x2D00, 0x2D25, + 0x2D27, 0x2D27, 0x2D2D, 0x2D2D, 0x2D30, 0x2D67, 0x2D6F, 0x2D6F, 0x2D80, 0x2D96, 0x2DA0, 0x2DA6, 0x2DA8, 0x2DAE, 0x2DB0, 0x2DB6, + 0x2DB8, 0x2DBE, 0x2DC0, 0x2DC6, 0x2DC8, 0x2DCE, 0x2DD0, 0x2DD6, 0x2DD8, 0x2DDE, 0x2E2F, 0x2E2F, 0x3005, 0x3007, 0x3021, 0x3029, + 0x3031, 0x3035, 0x3038, 0x303C, 0x3041, 0x3096, 0x309D, 0x309F, 0x30A1, 0x30FA, 0x30FC, 0x30FF, 0x3105, 0x312F, 0x3131, 0x318E, + 0x3192, 0x3195, 0x31A0, 0x31BF, 0x31F0, 0x31FF, 0x3220, 0x3229, 0x3248, 0x324F, 0x3251, 0x325F, 0x3280, 0x3289, 0x32B1, 0x32BF, + 0x3400, 0x4DBF, 0x4E00, 0xA48C, 0xA4D0, 0xA4FD, 0xA500, 0xA60C, 0xA610, 0xA62B, 0xA640, 0xA66E, 0xA67F, 0xA69D, 0xA6A0, 0xA6EF, + 0xA717, 0xA71F, 0xA722, 0xA788, 0xA78B, 0xA7CA, 0xA7D0, 0xA7D1, 0xA7D3, 0xA7D3, 0xA7D5, 0xA7D9, 0xA7F2, 0xA801, 0xA803, 0xA805, + 0xA807, 0xA80A, 0xA80C, 0xA822, 0xA830, 0xA835, 0xA840, 0xA873, 0xA882, 0xA8B3, 0xA8D0, 0xA8D9, 0xA8F2, 0xA8F7, 0xA8FB, 0xA8FB, + 0xA8FD, 0xA8FE, 0xA900, 0xA925, 0xA930, 0xA946, 0xA960, 0xA97C, 0xA984, 0xA9B2, 0xA9CF, 0xA9D9, 0xA9E0, 0xA9E4, 0xA9E6, 0xA9FE, + 0xAA00, 0xAA28, 0xAA40, 0xAA42, 0xAA44, 0xAA4B, 0xAA50, 0xAA59, 0xAA60, 0xAA76, 0xAA7A, 0xAA7A, 0xAA7E, 0xAAAF, 0xAAB1, 0xAAB1, + 0xAAB5, 0xAAB6, 0xAAB9, 0xAABD, 0xAAC0, 0xAAC0, 0xAAC2, 0xAAC2, 0xAADB, 0xAADD, 0xAAE0, 0xAAEA, 0xAAF2, 0xAAF4, 0xAB01, 0xAB06, + 0xAB09, 0xAB0E, 0xAB11, 0xAB16, 0xAB20, 0xAB26, 0xAB28, 0xAB2E, 0xAB30, 0xAB5A, 0xAB5C, 0xAB69, 0xAB70, 0xABE2, 0xABF0, 0xABF9, + 0xAC00, 0xD7A3, 0xD7B0, 0xD7C6, 0xD7CB, 0xD7FB, 0xF900, 0xFA6D, 0xFA70, 0xFAD9, 0xFB00, 0xFB06, 0xFB13, 0xFB17, 0xFB1D, 0xFB1D, + 0xFB1F, 0xFB28, 0xFB2A, 0xFB36, 0xFB38, 0xFB3C, 0xFB3E, 0xFB3E, 0xFB40, 0xFB41, 0xFB43, 0xFB44, 0xFB46, 0xFBB1, 0xFBD3, 0xFD3D, + 0xFD50, 0xFD8F, 0xFD92, 0xFDC7, 0xFDF0, 0xFDFB, 0xFE70, 0xFE74, 0xFE76, 0xFEFC, 0xFF10, 0xFF19, 0xFF21, 0xFF3A, 0xFF41, 0xFF5A, + 0xFF66, 0xFFBE, 0xFFC2, 0xFFC7, 0xFFCA, 0xFFCF, 0xFFD2, 0xFFD7, 0xFFDA, 0xFFDC, 0x10000, 0x1000B, 0x1000D, 0x10026, 0x10028, 0x1003A, + 0x1003C, 0x1003D, 0x1003F, 0x1004D, 0x10050, 0x1005D, 0x10080, 0x100FA, 0x10107, 0x10133, 0x10140, 0x10178, 0x1018A, 0x1018B, 0x10280, 0x1029C, + 0x102A0, 0x102D0, 0x102E1, 0x102FB, 0x10300, 0x10323, 0x1032D, 0x1034A, 0x10350, 0x10375, 0x10380, 0x1039D, 0x103A0, 0x103C3, 0x103C8, 0x103CF, + 0x103D1, 0x103D5, 0x10400, 0x1049D, 0x104A0, 0x104A9, 0x104B0, 0x104D3, 0x104D8, 0x104FB, 0x10500, 0x10527, 0x10530, 0x10563, 0x10570, 0x1057A, + 0x1057C, 0x1058A, 0x1058C, 0x10592, 0x10594, 0x10595, 0x10597, 0x105A1, 0x105A3, 0x105B1, 0x105B3, 0x105B9, 0x105BB, 0x105BC, 0x10600, 0x10736, + 0x10740, 0x10755, 0x10760, 0x10767, 0x10780, 0x10785, 0x10787, 0x107B0, 0x107B2, 0x107BA, 0x10800, 0x10805, 0x10808, 0x10808, 0x1080A, 0x10835, + 0x10837, 0x10838, 0x1083C, 0x1083C, 0x1083F, 0x10855, 0x10858, 0x10876, 0x10879, 0x1089E, 0x108A7, 0x108AF, 0x108E0, 0x108F2, 0x108F4, 0x108F5, + 0x108FB, 0x1091B, 0x10920, 0x10939, 0x10980, 0x109B7, 0x109BC, 0x109CF, 0x109D2, 0x10A00, 0x10A10, 0x10A13, 0x10A15, 0x10A17, 0x10A19, 0x10A35, + 0x10A40, 0x10A48, 0x10A60, 0x10A7E, 0x10A80, 0x10A9F, 0x10AC0, 0x10AC7, 0x10AC9, 0x10AE4, 0x10AEB, 0x10AEF, 0x10B00, 0x10B35, 0x10B40, 0x10B55, + 0x10B58, 0x10B72, 0x10B78, 0x10B91, 0x10BA9, 0x10BAF, 0x10C00, 0x10C48, 0x10C80, 0x10CB2, 0x10CC0, 0x10CF2, 0x10CFA, 0x10D23, 0x10D30, 0x10D39, + 0x10E60, 0x10E7E, 0x10E80, 0x10EA9, 0x10EB0, 0x10EB1, 0x10F00, 0x10F27, 0x10F30, 0x10F45, 0x10F51, 0x10F54, 0x10F70, 0x10F81, 0x10FB0, 0x10FCB, + 0x10FE0, 0x10FF6, 0x11003, 0x11037, 0x11052, 0x1106F, 0x11071, 0x11072, 0x11075, 0x11075, 0x11083, 0x110AF, 0x110D0, 0x110E8, 0x110F0, 0x110F9, + 0x11103, 0x11126, 0x11136, 0x1113F, 0x11144, 0x11144, 0x11147, 0x11147, 0x11150, 0x11172, 0x11176, 0x11176, 0x11183, 0x111B2, 0x111C1, 0x111C4, + 0x111D0, 0x111DA, 0x111DC, 0x111DC, 0x111E1, 0x111F4, 0x11200, 0x11211, 0x11213, 0x1122B, 0x1123F, 0x11240, 0x11280, 0x11286, 0x11288, 0x11288, + 0x1128A, 0x1128D, 0x1128F, 0x1129D, 0x1129F, 0x112A8, 0x112B0, 0x112DE, 0x112F0, 0x112F9, 0x11305, 0x1130C, 0x1130F, 0x11310, 0x11313, 0x11328, + 0x1132A, 0x11330, 0x11332, 0x11333, 0x11335, 0x11339, 0x1133D, 0x1133D, 0x11350, 0x11350, 0x1135D, 0x11361, 0x11400, 0x11434, 0x11447, 0x1144A, + 0x11450, 0x11459, 0x1145F, 0x11461, 0x11480, 0x114AF, 0x114C4, 0x114C5, 0x114C7, 0x114C7, 0x114D0, 0x114D9, 0x11580, 0x115AE, 0x115D8, 0x115DB, + 0x11600, 0x1162F, 0x11644, 0x11644, 0x11650, 0x11659, 0x11680, 0x116AA, 0x116B8, 0x116B8, 0x116C0, 0x116C9, 0x11700, 0x1171A, 0x11730, 0x1173B, + 0x11740, 0x11746, 0x11800, 0x1182B, 0x118A0, 0x118F2, 0x118FF, 0x11906, 0x11909, 0x11909, 0x1190C, 0x11913, 0x11915, 0x11916, 0x11918, 0x1192F, + 0x1193F, 0x1193F, 0x11941, 0x11941, 0x11950, 0x11959, 0x119A0, 0x119A7, 0x119AA, 0x119D0, 0x119E1, 0x119E1, 0x119E3, 0x119E3, 0x11A00, 0x11A00, + 0x11A0B, 0x11A32, 0x11A3A, 0x11A3A, 0x11A50, 0x11A50, 0x11A5C, 0x11A89, 0x11A9D, 0x11A9D, 0x11AB0, 0x11AF8, 0x11C00, 0x11C08, 0x11C0A, 0x11C2E, + 0x11C40, 0x11C40, 0x11C50, 0x11C6C, 0x11C72, 0x11C8F, 0x11D00, 0x11D06, 0x11D08, 0x11D09, 0x11D0B, 0x11D30, 0x11D46, 0x11D46, 0x11D50, 0x11D59, + 0x11D60, 0x11D65, 0x11D67, 0x11D68, 0x11D6A, 0x11D89, 0x11D98, 0x11D98, 0x11DA0, 0x11DA9, 0x11EE0, 0x11EF2, 0x11F02, 0x11F02, 0x11F04, 0x11F10, + 0x11F12, 0x11F33, 0x11F50, 0x11F59, 0x11FB0, 0x11FB0, 0x11FC0, 0x11FD4, 0x12000, 0x12399, 0x12400, 0x1246E, 0x12480, 0x12543, 0x12F90, 0x12FF0, + 0x13000, 0x1342F, 0x13441, 0x13446, 0x14400, 0x14646, 0x16800, 0x16A38, 0x16A40, 0x16A5E, 0x16A60, 0x16A69, 0x16A70, 0x16ABE, 0x16AC0, 0x16AC9, + 0x16AD0, 0x16AED, 0x16B00, 0x16B2F, 0x16B40, 0x16B43, 0x16B50, 0x16B59, 0x16B5B, 0x16B61, 0x16B63, 0x16B77, 0x16B7D, 0x16B8F, 0x16E40, 0x16E96, + 0x16F00, 0x16F4A, 0x16F50, 0x16F50, 0x16F93, 0x16F9F, 0x16FE0, 0x16FE1, 0x16FE3, 0x16FE3, 0x17000, 0x187F7, 0x18800, 0x18CD5, 0x18D00, 0x18D08, + 0x1AFF0, 0x1AFF3, 0x1AFF5, 0x1AFFB, 0x1AFFD, 0x1AFFE, 0x1B000, 0x1B122, 0x1B132, 0x1B132, 0x1B150, 0x1B152, 0x1B155, 0x1B155, 0x1B164, 0x1B167, + 0x1B170, 0x1B2FB, 0x1BC00, 0x1BC6A, 0x1BC70, 0x1BC7C, 0x1BC80, 0x1BC88, 0x1BC90, 0x1BC99, 0x1D2C0, 0x1D2D3, 0x1D2E0, 0x1D2F3, 0x1D360, 0x1D378, + 0x1D400, 0x1D454, 0x1D456, 0x1D49C, 0x1D49E, 0x1D49F, 0x1D4A2, 0x1D4A2, 0x1D4A5, 0x1D4A6, 0x1D4A9, 0x1D4AC, 0x1D4AE, 0x1D4B9, 0x1D4BB, 0x1D4BB, + 0x1D4BD, 0x1D4C3, 0x1D4C5, 0x1D505, 0x1D507, 0x1D50A, 0x1D50D, 0x1D514, 0x1D516, 0x1D51C, 0x1D51E, 0x1D539, 0x1D53B, 0x1D53E, 0x1D540, 0x1D544, + 0x1D546, 0x1D546, 0x1D54A, 0x1D550, 0x1D552, 0x1D6A5, 0x1D6A8, 0x1D6C0, 0x1D6C2, 0x1D6DA, 0x1D6DC, 0x1D6FA, 0x1D6FC, 0x1D714, 0x1D716, 0x1D734, + 0x1D736, 0x1D74E, 0x1D750, 0x1D76E, 0x1D770, 0x1D788, 0x1D78A, 0x1D7A8, 0x1D7AA, 0x1D7C2, 0x1D7C4, 0x1D7CB, 0x1D7CE, 0x1D7FF, 0x1DF00, 0x1DF1E, + 0x1DF25, 0x1DF2A, 0x1E030, 0x1E06D, 0x1E100, 0x1E12C, 0x1E137, 0x1E13D, 0x1E140, 0x1E149, 0x1E14E, 0x1E14E, 0x1E290, 0x1E2AD, 0x1E2C0, 0x1E2EB, + 0x1E2F0, 0x1E2F9, 0x1E4D0, 0x1E4EB, 0x1E4F0, 0x1E4F9, 0x1E7E0, 0x1E7E6, 0x1E7E8, 0x1E7EB, 0x1E7ED, 0x1E7EE, 0x1E7F0, 0x1E7FE, 0x1E800, 0x1E8C4, + 0x1E8C7, 0x1E8CF, 0x1E900, 0x1E943, 0x1E94B, 0x1E94B, 0x1E950, 0x1E959, 0x1EC71, 0x1ECAB, 0x1ECAD, 0x1ECAF, 0x1ECB1, 0x1ECB4, 0x1ED01, 0x1ED2D, + 0x1ED2F, 0x1ED3D, 0x1EE00, 0x1EE03, 0x1EE05, 0x1EE1F, 0x1EE21, 0x1EE22, 0x1EE24, 0x1EE24, 0x1EE27, 0x1EE27, 0x1EE29, 0x1EE32, 0x1EE34, 0x1EE37, + 0x1EE39, 0x1EE39, 0x1EE3B, 0x1EE3B, 0x1EE42, 0x1EE42, 0x1EE47, 0x1EE47, 0x1EE49, 0x1EE49, 0x1EE4B, 0x1EE4B, 0x1EE4D, 0x1EE4F, 0x1EE51, 0x1EE52, + 0x1EE54, 0x1EE54, 0x1EE57, 0x1EE57, 0x1EE59, 0x1EE59, 0x1EE5B, 0x1EE5B, 0x1EE5D, 0x1EE5D, 0x1EE5F, 0x1EE5F, 0x1EE61, 0x1EE62, 0x1EE64, 0x1EE64, + 0x1EE67, 0x1EE6A, 0x1EE6C, 0x1EE72, 0x1EE74, 0x1EE77, 0x1EE79, 0x1EE7C, 0x1EE7E, 0x1EE7E, 0x1EE80, 0x1EE89, 0x1EE8B, 0x1EE9B, 0x1EEA1, 0x1EEA3, + 0x1EEA5, 0x1EEA9, 0x1EEAB, 0x1EEBB, 0x1F100, 0x1F10C, 0x1FBF0, 0x1FBF9, 0x20000, 0x2A6DF, 0x2A700, 0x2B739, 0x2B740, 0x2B81D, 0x2B820, 0x2CEA1, + 0x2CEB0, 0x2EBE0, 0x2EBF0, 0x2EE5D, 0x2F800, 0x2FA1D, 0x30000, 0x3134A, 0x31350, 0x323AF, + ]; + + // \s: str.isspace(). + internal static readonly int[] SpaceRanges = + [ + 0x9, 0xD, 0x1C, 0x20, 0x85, 0x85, 0xA0, 0xA0, 0x1680, 0x1680, 0x2000, 0x200A, 0x2028, 0x2029, 0x202F, 0x202F, + 0x205F, 0x205F, 0x3000, 0x3000, + ]; + + // The characters that are cased and not case-ignorable, for the final sigma. + internal static readonly int[] CasedRanges = + [ + 0x41, 0x5A, 0x61, 0x7A, 0xAA, 0xAA, 0xB5, 0xB5, 0xBA, 0xBA, 0xC0, 0xD6, 0xD8, 0xF6, 0xF8, 0x1BA, + 0x1BC, 0x1BF, 0x1C4, 0x293, 0x295, 0x2AF, 0x370, 0x373, 0x376, 0x377, 0x37B, 0x37D, 0x37F, 0x37F, 0x386, 0x386, + 0x388, 0x38A, 0x38C, 0x38C, 0x38E, 0x3A1, 0x3A3, 0x3F5, 0x3F7, 0x481, 0x48A, 0x52F, 0x531, 0x556, 0x560, 0x588, + 0x10A0, 0x10C5, 0x10C7, 0x10C7, 0x10CD, 0x10CD, 0x10D0, 0x10FA, 0x10FD, 0x10FF, 0x13A0, 0x13F5, 0x13F8, 0x13FD, 0x1C80, 0x1C88, + 0x1C90, 0x1CBA, 0x1CBD, 0x1CBF, 0x1D00, 0x1D2B, 0x1D6B, 0x1D77, 0x1D79, 0x1D9A, 0x1E00, 0x1F15, 0x1F18, 0x1F1D, 0x1F20, 0x1F45, + 0x1F48, 0x1F4D, 0x1F50, 0x1F57, 0x1F59, 0x1F59, 0x1F5B, 0x1F5B, 0x1F5D, 0x1F5D, 0x1F5F, 0x1F7D, 0x1F80, 0x1FB4, 0x1FB6, 0x1FBC, + 0x1FBE, 0x1FBE, 0x1FC2, 0x1FC4, 0x1FC6, 0x1FCC, 0x1FD0, 0x1FD3, 0x1FD6, 0x1FDB, 0x1FE0, 0x1FEC, 0x1FF2, 0x1FF4, 0x1FF6, 0x1FFC, + 0x2102, 0x2102, 0x2107, 0x2107, 0x210A, 0x2113, 0x2115, 0x2115, 0x2119, 0x211D, 0x2124, 0x2124, 0x2126, 0x2126, 0x2128, 0x2128, + 0x212A, 0x212D, 0x212F, 0x2134, 0x2139, 0x2139, 0x213C, 0x213F, 0x2145, 0x2149, 0x214E, 0x214E, 0x2160, 0x217F, 0x2183, 0x2184, + 0x24B6, 0x24E9, 0x2C00, 0x2C7B, 0x2C7E, 0x2CE4, 0x2CEB, 0x2CEE, 0x2CF2, 0x2CF3, 0x2D00, 0x2D25, 0x2D27, 0x2D27, 0x2D2D, 0x2D2D, + 0xA640, 0xA66D, 0xA680, 0xA69B, 0xA722, 0xA76F, 0xA771, 0xA787, 0xA78B, 0xA78E, 0xA790, 0xA7CA, 0xA7D0, 0xA7D1, 0xA7D3, 0xA7D3, + 0xA7D5, 0xA7D9, 0xA7F5, 0xA7F6, 0xA7FA, 0xA7FA, 0xAB30, 0xAB5A, 0xAB60, 0xAB68, 0xAB70, 0xABBF, 0xFB00, 0xFB06, 0xFB13, 0xFB17, + 0xFF21, 0xFF3A, 0xFF41, 0xFF5A, 0x10400, 0x1044F, 0x104B0, 0x104D3, 0x104D8, 0x104FB, 0x10570, 0x1057A, 0x1057C, 0x1058A, 0x1058C, 0x10592, + 0x10594, 0x10595, 0x10597, 0x105A1, 0x105A3, 0x105B1, 0x105B3, 0x105B9, 0x105BB, 0x105BC, 0x10C80, 0x10CB2, 0x10CC0, 0x10CF2, 0x118A0, 0x118DF, + 0x16E40, 0x16E7F, 0x1D400, 0x1D454, 0x1D456, 0x1D49C, 0x1D49E, 0x1D49F, 0x1D4A2, 0x1D4A2, 0x1D4A5, 0x1D4A6, 0x1D4A9, 0x1D4AC, 0x1D4AE, 0x1D4B9, + 0x1D4BB, 0x1D4BB, 0x1D4BD, 0x1D4C3, 0x1D4C5, 0x1D505, 0x1D507, 0x1D50A, 0x1D50D, 0x1D514, 0x1D516, 0x1D51C, 0x1D51E, 0x1D539, 0x1D53B, 0x1D53E, + 0x1D540, 0x1D544, 0x1D546, 0x1D546, 0x1D54A, 0x1D550, 0x1D552, 0x1D6A5, 0x1D6A8, 0x1D6C0, 0x1D6C2, 0x1D6DA, 0x1D6DC, 0x1D6FA, 0x1D6FC, 0x1D714, + 0x1D716, 0x1D734, 0x1D736, 0x1D74E, 0x1D750, 0x1D76E, 0x1D770, 0x1D788, 0x1D78A, 0x1D7A8, 0x1D7AA, 0x1D7C2, 0x1D7C4, 0x1D7CB, 0x1DF00, 0x1DF09, + 0x1DF0B, 0x1DF1E, 0x1DF25, 0x1DF2A, 0x1E900, 0x1E943, 0x1F130, 0x1F149, 0x1F150, 0x1F169, 0x1F170, 0x1F189, + ]; + + // The characters that are case-ignorable, for the final sigma. + internal static readonly int[] CaseIgnorableRanges = + [ + 0x27, 0x27, 0x2E, 0x2E, 0x3A, 0x3A, 0x5E, 0x5E, 0x60, 0x60, 0xA8, 0xA8, 0xAD, 0xAD, 0xAF, 0xAF, + 0xB4, 0xB4, 0xB7, 0xB8, 0x2B0, 0x36F, 0x374, 0x375, 0x37A, 0x37A, 0x384, 0x385, 0x387, 0x387, 0x483, 0x489, + 0x559, 0x559, 0x55F, 0x55F, 0x591, 0x5BD, 0x5BF, 0x5BF, 0x5C1, 0x5C2, 0x5C4, 0x5C5, 0x5C7, 0x5C7, 0x5F4, 0x5F4, + 0x600, 0x605, 0x610, 0x61A, 0x61C, 0x61C, 0x640, 0x640, 0x64B, 0x65F, 0x670, 0x670, 0x6D6, 0x6DD, 0x6DF, 0x6E8, + 0x6EA, 0x6ED, 0x70F, 0x70F, 0x711, 0x711, 0x730, 0x74A, 0x7A6, 0x7B0, 0x7EB, 0x7F5, 0x7FA, 0x7FA, 0x7FD, 0x7FD, + 0x816, 0x82D, 0x859, 0x85B, 0x888, 0x888, 0x890, 0x891, 0x898, 0x89F, 0x8C9, 0x902, 0x93A, 0x93A, 0x93C, 0x93C, + 0x941, 0x948, 0x94D, 0x94D, 0x951, 0x957, 0x962, 0x963, 0x971, 0x971, 0x981, 0x981, 0x9BC, 0x9BC, 0x9C1, 0x9C4, + 0x9CD, 0x9CD, 0x9E2, 0x9E3, 0x9FE, 0x9FE, 0xA01, 0xA02, 0xA3C, 0xA3C, 0xA41, 0xA42, 0xA47, 0xA48, 0xA4B, 0xA4D, + 0xA51, 0xA51, 0xA70, 0xA71, 0xA75, 0xA75, 0xA81, 0xA82, 0xABC, 0xABC, 0xAC1, 0xAC5, 0xAC7, 0xAC8, 0xACD, 0xACD, + 0xAE2, 0xAE3, 0xAFA, 0xAFF, 0xB01, 0xB01, 0xB3C, 0xB3C, 0xB3F, 0xB3F, 0xB41, 0xB44, 0xB4D, 0xB4D, 0xB55, 0xB56, + 0xB62, 0xB63, 0xB82, 0xB82, 0xBC0, 0xBC0, 0xBCD, 0xBCD, 0xC00, 0xC00, 0xC04, 0xC04, 0xC3C, 0xC3C, 0xC3E, 0xC40, + 0xC46, 0xC48, 0xC4A, 0xC4D, 0xC55, 0xC56, 0xC62, 0xC63, 0xC81, 0xC81, 0xCBC, 0xCBC, 0xCBF, 0xCBF, 0xCC6, 0xCC6, + 0xCCC, 0xCCD, 0xCE2, 0xCE3, 0xD00, 0xD01, 0xD3B, 0xD3C, 0xD41, 0xD44, 0xD4D, 0xD4D, 0xD62, 0xD63, 0xD81, 0xD81, + 0xDCA, 0xDCA, 0xDD2, 0xDD4, 0xDD6, 0xDD6, 0xE31, 0xE31, 0xE34, 0xE3A, 0xE46, 0xE4E, 0xEB1, 0xEB1, 0xEB4, 0xEBC, + 0xEC6, 0xEC6, 0xEC8, 0xECE, 0xF18, 0xF19, 0xF35, 0xF35, 0xF37, 0xF37, 0xF39, 0xF39, 0xF71, 0xF7E, 0xF80, 0xF84, + 0xF86, 0xF87, 0xF8D, 0xF97, 0xF99, 0xFBC, 0xFC6, 0xFC6, 0x102D, 0x1030, 0x1032, 0x1037, 0x1039, 0x103A, 0x103D, 0x103E, + 0x1058, 0x1059, 0x105E, 0x1060, 0x1071, 0x1074, 0x1082, 0x1082, 0x1085, 0x1086, 0x108D, 0x108D, 0x109D, 0x109D, 0x10FC, 0x10FC, + 0x135D, 0x135F, 0x1712, 0x1714, 0x1732, 0x1733, 0x1752, 0x1753, 0x1772, 0x1773, 0x17B4, 0x17B5, 0x17B7, 0x17BD, 0x17C6, 0x17C6, + 0x17C9, 0x17D3, 0x17D7, 0x17D7, 0x17DD, 0x17DD, 0x180B, 0x180F, 0x1843, 0x1843, 0x1885, 0x1886, 0x18A9, 0x18A9, 0x1920, 0x1922, + 0x1927, 0x1928, 0x1932, 0x1932, 0x1939, 0x193B, 0x1A17, 0x1A18, 0x1A1B, 0x1A1B, 0x1A56, 0x1A56, 0x1A58, 0x1A5E, 0x1A60, 0x1A60, + 0x1A62, 0x1A62, 0x1A65, 0x1A6C, 0x1A73, 0x1A7C, 0x1A7F, 0x1A7F, 0x1AA7, 0x1AA7, 0x1AB0, 0x1ACE, 0x1B00, 0x1B03, 0x1B34, 0x1B34, + 0x1B36, 0x1B3A, 0x1B3C, 0x1B3C, 0x1B42, 0x1B42, 0x1B6B, 0x1B73, 0x1B80, 0x1B81, 0x1BA2, 0x1BA5, 0x1BA8, 0x1BA9, 0x1BAB, 0x1BAD, + 0x1BE6, 0x1BE6, 0x1BE8, 0x1BE9, 0x1BED, 0x1BED, 0x1BEF, 0x1BF1, 0x1C2C, 0x1C33, 0x1C36, 0x1C37, 0x1C78, 0x1C7D, 0x1CD0, 0x1CD2, + 0x1CD4, 0x1CE0, 0x1CE2, 0x1CE8, 0x1CED, 0x1CED, 0x1CF4, 0x1CF4, 0x1CF8, 0x1CF9, 0x1D2C, 0x1D6A, 0x1D78, 0x1D78, 0x1D9B, 0x1DFF, + 0x1FBD, 0x1FBD, 0x1FBF, 0x1FC1, 0x1FCD, 0x1FCF, 0x1FDD, 0x1FDF, 0x1FED, 0x1FEF, 0x1FFD, 0x1FFE, 0x200B, 0x200F, 0x2018, 0x2019, + 0x2024, 0x2024, 0x2027, 0x2027, 0x202A, 0x202E, 0x2060, 0x2064, 0x2066, 0x206F, 0x2071, 0x2071, 0x207F, 0x207F, 0x2090, 0x209C, + 0x20D0, 0x20F0, 0x2C7C, 0x2C7D, 0x2CEF, 0x2CF1, 0x2D6F, 0x2D6F, 0x2D7F, 0x2D7F, 0x2DE0, 0x2DFF, 0x2E2F, 0x2E2F, 0x3005, 0x3005, + 0x302A, 0x302D, 0x3031, 0x3035, 0x303B, 0x303B, 0x3099, 0x309E, 0x30FC, 0x30FE, 0xA015, 0xA015, 0xA4F8, 0xA4FD, 0xA60C, 0xA60C, + 0xA66F, 0xA672, 0xA674, 0xA67D, 0xA67F, 0xA67F, 0xA69C, 0xA69F, 0xA6F0, 0xA6F1, 0xA700, 0xA721, 0xA770, 0xA770, 0xA788, 0xA78A, + 0xA7F2, 0xA7F4, 0xA7F8, 0xA7F9, 0xA802, 0xA802, 0xA806, 0xA806, 0xA80B, 0xA80B, 0xA825, 0xA826, 0xA82C, 0xA82C, 0xA8C4, 0xA8C5, + 0xA8E0, 0xA8F1, 0xA8FF, 0xA8FF, 0xA926, 0xA92D, 0xA947, 0xA951, 0xA980, 0xA982, 0xA9B3, 0xA9B3, 0xA9B6, 0xA9B9, 0xA9BC, 0xA9BD, + 0xA9CF, 0xA9CF, 0xA9E5, 0xA9E6, 0xAA29, 0xAA2E, 0xAA31, 0xAA32, 0xAA35, 0xAA36, 0xAA43, 0xAA43, 0xAA4C, 0xAA4C, 0xAA70, 0xAA70, + 0xAA7C, 0xAA7C, 0xAAB0, 0xAAB0, 0xAAB2, 0xAAB4, 0xAAB7, 0xAAB8, 0xAABE, 0xAABF, 0xAAC1, 0xAAC1, 0xAADD, 0xAADD, 0xAAEC, 0xAAED, + 0xAAF3, 0xAAF4, 0xAAF6, 0xAAF6, 0xAB5B, 0xAB5F, 0xAB69, 0xAB6B, 0xABE5, 0xABE5, 0xABE8, 0xABE8, 0xABED, 0xABED, 0xFB1E, 0xFB1E, + 0xFBB2, 0xFBC2, 0xFE00, 0xFE0F, 0xFE13, 0xFE13, 0xFE20, 0xFE2F, 0xFE52, 0xFE52, 0xFE55, 0xFE55, 0xFEFF, 0xFEFF, 0xFF07, 0xFF07, + 0xFF0E, 0xFF0E, 0xFF1A, 0xFF1A, 0xFF3E, 0xFF3E, 0xFF40, 0xFF40, 0xFF70, 0xFF70, 0xFF9E, 0xFF9F, 0xFFE3, 0xFFE3, 0xFFF9, 0xFFFB, + 0x101FD, 0x101FD, 0x102E0, 0x102E0, 0x10376, 0x1037A, 0x10780, 0x10785, 0x10787, 0x107B0, 0x107B2, 0x107BA, 0x10A01, 0x10A03, 0x10A05, 0x10A06, + 0x10A0C, 0x10A0F, 0x10A38, 0x10A3A, 0x10A3F, 0x10A3F, 0x10AE5, 0x10AE6, 0x10D24, 0x10D27, 0x10EAB, 0x10EAC, 0x10EFD, 0x10EFF, 0x10F46, 0x10F50, + 0x10F82, 0x10F85, 0x11001, 0x11001, 0x11038, 0x11046, 0x11070, 0x11070, 0x11073, 0x11074, 0x1107F, 0x11081, 0x110B3, 0x110B6, 0x110B9, 0x110BA, + 0x110BD, 0x110BD, 0x110C2, 0x110C2, 0x110CD, 0x110CD, 0x11100, 0x11102, 0x11127, 0x1112B, 0x1112D, 0x11134, 0x11173, 0x11173, 0x11180, 0x11181, + 0x111B6, 0x111BE, 0x111C9, 0x111CC, 0x111CF, 0x111CF, 0x1122F, 0x11231, 0x11234, 0x11234, 0x11236, 0x11237, 0x1123E, 0x1123E, 0x11241, 0x11241, + 0x112DF, 0x112DF, 0x112E3, 0x112EA, 0x11300, 0x11301, 0x1133B, 0x1133C, 0x11340, 0x11340, 0x11366, 0x1136C, 0x11370, 0x11374, 0x11438, 0x1143F, + 0x11442, 0x11444, 0x11446, 0x11446, 0x1145E, 0x1145E, 0x114B3, 0x114B8, 0x114BA, 0x114BA, 0x114BF, 0x114C0, 0x114C2, 0x114C3, 0x115B2, 0x115B5, + 0x115BC, 0x115BD, 0x115BF, 0x115C0, 0x115DC, 0x115DD, 0x11633, 0x1163A, 0x1163D, 0x1163D, 0x1163F, 0x11640, 0x116AB, 0x116AB, 0x116AD, 0x116AD, + 0x116B0, 0x116B5, 0x116B7, 0x116B7, 0x1171D, 0x1171F, 0x11722, 0x11725, 0x11727, 0x1172B, 0x1182F, 0x11837, 0x11839, 0x1183A, 0x1193B, 0x1193C, + 0x1193E, 0x1193E, 0x11943, 0x11943, 0x119D4, 0x119D7, 0x119DA, 0x119DB, 0x119E0, 0x119E0, 0x11A01, 0x11A0A, 0x11A33, 0x11A38, 0x11A3B, 0x11A3E, + 0x11A47, 0x11A47, 0x11A51, 0x11A56, 0x11A59, 0x11A5B, 0x11A8A, 0x11A96, 0x11A98, 0x11A99, 0x11C30, 0x11C36, 0x11C38, 0x11C3D, 0x11C3F, 0x11C3F, + 0x11C92, 0x11CA7, 0x11CAA, 0x11CB0, 0x11CB2, 0x11CB3, 0x11CB5, 0x11CB6, 0x11D31, 0x11D36, 0x11D3A, 0x11D3A, 0x11D3C, 0x11D3D, 0x11D3F, 0x11D45, + 0x11D47, 0x11D47, 0x11D90, 0x11D91, 0x11D95, 0x11D95, 0x11D97, 0x11D97, 0x11EF3, 0x11EF4, 0x11F00, 0x11F01, 0x11F36, 0x11F3A, 0x11F40, 0x11F40, + 0x11F42, 0x11F42, 0x13430, 0x13440, 0x13447, 0x13455, 0x16AF0, 0x16AF4, 0x16B30, 0x16B36, 0x16B40, 0x16B43, 0x16F4F, 0x16F4F, 0x16F8F, 0x16F9F, + 0x16FE0, 0x16FE1, 0x16FE3, 0x16FE4, 0x1AFF0, 0x1AFF3, 0x1AFF5, 0x1AFFB, 0x1AFFD, 0x1AFFE, 0x1BC9D, 0x1BC9E, 0x1BCA0, 0x1BCA3, 0x1CF00, 0x1CF2D, + 0x1CF30, 0x1CF46, 0x1D167, 0x1D169, 0x1D173, 0x1D182, 0x1D185, 0x1D18B, 0x1D1AA, 0x1D1AD, 0x1D242, 0x1D244, 0x1DA00, 0x1DA36, 0x1DA3B, 0x1DA6C, + 0x1DA75, 0x1DA75, 0x1DA84, 0x1DA84, 0x1DA9B, 0x1DA9F, 0x1DAA1, 0x1DAAF, 0x1E000, 0x1E006, 0x1E008, 0x1E018, 0x1E01B, 0x1E021, 0x1E023, 0x1E024, + 0x1E026, 0x1E02A, 0x1E030, 0x1E06D, 0x1E08F, 0x1E08F, 0x1E130, 0x1E13D, 0x1E2AE, 0x1E2AE, 0x1E2EC, 0x1E2EF, 0x1E4EB, 0x1E4EF, 0x1E8D0, 0x1E8D6, + 0x1E944, 0x1E94B, 0x1F3FB, 0x1F3FF, 0xE0001, 0xE0001, 0xE0020, 0xE007F, 0xE0100, 0xE01EF, + ]; + + // str.lower() of the characters that change, without the context of the final sigma: pairs of code point and lowercase. + internal static readonly int[] Lowercase = + [ + 0x41, 0x61, 0x42, 0x62, 0x43, 0x63, 0x44, 0x64, 0x45, 0x65, 0x46, 0x66, 0x47, 0x67, 0x48, 0x68, + 0x49, 0x69, 0x4A, 0x6A, 0x4B, 0x6B, 0x4C, 0x6C, 0x4D, 0x6D, 0x4E, 0x6E, 0x4F, 0x6F, 0x50, 0x70, + 0x51, 0x71, 0x52, 0x72, 0x53, 0x73, 0x54, 0x74, 0x55, 0x75, 0x56, 0x76, 0x57, 0x77, 0x58, 0x78, + 0x59, 0x79, 0x5A, 0x7A, 0xC0, 0xE0, 0xC1, 0xE1, 0xC2, 0xE2, 0xC3, 0xE3, 0xC4, 0xE4, 0xC5, 0xE5, + 0xC6, 0xE6, 0xC7, 0xE7, 0xC8, 0xE8, 0xC9, 0xE9, 0xCA, 0xEA, 0xCB, 0xEB, 0xCC, 0xEC, 0xCD, 0xED, + 0xCE, 0xEE, 0xCF, 0xEF, 0xD0, 0xF0, 0xD1, 0xF1, 0xD2, 0xF2, 0xD3, 0xF3, 0xD4, 0xF4, 0xD5, 0xF5, + 0xD6, 0xF6, 0xD8, 0xF8, 0xD9, 0xF9, 0xDA, 0xFA, 0xDB, 0xFB, 0xDC, 0xFC, 0xDD, 0xFD, 0xDE, 0xFE, + 0x100, 0x101, 0x102, 0x103, 0x104, 0x105, 0x106, 0x107, 0x108, 0x109, 0x10A, 0x10B, 0x10C, 0x10D, 0x10E, 0x10F, + 0x110, 0x111, 0x112, 0x113, 0x114, 0x115, 0x116, 0x117, 0x118, 0x119, 0x11A, 0x11B, 0x11C, 0x11D, 0x11E, 0x11F, + 0x120, 0x121, 0x122, 0x123, 0x124, 0x125, 0x126, 0x127, 0x128, 0x129, 0x12A, 0x12B, 0x12C, 0x12D, 0x12E, 0x12F, + 0x132, 0x133, 0x134, 0x135, 0x136, 0x137, 0x139, 0x13A, 0x13B, 0x13C, 0x13D, 0x13E, 0x13F, 0x140, 0x141, 0x142, + 0x143, 0x144, 0x145, 0x146, 0x147, 0x148, 0x14A, 0x14B, 0x14C, 0x14D, 0x14E, 0x14F, 0x150, 0x151, 0x152, 0x153, + 0x154, 0x155, 0x156, 0x157, 0x158, 0x159, 0x15A, 0x15B, 0x15C, 0x15D, 0x15E, 0x15F, 0x160, 0x161, 0x162, 0x163, + 0x164, 0x165, 0x166, 0x167, 0x168, 0x169, 0x16A, 0x16B, 0x16C, 0x16D, 0x16E, 0x16F, 0x170, 0x171, 0x172, 0x173, + 0x174, 0x175, 0x176, 0x177, 0x178, 0xFF, 0x179, 0x17A, 0x17B, 0x17C, 0x17D, 0x17E, 0x181, 0x253, 0x182, 0x183, + 0x184, 0x185, 0x186, 0x254, 0x187, 0x188, 0x189, 0x256, 0x18A, 0x257, 0x18B, 0x18C, 0x18E, 0x1DD, 0x18F, 0x259, + 0x190, 0x25B, 0x191, 0x192, 0x193, 0x260, 0x194, 0x263, 0x196, 0x269, 0x197, 0x268, 0x198, 0x199, 0x19C, 0x26F, + 0x19D, 0x272, 0x19F, 0x275, 0x1A0, 0x1A1, 0x1A2, 0x1A3, 0x1A4, 0x1A5, 0x1A6, 0x280, 0x1A7, 0x1A8, 0x1A9, 0x283, + 0x1AC, 0x1AD, 0x1AE, 0x288, 0x1AF, 0x1B0, 0x1B1, 0x28A, 0x1B2, 0x28B, 0x1B3, 0x1B4, 0x1B5, 0x1B6, 0x1B7, 0x292, + 0x1B8, 0x1B9, 0x1BC, 0x1BD, 0x1C4, 0x1C6, 0x1C5, 0x1C6, 0x1C7, 0x1C9, 0x1C8, 0x1C9, 0x1CA, 0x1CC, 0x1CB, 0x1CC, + 0x1CD, 0x1CE, 0x1CF, 0x1D0, 0x1D1, 0x1D2, 0x1D3, 0x1D4, 0x1D5, 0x1D6, 0x1D7, 0x1D8, 0x1D9, 0x1DA, 0x1DB, 0x1DC, + 0x1DE, 0x1DF, 0x1E0, 0x1E1, 0x1E2, 0x1E3, 0x1E4, 0x1E5, 0x1E6, 0x1E7, 0x1E8, 0x1E9, 0x1EA, 0x1EB, 0x1EC, 0x1ED, + 0x1EE, 0x1EF, 0x1F1, 0x1F3, 0x1F2, 0x1F3, 0x1F4, 0x1F5, 0x1F6, 0x195, 0x1F7, 0x1BF, 0x1F8, 0x1F9, 0x1FA, 0x1FB, + 0x1FC, 0x1FD, 0x1FE, 0x1FF, 0x200, 0x201, 0x202, 0x203, 0x204, 0x205, 0x206, 0x207, 0x208, 0x209, 0x20A, 0x20B, + 0x20C, 0x20D, 0x20E, 0x20F, 0x210, 0x211, 0x212, 0x213, 0x214, 0x215, 0x216, 0x217, 0x218, 0x219, 0x21A, 0x21B, + 0x21C, 0x21D, 0x21E, 0x21F, 0x220, 0x19E, 0x222, 0x223, 0x224, 0x225, 0x226, 0x227, 0x228, 0x229, 0x22A, 0x22B, + 0x22C, 0x22D, 0x22E, 0x22F, 0x230, 0x231, 0x232, 0x233, 0x23A, 0x2C65, 0x23B, 0x23C, 0x23D, 0x19A, 0x23E, 0x2C66, + 0x241, 0x242, 0x243, 0x180, 0x244, 0x289, 0x245, 0x28C, 0x246, 0x247, 0x248, 0x249, 0x24A, 0x24B, 0x24C, 0x24D, + 0x24E, 0x24F, 0x370, 0x371, 0x372, 0x373, 0x376, 0x377, 0x37F, 0x3F3, 0x386, 0x3AC, 0x388, 0x3AD, 0x389, 0x3AE, + 0x38A, 0x3AF, 0x38C, 0x3CC, 0x38E, 0x3CD, 0x38F, 0x3CE, 0x391, 0x3B1, 0x392, 0x3B2, 0x393, 0x3B3, 0x394, 0x3B4, + 0x395, 0x3B5, 0x396, 0x3B6, 0x397, 0x3B7, 0x398, 0x3B8, 0x399, 0x3B9, 0x39A, 0x3BA, 0x39B, 0x3BB, 0x39C, 0x3BC, + 0x39D, 0x3BD, 0x39E, 0x3BE, 0x39F, 0x3BF, 0x3A0, 0x3C0, 0x3A1, 0x3C1, 0x3A3, 0x3C3, 0x3A4, 0x3C4, 0x3A5, 0x3C5, + 0x3A6, 0x3C6, 0x3A7, 0x3C7, 0x3A8, 0x3C8, 0x3A9, 0x3C9, 0x3AA, 0x3CA, 0x3AB, 0x3CB, 0x3CF, 0x3D7, 0x3D8, 0x3D9, + 0x3DA, 0x3DB, 0x3DC, 0x3DD, 0x3DE, 0x3DF, 0x3E0, 0x3E1, 0x3E2, 0x3E3, 0x3E4, 0x3E5, 0x3E6, 0x3E7, 0x3E8, 0x3E9, + 0x3EA, 0x3EB, 0x3EC, 0x3ED, 0x3EE, 0x3EF, 0x3F4, 0x3B8, 0x3F7, 0x3F8, 0x3F9, 0x3F2, 0x3FA, 0x3FB, 0x3FD, 0x37B, + 0x3FE, 0x37C, 0x3FF, 0x37D, 0x400, 0x450, 0x401, 0x451, 0x402, 0x452, 0x403, 0x453, 0x404, 0x454, 0x405, 0x455, + 0x406, 0x456, 0x407, 0x457, 0x408, 0x458, 0x409, 0x459, 0x40A, 0x45A, 0x40B, 0x45B, 0x40C, 0x45C, 0x40D, 0x45D, + 0x40E, 0x45E, 0x40F, 0x45F, 0x410, 0x430, 0x411, 0x431, 0x412, 0x432, 0x413, 0x433, 0x414, 0x434, 0x415, 0x435, + 0x416, 0x436, 0x417, 0x437, 0x418, 0x438, 0x419, 0x439, 0x41A, 0x43A, 0x41B, 0x43B, 0x41C, 0x43C, 0x41D, 0x43D, + 0x41E, 0x43E, 0x41F, 0x43F, 0x420, 0x440, 0x421, 0x441, 0x422, 0x442, 0x423, 0x443, 0x424, 0x444, 0x425, 0x445, + 0x426, 0x446, 0x427, 0x447, 0x428, 0x448, 0x429, 0x449, 0x42A, 0x44A, 0x42B, 0x44B, 0x42C, 0x44C, 0x42D, 0x44D, + 0x42E, 0x44E, 0x42F, 0x44F, 0x460, 0x461, 0x462, 0x463, 0x464, 0x465, 0x466, 0x467, 0x468, 0x469, 0x46A, 0x46B, + 0x46C, 0x46D, 0x46E, 0x46F, 0x470, 0x471, 0x472, 0x473, 0x474, 0x475, 0x476, 0x477, 0x478, 0x479, 0x47A, 0x47B, + 0x47C, 0x47D, 0x47E, 0x47F, 0x480, 0x481, 0x48A, 0x48B, 0x48C, 0x48D, 0x48E, 0x48F, 0x490, 0x491, 0x492, 0x493, + 0x494, 0x495, 0x496, 0x497, 0x498, 0x499, 0x49A, 0x49B, 0x49C, 0x49D, 0x49E, 0x49F, 0x4A0, 0x4A1, 0x4A2, 0x4A3, + 0x4A4, 0x4A5, 0x4A6, 0x4A7, 0x4A8, 0x4A9, 0x4AA, 0x4AB, 0x4AC, 0x4AD, 0x4AE, 0x4AF, 0x4B0, 0x4B1, 0x4B2, 0x4B3, + 0x4B4, 0x4B5, 0x4B6, 0x4B7, 0x4B8, 0x4B9, 0x4BA, 0x4BB, 0x4BC, 0x4BD, 0x4BE, 0x4BF, 0x4C0, 0x4CF, 0x4C1, 0x4C2, + 0x4C3, 0x4C4, 0x4C5, 0x4C6, 0x4C7, 0x4C8, 0x4C9, 0x4CA, 0x4CB, 0x4CC, 0x4CD, 0x4CE, 0x4D0, 0x4D1, 0x4D2, 0x4D3, + 0x4D4, 0x4D5, 0x4D6, 0x4D7, 0x4D8, 0x4D9, 0x4DA, 0x4DB, 0x4DC, 0x4DD, 0x4DE, 0x4DF, 0x4E0, 0x4E1, 0x4E2, 0x4E3, + 0x4E4, 0x4E5, 0x4E6, 0x4E7, 0x4E8, 0x4E9, 0x4EA, 0x4EB, 0x4EC, 0x4ED, 0x4EE, 0x4EF, 0x4F0, 0x4F1, 0x4F2, 0x4F3, + 0x4F4, 0x4F5, 0x4F6, 0x4F7, 0x4F8, 0x4F9, 0x4FA, 0x4FB, 0x4FC, 0x4FD, 0x4FE, 0x4FF, 0x500, 0x501, 0x502, 0x503, + 0x504, 0x505, 0x506, 0x507, 0x508, 0x509, 0x50A, 0x50B, 0x50C, 0x50D, 0x50E, 0x50F, 0x510, 0x511, 0x512, 0x513, + 0x514, 0x515, 0x516, 0x517, 0x518, 0x519, 0x51A, 0x51B, 0x51C, 0x51D, 0x51E, 0x51F, 0x520, 0x521, 0x522, 0x523, + 0x524, 0x525, 0x526, 0x527, 0x528, 0x529, 0x52A, 0x52B, 0x52C, 0x52D, 0x52E, 0x52F, 0x531, 0x561, 0x532, 0x562, + 0x533, 0x563, 0x534, 0x564, 0x535, 0x565, 0x536, 0x566, 0x537, 0x567, 0x538, 0x568, 0x539, 0x569, 0x53A, 0x56A, + 0x53B, 0x56B, 0x53C, 0x56C, 0x53D, 0x56D, 0x53E, 0x56E, 0x53F, 0x56F, 0x540, 0x570, 0x541, 0x571, 0x542, 0x572, + 0x543, 0x573, 0x544, 0x574, 0x545, 0x575, 0x546, 0x576, 0x547, 0x577, 0x548, 0x578, 0x549, 0x579, 0x54A, 0x57A, + 0x54B, 0x57B, 0x54C, 0x57C, 0x54D, 0x57D, 0x54E, 0x57E, 0x54F, 0x57F, 0x550, 0x580, 0x551, 0x581, 0x552, 0x582, + 0x553, 0x583, 0x554, 0x584, 0x555, 0x585, 0x556, 0x586, 0x10A0, 0x2D00, 0x10A1, 0x2D01, 0x10A2, 0x2D02, 0x10A3, 0x2D03, + 0x10A4, 0x2D04, 0x10A5, 0x2D05, 0x10A6, 0x2D06, 0x10A7, 0x2D07, 0x10A8, 0x2D08, 0x10A9, 0x2D09, 0x10AA, 0x2D0A, 0x10AB, 0x2D0B, + 0x10AC, 0x2D0C, 0x10AD, 0x2D0D, 0x10AE, 0x2D0E, 0x10AF, 0x2D0F, 0x10B0, 0x2D10, 0x10B1, 0x2D11, 0x10B2, 0x2D12, 0x10B3, 0x2D13, + 0x10B4, 0x2D14, 0x10B5, 0x2D15, 0x10B6, 0x2D16, 0x10B7, 0x2D17, 0x10B8, 0x2D18, 0x10B9, 0x2D19, 0x10BA, 0x2D1A, 0x10BB, 0x2D1B, + 0x10BC, 0x2D1C, 0x10BD, 0x2D1D, 0x10BE, 0x2D1E, 0x10BF, 0x2D1F, 0x10C0, 0x2D20, 0x10C1, 0x2D21, 0x10C2, 0x2D22, 0x10C3, 0x2D23, + 0x10C4, 0x2D24, 0x10C5, 0x2D25, 0x10C7, 0x2D27, 0x10CD, 0x2D2D, 0x13A0, 0xAB70, 0x13A1, 0xAB71, 0x13A2, 0xAB72, 0x13A3, 0xAB73, + 0x13A4, 0xAB74, 0x13A5, 0xAB75, 0x13A6, 0xAB76, 0x13A7, 0xAB77, 0x13A8, 0xAB78, 0x13A9, 0xAB79, 0x13AA, 0xAB7A, 0x13AB, 0xAB7B, + 0x13AC, 0xAB7C, 0x13AD, 0xAB7D, 0x13AE, 0xAB7E, 0x13AF, 0xAB7F, 0x13B0, 0xAB80, 0x13B1, 0xAB81, 0x13B2, 0xAB82, 0x13B3, 0xAB83, + 0x13B4, 0xAB84, 0x13B5, 0xAB85, 0x13B6, 0xAB86, 0x13B7, 0xAB87, 0x13B8, 0xAB88, 0x13B9, 0xAB89, 0x13BA, 0xAB8A, 0x13BB, 0xAB8B, + 0x13BC, 0xAB8C, 0x13BD, 0xAB8D, 0x13BE, 0xAB8E, 0x13BF, 0xAB8F, 0x13C0, 0xAB90, 0x13C1, 0xAB91, 0x13C2, 0xAB92, 0x13C3, 0xAB93, + 0x13C4, 0xAB94, 0x13C5, 0xAB95, 0x13C6, 0xAB96, 0x13C7, 0xAB97, 0x13C8, 0xAB98, 0x13C9, 0xAB99, 0x13CA, 0xAB9A, 0x13CB, 0xAB9B, + 0x13CC, 0xAB9C, 0x13CD, 0xAB9D, 0x13CE, 0xAB9E, 0x13CF, 0xAB9F, 0x13D0, 0xABA0, 0x13D1, 0xABA1, 0x13D2, 0xABA2, 0x13D3, 0xABA3, + 0x13D4, 0xABA4, 0x13D5, 0xABA5, 0x13D6, 0xABA6, 0x13D7, 0xABA7, 0x13D8, 0xABA8, 0x13D9, 0xABA9, 0x13DA, 0xABAA, 0x13DB, 0xABAB, + 0x13DC, 0xABAC, 0x13DD, 0xABAD, 0x13DE, 0xABAE, 0x13DF, 0xABAF, 0x13E0, 0xABB0, 0x13E1, 0xABB1, 0x13E2, 0xABB2, 0x13E3, 0xABB3, + 0x13E4, 0xABB4, 0x13E5, 0xABB5, 0x13E6, 0xABB6, 0x13E7, 0xABB7, 0x13E8, 0xABB8, 0x13E9, 0xABB9, 0x13EA, 0xABBA, 0x13EB, 0xABBB, + 0x13EC, 0xABBC, 0x13ED, 0xABBD, 0x13EE, 0xABBE, 0x13EF, 0xABBF, 0x13F0, 0x13F8, 0x13F1, 0x13F9, 0x13F2, 0x13FA, 0x13F3, 0x13FB, + 0x13F4, 0x13FC, 0x13F5, 0x13FD, 0x1C90, 0x10D0, 0x1C91, 0x10D1, 0x1C92, 0x10D2, 0x1C93, 0x10D3, 0x1C94, 0x10D4, 0x1C95, 0x10D5, + 0x1C96, 0x10D6, 0x1C97, 0x10D7, 0x1C98, 0x10D8, 0x1C99, 0x10D9, 0x1C9A, 0x10DA, 0x1C9B, 0x10DB, 0x1C9C, 0x10DC, 0x1C9D, 0x10DD, + 0x1C9E, 0x10DE, 0x1C9F, 0x10DF, 0x1CA0, 0x10E0, 0x1CA1, 0x10E1, 0x1CA2, 0x10E2, 0x1CA3, 0x10E3, 0x1CA4, 0x10E4, 0x1CA5, 0x10E5, + 0x1CA6, 0x10E6, 0x1CA7, 0x10E7, 0x1CA8, 0x10E8, 0x1CA9, 0x10E9, 0x1CAA, 0x10EA, 0x1CAB, 0x10EB, 0x1CAC, 0x10EC, 0x1CAD, 0x10ED, + 0x1CAE, 0x10EE, 0x1CAF, 0x10EF, 0x1CB0, 0x10F0, 0x1CB1, 0x10F1, 0x1CB2, 0x10F2, 0x1CB3, 0x10F3, 0x1CB4, 0x10F4, 0x1CB5, 0x10F5, + 0x1CB6, 0x10F6, 0x1CB7, 0x10F7, 0x1CB8, 0x10F8, 0x1CB9, 0x10F9, 0x1CBA, 0x10FA, 0x1CBD, 0x10FD, 0x1CBE, 0x10FE, 0x1CBF, 0x10FF, + 0x1E00, 0x1E01, 0x1E02, 0x1E03, 0x1E04, 0x1E05, 0x1E06, 0x1E07, 0x1E08, 0x1E09, 0x1E0A, 0x1E0B, 0x1E0C, 0x1E0D, 0x1E0E, 0x1E0F, + 0x1E10, 0x1E11, 0x1E12, 0x1E13, 0x1E14, 0x1E15, 0x1E16, 0x1E17, 0x1E18, 0x1E19, 0x1E1A, 0x1E1B, 0x1E1C, 0x1E1D, 0x1E1E, 0x1E1F, + 0x1E20, 0x1E21, 0x1E22, 0x1E23, 0x1E24, 0x1E25, 0x1E26, 0x1E27, 0x1E28, 0x1E29, 0x1E2A, 0x1E2B, 0x1E2C, 0x1E2D, 0x1E2E, 0x1E2F, + 0x1E30, 0x1E31, 0x1E32, 0x1E33, 0x1E34, 0x1E35, 0x1E36, 0x1E37, 0x1E38, 0x1E39, 0x1E3A, 0x1E3B, 0x1E3C, 0x1E3D, 0x1E3E, 0x1E3F, + 0x1E40, 0x1E41, 0x1E42, 0x1E43, 0x1E44, 0x1E45, 0x1E46, 0x1E47, 0x1E48, 0x1E49, 0x1E4A, 0x1E4B, 0x1E4C, 0x1E4D, 0x1E4E, 0x1E4F, + 0x1E50, 0x1E51, 0x1E52, 0x1E53, 0x1E54, 0x1E55, 0x1E56, 0x1E57, 0x1E58, 0x1E59, 0x1E5A, 0x1E5B, 0x1E5C, 0x1E5D, 0x1E5E, 0x1E5F, + 0x1E60, 0x1E61, 0x1E62, 0x1E63, 0x1E64, 0x1E65, 0x1E66, 0x1E67, 0x1E68, 0x1E69, 0x1E6A, 0x1E6B, 0x1E6C, 0x1E6D, 0x1E6E, 0x1E6F, + 0x1E70, 0x1E71, 0x1E72, 0x1E73, 0x1E74, 0x1E75, 0x1E76, 0x1E77, 0x1E78, 0x1E79, 0x1E7A, 0x1E7B, 0x1E7C, 0x1E7D, 0x1E7E, 0x1E7F, + 0x1E80, 0x1E81, 0x1E82, 0x1E83, 0x1E84, 0x1E85, 0x1E86, 0x1E87, 0x1E88, 0x1E89, 0x1E8A, 0x1E8B, 0x1E8C, 0x1E8D, 0x1E8E, 0x1E8F, + 0x1E90, 0x1E91, 0x1E92, 0x1E93, 0x1E94, 0x1E95, 0x1E9E, 0xDF, 0x1EA0, 0x1EA1, 0x1EA2, 0x1EA3, 0x1EA4, 0x1EA5, 0x1EA6, 0x1EA7, + 0x1EA8, 0x1EA9, 0x1EAA, 0x1EAB, 0x1EAC, 0x1EAD, 0x1EAE, 0x1EAF, 0x1EB0, 0x1EB1, 0x1EB2, 0x1EB3, 0x1EB4, 0x1EB5, 0x1EB6, 0x1EB7, + 0x1EB8, 0x1EB9, 0x1EBA, 0x1EBB, 0x1EBC, 0x1EBD, 0x1EBE, 0x1EBF, 0x1EC0, 0x1EC1, 0x1EC2, 0x1EC3, 0x1EC4, 0x1EC5, 0x1EC6, 0x1EC7, + 0x1EC8, 0x1EC9, 0x1ECA, 0x1ECB, 0x1ECC, 0x1ECD, 0x1ECE, 0x1ECF, 0x1ED0, 0x1ED1, 0x1ED2, 0x1ED3, 0x1ED4, 0x1ED5, 0x1ED6, 0x1ED7, + 0x1ED8, 0x1ED9, 0x1EDA, 0x1EDB, 0x1EDC, 0x1EDD, 0x1EDE, 0x1EDF, 0x1EE0, 0x1EE1, 0x1EE2, 0x1EE3, 0x1EE4, 0x1EE5, 0x1EE6, 0x1EE7, + 0x1EE8, 0x1EE9, 0x1EEA, 0x1EEB, 0x1EEC, 0x1EED, 0x1EEE, 0x1EEF, 0x1EF0, 0x1EF1, 0x1EF2, 0x1EF3, 0x1EF4, 0x1EF5, 0x1EF6, 0x1EF7, + 0x1EF8, 0x1EF9, 0x1EFA, 0x1EFB, 0x1EFC, 0x1EFD, 0x1EFE, 0x1EFF, 0x1F08, 0x1F00, 0x1F09, 0x1F01, 0x1F0A, 0x1F02, 0x1F0B, 0x1F03, + 0x1F0C, 0x1F04, 0x1F0D, 0x1F05, 0x1F0E, 0x1F06, 0x1F0F, 0x1F07, 0x1F18, 0x1F10, 0x1F19, 0x1F11, 0x1F1A, 0x1F12, 0x1F1B, 0x1F13, + 0x1F1C, 0x1F14, 0x1F1D, 0x1F15, 0x1F28, 0x1F20, 0x1F29, 0x1F21, 0x1F2A, 0x1F22, 0x1F2B, 0x1F23, 0x1F2C, 0x1F24, 0x1F2D, 0x1F25, + 0x1F2E, 0x1F26, 0x1F2F, 0x1F27, 0x1F38, 0x1F30, 0x1F39, 0x1F31, 0x1F3A, 0x1F32, 0x1F3B, 0x1F33, 0x1F3C, 0x1F34, 0x1F3D, 0x1F35, + 0x1F3E, 0x1F36, 0x1F3F, 0x1F37, 0x1F48, 0x1F40, 0x1F49, 0x1F41, 0x1F4A, 0x1F42, 0x1F4B, 0x1F43, 0x1F4C, 0x1F44, 0x1F4D, 0x1F45, + 0x1F59, 0x1F51, 0x1F5B, 0x1F53, 0x1F5D, 0x1F55, 0x1F5F, 0x1F57, 0x1F68, 0x1F60, 0x1F69, 0x1F61, 0x1F6A, 0x1F62, 0x1F6B, 0x1F63, + 0x1F6C, 0x1F64, 0x1F6D, 0x1F65, 0x1F6E, 0x1F66, 0x1F6F, 0x1F67, 0x1F88, 0x1F80, 0x1F89, 0x1F81, 0x1F8A, 0x1F82, 0x1F8B, 0x1F83, + 0x1F8C, 0x1F84, 0x1F8D, 0x1F85, 0x1F8E, 0x1F86, 0x1F8F, 0x1F87, 0x1F98, 0x1F90, 0x1F99, 0x1F91, 0x1F9A, 0x1F92, 0x1F9B, 0x1F93, + 0x1F9C, 0x1F94, 0x1F9D, 0x1F95, 0x1F9E, 0x1F96, 0x1F9F, 0x1F97, 0x1FA8, 0x1FA0, 0x1FA9, 0x1FA1, 0x1FAA, 0x1FA2, 0x1FAB, 0x1FA3, + 0x1FAC, 0x1FA4, 0x1FAD, 0x1FA5, 0x1FAE, 0x1FA6, 0x1FAF, 0x1FA7, 0x1FB8, 0x1FB0, 0x1FB9, 0x1FB1, 0x1FBA, 0x1F70, 0x1FBB, 0x1F71, + 0x1FBC, 0x1FB3, 0x1FC8, 0x1F72, 0x1FC9, 0x1F73, 0x1FCA, 0x1F74, 0x1FCB, 0x1F75, 0x1FCC, 0x1FC3, 0x1FD8, 0x1FD0, 0x1FD9, 0x1FD1, + 0x1FDA, 0x1F76, 0x1FDB, 0x1F77, 0x1FE8, 0x1FE0, 0x1FE9, 0x1FE1, 0x1FEA, 0x1F7A, 0x1FEB, 0x1F7B, 0x1FEC, 0x1FE5, 0x1FF8, 0x1F78, + 0x1FF9, 0x1F79, 0x1FFA, 0x1F7C, 0x1FFB, 0x1F7D, 0x1FFC, 0x1FF3, 0x2126, 0x3C9, 0x212A, 0x6B, 0x212B, 0xE5, 0x2132, 0x214E, + 0x2160, 0x2170, 0x2161, 0x2171, 0x2162, 0x2172, 0x2163, 0x2173, 0x2164, 0x2174, 0x2165, 0x2175, 0x2166, 0x2176, 0x2167, 0x2177, + 0x2168, 0x2178, 0x2169, 0x2179, 0x216A, 0x217A, 0x216B, 0x217B, 0x216C, 0x217C, 0x216D, 0x217D, 0x216E, 0x217E, 0x216F, 0x217F, + 0x2183, 0x2184, 0x24B6, 0x24D0, 0x24B7, 0x24D1, 0x24B8, 0x24D2, 0x24B9, 0x24D3, 0x24BA, 0x24D4, 0x24BB, 0x24D5, 0x24BC, 0x24D6, + 0x24BD, 0x24D7, 0x24BE, 0x24D8, 0x24BF, 0x24D9, 0x24C0, 0x24DA, 0x24C1, 0x24DB, 0x24C2, 0x24DC, 0x24C3, 0x24DD, 0x24C4, 0x24DE, + 0x24C5, 0x24DF, 0x24C6, 0x24E0, 0x24C7, 0x24E1, 0x24C8, 0x24E2, 0x24C9, 0x24E3, 0x24CA, 0x24E4, 0x24CB, 0x24E5, 0x24CC, 0x24E6, + 0x24CD, 0x24E7, 0x24CE, 0x24E8, 0x24CF, 0x24E9, 0x2C00, 0x2C30, 0x2C01, 0x2C31, 0x2C02, 0x2C32, 0x2C03, 0x2C33, 0x2C04, 0x2C34, + 0x2C05, 0x2C35, 0x2C06, 0x2C36, 0x2C07, 0x2C37, 0x2C08, 0x2C38, 0x2C09, 0x2C39, 0x2C0A, 0x2C3A, 0x2C0B, 0x2C3B, 0x2C0C, 0x2C3C, + 0x2C0D, 0x2C3D, 0x2C0E, 0x2C3E, 0x2C0F, 0x2C3F, 0x2C10, 0x2C40, 0x2C11, 0x2C41, 0x2C12, 0x2C42, 0x2C13, 0x2C43, 0x2C14, 0x2C44, + 0x2C15, 0x2C45, 0x2C16, 0x2C46, 0x2C17, 0x2C47, 0x2C18, 0x2C48, 0x2C19, 0x2C49, 0x2C1A, 0x2C4A, 0x2C1B, 0x2C4B, 0x2C1C, 0x2C4C, + 0x2C1D, 0x2C4D, 0x2C1E, 0x2C4E, 0x2C1F, 0x2C4F, 0x2C20, 0x2C50, 0x2C21, 0x2C51, 0x2C22, 0x2C52, 0x2C23, 0x2C53, 0x2C24, 0x2C54, + 0x2C25, 0x2C55, 0x2C26, 0x2C56, 0x2C27, 0x2C57, 0x2C28, 0x2C58, 0x2C29, 0x2C59, 0x2C2A, 0x2C5A, 0x2C2B, 0x2C5B, 0x2C2C, 0x2C5C, + 0x2C2D, 0x2C5D, 0x2C2E, 0x2C5E, 0x2C2F, 0x2C5F, 0x2C60, 0x2C61, 0x2C62, 0x26B, 0x2C63, 0x1D7D, 0x2C64, 0x27D, 0x2C67, 0x2C68, + 0x2C69, 0x2C6A, 0x2C6B, 0x2C6C, 0x2C6D, 0x251, 0x2C6E, 0x271, 0x2C6F, 0x250, 0x2C70, 0x252, 0x2C72, 0x2C73, 0x2C75, 0x2C76, + 0x2C7E, 0x23F, 0x2C7F, 0x240, 0x2C80, 0x2C81, 0x2C82, 0x2C83, 0x2C84, 0x2C85, 0x2C86, 0x2C87, 0x2C88, 0x2C89, 0x2C8A, 0x2C8B, + 0x2C8C, 0x2C8D, 0x2C8E, 0x2C8F, 0x2C90, 0x2C91, 0x2C92, 0x2C93, 0x2C94, 0x2C95, 0x2C96, 0x2C97, 0x2C98, 0x2C99, 0x2C9A, 0x2C9B, + 0x2C9C, 0x2C9D, 0x2C9E, 0x2C9F, 0x2CA0, 0x2CA1, 0x2CA2, 0x2CA3, 0x2CA4, 0x2CA5, 0x2CA6, 0x2CA7, 0x2CA8, 0x2CA9, 0x2CAA, 0x2CAB, + 0x2CAC, 0x2CAD, 0x2CAE, 0x2CAF, 0x2CB0, 0x2CB1, 0x2CB2, 0x2CB3, 0x2CB4, 0x2CB5, 0x2CB6, 0x2CB7, 0x2CB8, 0x2CB9, 0x2CBA, 0x2CBB, + 0x2CBC, 0x2CBD, 0x2CBE, 0x2CBF, 0x2CC0, 0x2CC1, 0x2CC2, 0x2CC3, 0x2CC4, 0x2CC5, 0x2CC6, 0x2CC7, 0x2CC8, 0x2CC9, 0x2CCA, 0x2CCB, + 0x2CCC, 0x2CCD, 0x2CCE, 0x2CCF, 0x2CD0, 0x2CD1, 0x2CD2, 0x2CD3, 0x2CD4, 0x2CD5, 0x2CD6, 0x2CD7, 0x2CD8, 0x2CD9, 0x2CDA, 0x2CDB, + 0x2CDC, 0x2CDD, 0x2CDE, 0x2CDF, 0x2CE0, 0x2CE1, 0x2CE2, 0x2CE3, 0x2CEB, 0x2CEC, 0x2CED, 0x2CEE, 0x2CF2, 0x2CF3, 0xA640, 0xA641, + 0xA642, 0xA643, 0xA644, 0xA645, 0xA646, 0xA647, 0xA648, 0xA649, 0xA64A, 0xA64B, 0xA64C, 0xA64D, 0xA64E, 0xA64F, 0xA650, 0xA651, + 0xA652, 0xA653, 0xA654, 0xA655, 0xA656, 0xA657, 0xA658, 0xA659, 0xA65A, 0xA65B, 0xA65C, 0xA65D, 0xA65E, 0xA65F, 0xA660, 0xA661, + 0xA662, 0xA663, 0xA664, 0xA665, 0xA666, 0xA667, 0xA668, 0xA669, 0xA66A, 0xA66B, 0xA66C, 0xA66D, 0xA680, 0xA681, 0xA682, 0xA683, + 0xA684, 0xA685, 0xA686, 0xA687, 0xA688, 0xA689, 0xA68A, 0xA68B, 0xA68C, 0xA68D, 0xA68E, 0xA68F, 0xA690, 0xA691, 0xA692, 0xA693, + 0xA694, 0xA695, 0xA696, 0xA697, 0xA698, 0xA699, 0xA69A, 0xA69B, 0xA722, 0xA723, 0xA724, 0xA725, 0xA726, 0xA727, 0xA728, 0xA729, + 0xA72A, 0xA72B, 0xA72C, 0xA72D, 0xA72E, 0xA72F, 0xA732, 0xA733, 0xA734, 0xA735, 0xA736, 0xA737, 0xA738, 0xA739, 0xA73A, 0xA73B, + 0xA73C, 0xA73D, 0xA73E, 0xA73F, 0xA740, 0xA741, 0xA742, 0xA743, 0xA744, 0xA745, 0xA746, 0xA747, 0xA748, 0xA749, 0xA74A, 0xA74B, + 0xA74C, 0xA74D, 0xA74E, 0xA74F, 0xA750, 0xA751, 0xA752, 0xA753, 0xA754, 0xA755, 0xA756, 0xA757, 0xA758, 0xA759, 0xA75A, 0xA75B, + 0xA75C, 0xA75D, 0xA75E, 0xA75F, 0xA760, 0xA761, 0xA762, 0xA763, 0xA764, 0xA765, 0xA766, 0xA767, 0xA768, 0xA769, 0xA76A, 0xA76B, + 0xA76C, 0xA76D, 0xA76E, 0xA76F, 0xA779, 0xA77A, 0xA77B, 0xA77C, 0xA77D, 0x1D79, 0xA77E, 0xA77F, 0xA780, 0xA781, 0xA782, 0xA783, + 0xA784, 0xA785, 0xA786, 0xA787, 0xA78B, 0xA78C, 0xA78D, 0x265, 0xA790, 0xA791, 0xA792, 0xA793, 0xA796, 0xA797, 0xA798, 0xA799, + 0xA79A, 0xA79B, 0xA79C, 0xA79D, 0xA79E, 0xA79F, 0xA7A0, 0xA7A1, 0xA7A2, 0xA7A3, 0xA7A4, 0xA7A5, 0xA7A6, 0xA7A7, 0xA7A8, 0xA7A9, + 0xA7AA, 0x266, 0xA7AB, 0x25C, 0xA7AC, 0x261, 0xA7AD, 0x26C, 0xA7AE, 0x26A, 0xA7B0, 0x29E, 0xA7B1, 0x287, 0xA7B2, 0x29D, + 0xA7B3, 0xAB53, 0xA7B4, 0xA7B5, 0xA7B6, 0xA7B7, 0xA7B8, 0xA7B9, 0xA7BA, 0xA7BB, 0xA7BC, 0xA7BD, 0xA7BE, 0xA7BF, 0xA7C0, 0xA7C1, + 0xA7C2, 0xA7C3, 0xA7C4, 0xA794, 0xA7C5, 0x282, 0xA7C6, 0x1D8E, 0xA7C7, 0xA7C8, 0xA7C9, 0xA7CA, 0xA7D0, 0xA7D1, 0xA7D6, 0xA7D7, + 0xA7D8, 0xA7D9, 0xA7F5, 0xA7F6, 0xFF21, 0xFF41, 0xFF22, 0xFF42, 0xFF23, 0xFF43, 0xFF24, 0xFF44, 0xFF25, 0xFF45, 0xFF26, 0xFF46, + 0xFF27, 0xFF47, 0xFF28, 0xFF48, 0xFF29, 0xFF49, 0xFF2A, 0xFF4A, 0xFF2B, 0xFF4B, 0xFF2C, 0xFF4C, 0xFF2D, 0xFF4D, 0xFF2E, 0xFF4E, + 0xFF2F, 0xFF4F, 0xFF30, 0xFF50, 0xFF31, 0xFF51, 0xFF32, 0xFF52, 0xFF33, 0xFF53, 0xFF34, 0xFF54, 0xFF35, 0xFF55, 0xFF36, 0xFF56, + 0xFF37, 0xFF57, 0xFF38, 0xFF58, 0xFF39, 0xFF59, 0xFF3A, 0xFF5A, 0x10400, 0x10428, 0x10401, 0x10429, 0x10402, 0x1042A, 0x10403, 0x1042B, + 0x10404, 0x1042C, 0x10405, 0x1042D, 0x10406, 0x1042E, 0x10407, 0x1042F, 0x10408, 0x10430, 0x10409, 0x10431, 0x1040A, 0x10432, 0x1040B, 0x10433, + 0x1040C, 0x10434, 0x1040D, 0x10435, 0x1040E, 0x10436, 0x1040F, 0x10437, 0x10410, 0x10438, 0x10411, 0x10439, 0x10412, 0x1043A, 0x10413, 0x1043B, + 0x10414, 0x1043C, 0x10415, 0x1043D, 0x10416, 0x1043E, 0x10417, 0x1043F, 0x10418, 0x10440, 0x10419, 0x10441, 0x1041A, 0x10442, 0x1041B, 0x10443, + 0x1041C, 0x10444, 0x1041D, 0x10445, 0x1041E, 0x10446, 0x1041F, 0x10447, 0x10420, 0x10448, 0x10421, 0x10449, 0x10422, 0x1044A, 0x10423, 0x1044B, + 0x10424, 0x1044C, 0x10425, 0x1044D, 0x10426, 0x1044E, 0x10427, 0x1044F, 0x104B0, 0x104D8, 0x104B1, 0x104D9, 0x104B2, 0x104DA, 0x104B3, 0x104DB, + 0x104B4, 0x104DC, 0x104B5, 0x104DD, 0x104B6, 0x104DE, 0x104B7, 0x104DF, 0x104B8, 0x104E0, 0x104B9, 0x104E1, 0x104BA, 0x104E2, 0x104BB, 0x104E3, + 0x104BC, 0x104E4, 0x104BD, 0x104E5, 0x104BE, 0x104E6, 0x104BF, 0x104E7, 0x104C0, 0x104E8, 0x104C1, 0x104E9, 0x104C2, 0x104EA, 0x104C3, 0x104EB, + 0x104C4, 0x104EC, 0x104C5, 0x104ED, 0x104C6, 0x104EE, 0x104C7, 0x104EF, 0x104C8, 0x104F0, 0x104C9, 0x104F1, 0x104CA, 0x104F2, 0x104CB, 0x104F3, + 0x104CC, 0x104F4, 0x104CD, 0x104F5, 0x104CE, 0x104F6, 0x104CF, 0x104F7, 0x104D0, 0x104F8, 0x104D1, 0x104F9, 0x104D2, 0x104FA, 0x104D3, 0x104FB, + 0x10570, 0x10597, 0x10571, 0x10598, 0x10572, 0x10599, 0x10573, 0x1059A, 0x10574, 0x1059B, 0x10575, 0x1059C, 0x10576, 0x1059D, 0x10577, 0x1059E, + 0x10578, 0x1059F, 0x10579, 0x105A0, 0x1057A, 0x105A1, 0x1057C, 0x105A3, 0x1057D, 0x105A4, 0x1057E, 0x105A5, 0x1057F, 0x105A6, 0x10580, 0x105A7, + 0x10581, 0x105A8, 0x10582, 0x105A9, 0x10583, 0x105AA, 0x10584, 0x105AB, 0x10585, 0x105AC, 0x10586, 0x105AD, 0x10587, 0x105AE, 0x10588, 0x105AF, + 0x10589, 0x105B0, 0x1058A, 0x105B1, 0x1058C, 0x105B3, 0x1058D, 0x105B4, 0x1058E, 0x105B5, 0x1058F, 0x105B6, 0x10590, 0x105B7, 0x10591, 0x105B8, + 0x10592, 0x105B9, 0x10594, 0x105BB, 0x10595, 0x105BC, 0x10C80, 0x10CC0, 0x10C81, 0x10CC1, 0x10C82, 0x10CC2, 0x10C83, 0x10CC3, 0x10C84, 0x10CC4, + 0x10C85, 0x10CC5, 0x10C86, 0x10CC6, 0x10C87, 0x10CC7, 0x10C88, 0x10CC8, 0x10C89, 0x10CC9, 0x10C8A, 0x10CCA, 0x10C8B, 0x10CCB, 0x10C8C, 0x10CCC, + 0x10C8D, 0x10CCD, 0x10C8E, 0x10CCE, 0x10C8F, 0x10CCF, 0x10C90, 0x10CD0, 0x10C91, 0x10CD1, 0x10C92, 0x10CD2, 0x10C93, 0x10CD3, 0x10C94, 0x10CD4, + 0x10C95, 0x10CD5, 0x10C96, 0x10CD6, 0x10C97, 0x10CD7, 0x10C98, 0x10CD8, 0x10C99, 0x10CD9, 0x10C9A, 0x10CDA, 0x10C9B, 0x10CDB, 0x10C9C, 0x10CDC, + 0x10C9D, 0x10CDD, 0x10C9E, 0x10CDE, 0x10C9F, 0x10CDF, 0x10CA0, 0x10CE0, 0x10CA1, 0x10CE1, 0x10CA2, 0x10CE2, 0x10CA3, 0x10CE3, 0x10CA4, 0x10CE4, + 0x10CA5, 0x10CE5, 0x10CA6, 0x10CE6, 0x10CA7, 0x10CE7, 0x10CA8, 0x10CE8, 0x10CA9, 0x10CE9, 0x10CAA, 0x10CEA, 0x10CAB, 0x10CEB, 0x10CAC, 0x10CEC, + 0x10CAD, 0x10CED, 0x10CAE, 0x10CEE, 0x10CAF, 0x10CEF, 0x10CB0, 0x10CF0, 0x10CB1, 0x10CF1, 0x10CB2, 0x10CF2, 0x118A0, 0x118C0, 0x118A1, 0x118C1, + 0x118A2, 0x118C2, 0x118A3, 0x118C3, 0x118A4, 0x118C4, 0x118A5, 0x118C5, 0x118A6, 0x118C6, 0x118A7, 0x118C7, 0x118A8, 0x118C8, 0x118A9, 0x118C9, + 0x118AA, 0x118CA, 0x118AB, 0x118CB, 0x118AC, 0x118CC, 0x118AD, 0x118CD, 0x118AE, 0x118CE, 0x118AF, 0x118CF, 0x118B0, 0x118D0, 0x118B1, 0x118D1, + 0x118B2, 0x118D2, 0x118B3, 0x118D3, 0x118B4, 0x118D4, 0x118B5, 0x118D5, 0x118B6, 0x118D6, 0x118B7, 0x118D7, 0x118B8, 0x118D8, 0x118B9, 0x118D9, + 0x118BA, 0x118DA, 0x118BB, 0x118DB, 0x118BC, 0x118DC, 0x118BD, 0x118DD, 0x118BE, 0x118DE, 0x118BF, 0x118DF, 0x16E40, 0x16E60, 0x16E41, 0x16E61, + 0x16E42, 0x16E62, 0x16E43, 0x16E63, 0x16E44, 0x16E64, 0x16E45, 0x16E65, 0x16E46, 0x16E66, 0x16E47, 0x16E67, 0x16E48, 0x16E68, 0x16E49, 0x16E69, + 0x16E4A, 0x16E6A, 0x16E4B, 0x16E6B, 0x16E4C, 0x16E6C, 0x16E4D, 0x16E6D, 0x16E4E, 0x16E6E, 0x16E4F, 0x16E6F, 0x16E50, 0x16E70, 0x16E51, 0x16E71, + 0x16E52, 0x16E72, 0x16E53, 0x16E73, 0x16E54, 0x16E74, 0x16E55, 0x16E75, 0x16E56, 0x16E76, 0x16E57, 0x16E77, 0x16E58, 0x16E78, 0x16E59, 0x16E79, + 0x16E5A, 0x16E7A, 0x16E5B, 0x16E7B, 0x16E5C, 0x16E7C, 0x16E5D, 0x16E7D, 0x16E5E, 0x16E7E, 0x16E5F, 0x16E7F, 0x1E900, 0x1E922, 0x1E901, 0x1E923, + 0x1E902, 0x1E924, 0x1E903, 0x1E925, 0x1E904, 0x1E926, 0x1E905, 0x1E927, 0x1E906, 0x1E928, 0x1E907, 0x1E929, 0x1E908, 0x1E92A, 0x1E909, 0x1E92B, + 0x1E90A, 0x1E92C, 0x1E90B, 0x1E92D, 0x1E90C, 0x1E92E, 0x1E90D, 0x1E92F, 0x1E90E, 0x1E930, 0x1E90F, 0x1E931, 0x1E910, 0x1E932, 0x1E911, 0x1E933, + 0x1E912, 0x1E934, 0x1E913, 0x1E935, 0x1E914, 0x1E936, 0x1E915, 0x1E937, 0x1E916, 0x1E938, 0x1E917, 0x1E939, 0x1E918, 0x1E93A, 0x1E919, 0x1E93B, + 0x1E91A, 0x1E93C, 0x1E91B, 0x1E93D, 0x1E91C, 0x1E93E, 0x1E91D, 0x1E93F, 0x1E91E, 0x1E940, 0x1E91F, 0x1E941, 0x1E920, 0x1E942, 0x1E921, 0x1E943, + ]; + + // The ones whose lowercase has more than one code point. + internal static readonly Dictionary LowercaseLonger = new() + { + [0x130] = "\u0069\u0307", + }; +} diff --git a/ChromaDB.Client/Common/ChromaEmbeddings.cs b/ChromaDB.Client/Common/ChromaEmbeddings.cs new file mode 100644 index 0000000..c0d5ee0 --- /dev/null +++ b/ChromaDB.Client/Common/ChromaEmbeddings.cs @@ -0,0 +1,57 @@ +using System.Runtime.InteropServices; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace ChromaDB.Client.Common; + +// The embeddings of an add, update or upsert: lists of numbers, or, where the server declares it, base64 strings of +// their float32 values in little-endian order, about half the size. Queries always send numbers. +[JsonConverter(typeof(ChromaEmbeddingsConverter))] +internal sealed class ChromaEmbeddings(IReadOnlyList> items, bool base64) +{ + public IReadOnlyList> Items { get; } = items; + public bool Base64 { get; } = base64; +} + +internal sealed class ChromaEmbeddingsConverter : JsonConverter +{ + public override ChromaEmbeddings Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) => throw new NotSupportedException(); + + public override void Write(Utf8JsonWriter writer, ChromaEmbeddings value, JsonSerializerOptions options) + { + writer.WriteStartArray(); + foreach (var embedding in value.Items) + { + if (value.Base64) + { + writer.WriteBase64StringValue(LittleEndianBytes(embedding.Span)); + } + else + { + writer.WriteStartArray(); + foreach (var number in embedding.Span) + { + writer.WriteRawValue(ChromaNumbers.Format(number), skipInputValidation: true); + } + writer.WriteEndArray(); + } + } + writer.WriteEndArray(); + } + + private static ReadOnlySpan LittleEndianBytes(ReadOnlySpan embedding) + { + if (BitConverter.IsLittleEndian) + { + return MemoryMarshal.AsBytes(embedding); + } + var bytes = new byte[embedding.Length * sizeof(float)]; + for (var i = 0; i < embedding.Length; i++) + { + var number = BitConverter.GetBytes(embedding[i]); + Array.Reverse(number); + number.CopyTo(bytes, i * sizeof(float)); + } + return bytes; + } +} diff --git a/ChromaDB.Client/Common/ChromaFilterLists.cs b/ChromaDB.Client/Common/ChromaFilterLists.cs new file mode 100644 index 0000000..ca2ff81 --- /dev/null +++ b/ChromaDB.Client/Common/ChromaFilterLists.cs @@ -0,0 +1,70 @@ +namespace ChromaDB.Client.Common; + +// The lists of $and and $or as they go to the server. A single Chroma server turns a filter into an SQLite expression where a +// list of n filters is n deep, plus its deepest filter, and SQLite stops at 1000: Chroma 1.5.9 takes 987 to 994 filters in +// one list, whatever the operator, and from about 4,400 it crashes on a stack overflow; where and where_document have a limit each. +// The client counts that depth: up to 900, with room for the rest of the query, a filter goes as it is, one list for a chain, as +// the Python client writes it. Beyond, each list of n filters goes as ⌈√n⌉ lists of about √n, with the same meaning, about 2√n deep. +internal static class ChromaFilterLists +{ + public const int MaxDepth = 900; + + // Works around KD-4 (docs/COMPATIBILITY.md) + public static Dictionary Shape(Dictionary filter) + => Depth(filter) <= MaxDepth ? filter : (Dictionary)Split(filter); + + // At most the depth of the SQLite expression: the deepest filter of a list counts as if it were the first of the chain. + private static int Depth(object filter) + => List(filter) is var (_, filters) ? filters.Length - 1 + filters.Max(Depth) : 1; + + private static object Split(object filter) + { + if (List(filter) is not var (@operator, filters)) + { + return filter; + } + var operands = filters.Select(Split).ToList(); + var count = (int)Math.Ceiling(Math.Sqrt(operands.Count)); + // Fewer than 5 filters are as deep in one list as in groups. + if (operands.Count < 5) + { + return new Dictionary { [@operator] = operands.ToArray() }; + } + var groups = new List(); + for (int i = 0, start = 0; i < count; i++) + { + // Sizes that differ by one at most; Chroma takes $and and $or with two filters or more, so a group of one is the filter. + var size = operands.Count / count + (i < operands.Count % count ? 1 : 0); + groups.Add(size == 1 ? operands[start] : new Dictionary { [@operator] = operands.GetRange(start, size).ToArray() }); + start += size; + } + return new Dictionary { [@operator] = groups.ToArray() }; + } + + // A list of $and or $or: an array in a dictionary, as the operators write it, or a JSON array in a dictionary or a JSON object, as a + // filter given as a dictionary has it. + private static (string Operator, object[] Filters)? List(object filter) + => filter switch + { + IReadOnlyDictionary { Count: 1 } dictionary when dictionary.First() is { Key: "$and" or "$or" } pair && Filters(pair.Value) is { } filters + => (pair.Key, filters), + System.Text.Json.JsonElement { ValueKind: System.Text.Json.JsonValueKind.Object } json + when json.EnumerateObject().Take(2).ToList() is [{ Name: "$and" or "$or" } property] && Filters(property.Value) is { } filters + => (property.Name, filters), + _ => null, + }; + + // A filter given as a dictionary, written to JSON once when the client gets it, with the serializer, the options and the converters + // of the requests, and kept as that JSON: the JSON that goes, which the checks of Chroma Cloud, the split of the long lists and the + // Search API read whatever the types of the values. A value the client cannot write throws here. + public static Dictionary ToJson(IReadOnlyDictionary filter) + => System.Text.Json.JsonSerializer.SerializeToElement(filter.ToDictionary(pair => pair.Key, pair => pair.Value), HttpClientHelpers.PostJsonSerializerOptions.TypeInfo>()) + .EnumerateObject().ToDictionary(property => property.Name, property => (object)property.Value); + + private static object[]? Filters(object value) => value switch + { + object[] filters => filters, + System.Text.Json.JsonElement { ValueKind: System.Text.Json.JsonValueKind.Array } json => json.EnumerateArray().Select(item => (object)item).ToArray(), + _ => null, + }; +} diff --git a/ChromaDB.Client/Common/ChromaHttpClient.cs b/ChromaDB.Client/Common/ChromaHttpClient.cs new file mode 100644 index 0000000..bdd079c --- /dev/null +++ b/ChromaDB.Client/Common/ChromaHttpClient.cs @@ -0,0 +1,290 @@ +using System.Net; +using System.Net.Http.Headers; +using System.Text; +using System.Text.Json; +using ChromaDB.Client.Models; +using ChromaDB.Client.Models.Requests; + +namespace ChromaDB.Client.Common; + +// Sends requests with the URI and token of one client, without changing the HttpClient it was given: +// the same HttpClient can be shared by clients for different servers or tokens. +internal sealed class ChromaHttpClient +{ + private readonly HttpClient _httpClient; + private readonly Uri _baseUri; + private readonly string? _chromaToken; + private readonly AuthenticationHeaderValue? _authorization; + + // What the client learns about the server, shared by the ChromaHttpClient made from this one. + private readonly ServerFacts _server; + + public ChromaRoutes Routes { get; } + public JsonSerializerOptions DeserializerOptions { get; } + public bool BatchSplitting { get; } + public ChromaUpsertStrategy UpsertStrategy { get; } + public bool ReadsBeyondKeyQuota { get; } + public ChromaReadRetries ReadRetries { get; } + public int? MaxBatchSize { get; } + public bool IsChromaCloud { get; } + + public ChromaHttpClient(HttpClient httpClient, ChromaConfigurationOptions options) + { + _httpClient = httpClient; + _server = new ServerFacts(); + _baseUri = CreateBaseUri(options.Uri, options.ApiVersion); + Routes = options.ApiVersion == ChromaApiVersion.V1 ? ChromaRoutes.V1 : ChromaRoutes.V2; + DeserializerOptions = HttpClientHelpers.DeserializerOptions(options.MetadataValues); + BatchSplitting = options.BatchSplitting; + UpsertStrategy = options.UpsertStrategy; + ReadsBeyondKeyQuota = options.ReadsBeyondKeyQuota; + ReadRetries = options.ReadRetries; + IsChromaCloud = options.IsChromaCloud; + // Works around KD-21 (docs/COMPATIBILITY.md) + // Chroma Cloud declares a max_batch_size of 1000, but takes 300 records per write and answers at most 300 per read, without an + // error: its quotas by default. + MaxBatchSize = options.MaxBatchSize ?? (IsChromaCloud ? ChromaCloudQuotas.MaxRecordsPerRequest : null); + if (options.ChromaToken is not null and not []) + { + if (options.ChromaTokenTransportHeader == ChromaTokenTransportHeader.Authorization) + { + _authorization = new AuthenticationHeaderValue("Bearer", options.ChromaToken); + } + else + { + _chromaToken = options.ChromaToken; + } + } + if (options.BasicAuthUsername is not null) + { + if (_authorization is not null) + { + throw new ArgumentException("Basic authentication and a token in the Authorization header cannot be used together.", nameof(options)); + } + _authorization = new AuthenticationHeaderValue("Basic", Convert.ToBase64String(Encoding.UTF8.GetBytes($"{options.BasicAuthUsername}:{options.BasicAuthPassword}"))); + } + } + + // The same server, HttpClient and credentials, reading metadata values another way. + private ChromaHttpClient(ChromaHttpClient other, ChromaMetadataValues metadataValues) + { + _httpClient = other._httpClient; + _baseUri = other._baseUri; + _chromaToken = other._chromaToken; + _authorization = other._authorization; + _server = other._server; + Routes = other.Routes; + BatchSplitting = other.BatchSplitting; + UpsertStrategy = other.UpsertStrategy; + ReadsBeyondKeyQuota = other.ReadsBeyondKeyQuota; + ReadRetries = other.ReadRetries; + MaxBatchSize = other.MaxBatchSize; + IsChromaCloud = other.IsChromaCloud; + DeserializerOptions = HttpClientHelpers.DeserializerOptions(metadataValues); + } + + public ChromaHttpClient WithMetadataValues(ChromaMetadataValues metadataValues) => new(this, metadataValues); + + public Uri CreateUri(string endpoint) => new(_baseUri, endpoint); + + // Chroma 0.x keeps the space of a collection only in its metadata, and a collection without it uses l2: the client asks the + // version only for a collection that reports no space, and not on Chroma Cloud, which always reports it. + public async Task WithSpace(ChromaCollection collection, CancellationToken cancellationToken) + { + if (collection.Space is null && !IsChromaCloud && await IsChroma0(cancellationToken)) + { + collection.DefaultSpace = ChromaSpace.L2; + } + return collection; + } + + public async Task> WithSpaces(List collections, CancellationToken cancellationToken) + { + foreach (var collection in collections) + { + await WithSpace(collection, cancellationToken); + } + return collections; + } + + // Works around KD-14 (docs/COMPATIBILITY.md) + // Asked when a request needs it, once for concurrent calls, and again after ServerFacts.Lifetime, in case the server changed; + // a failed or canceled request is not kept, so the next call asks again. + // The 0.x servers send their own version; every Chroma 1.x answers "1.0.0", so the version tells only 0.x from 1.x apart. + public async Task IsChroma0(CancellationToken cancellationToken) + { + var version = _server.Version; + if (version is not { IsCurrent: true }) + { + await _server.VersionLock.WaitAsync(cancellationToken); + try + { + version = _server.Version; + if (version is not { IsCurrent: true }) + { + version = new Fact(await this.Get(Routes.Version, new RequestQueryParams(), cancellationToken)); + _server.Version = version; + } + } + finally + { + _server.VersionLock.Release(); + } + } + return version.Value.StartsWith("0.", StringComparison.Ordinal); + } + + // The endpoints are relative to the base URI, so it needs the trailing slash: without it, new Uri(base, endpoint) + // replaces the last segment ("api/v2" becomes "api/"). A URI with just the server address gets the path of the API version. + private static Uri CreateBaseUri(Uri uri, ChromaApiVersion apiVersion) + { + if (uri.AbsolutePath is "/") + { + return new Uri(uri, apiVersion == ChromaApiVersion.V1 ? "api/v1/" : "api/v2/"); + } + var path = uri.GetLeftPart(UriPartial.Path); + return path.EndsWith("/") ? uri : new Uri(path + "/"); + } + + // The quota of records per request that the server enforced, like the 300 of Chroma Cloud, trusted as long as the other facts. + public int? RecordsLimit => _server.RecordsLimit is { IsCurrent: true } limit ? limit.Value : null; + + public void LearnRecordsLimit(int limit) => _server.RecordsLimit = new Fact(limit); + + // From pre-flight-checks, asked like the version. Null for Chroma 0.4.10, which has no pre-flight-checks, and when the server + // cannot tell: batch splitting is on by default, and the records then go in one request, as without it. + public async Task GetMaxBatchSize(CancellationToken cancellationToken) + { + try + { + return (await GetPreFlightChecks(cancellationToken))?.MaxBatchSize is > 0 and var limit ? limit : null; + } + catch (ChromaException) + { + return null; + } + } + + // Chroma 1.0.13 and later declare that add, update and upsert take embeddings as base64 strings; the earlier ones reject them. + // Base64 only makes requests smaller: when the server cannot tell, the embeddings go as numbers, which every server takes. + public async Task SupportsBase64Embeddings(CancellationToken cancellationToken) + { + try + { + return (await GetPreFlightChecks(cancellationToken))?.SupportsBase64Encoding == true; + } + catch (ChromaException) + { + return false; + } + } + + // Chroma 1.5.3 and later apply the limit of a delete, and from that version the OpenAPI description of the server declares it; + // the earlier versions ignore it and delete every matching record. Asked like the version, only when a delete has a limit; + // a server without the description, or without the limit in it, is taken as one that ignores it. + public async Task SupportsDeleteLimit(CancellationToken cancellationToken) + { + var supported = _server.DeleteLimit; + if (supported is not { IsCurrent: true }) + { + await _server.DeleteLimitLock.WaitAsync(cancellationToken); + try + { + supported = _server.DeleteLimit; + if (supported is not { IsCurrent: true }) + { + bool value; + try + { + // At the root of the server, next to the api/v2/ of the base URI. + var description = await this.Get("../../openapi.json", new RequestQueryParams(), cancellationToken); + value = description.ValueKind == JsonValueKind.Object + && description.TryGetProperty("components", out var components) && components.ValueKind == JsonValueKind.Object + && components.TryGetProperty("schemas", out var schemas) && schemas.ValueKind == JsonValueKind.Object + && schemas.TryGetProperty("DeleteCollectionRecordsPayload", out var payload) + && payload.GetRawText().Contains("\"limit\""); + } + catch (ChromaException) + { + value = false; + } + supported = new Fact(value); + _server.DeleteLimit = supported; + } + } + finally + { + _server.DeleteLimitLock.Release(); + } + } + return supported.Value; + } + + private async Task GetPreFlightChecks(CancellationToken cancellationToken) + { + var checks = _server.PreFlightChecks; + if (checks is not { IsCurrent: true }) + { + await _server.PreFlightChecksLock.WaitAsync(cancellationToken); + try + { + checks = _server.PreFlightChecks; + if (checks is not { IsCurrent: true }) + { + ChromaPreFlightChecks? value; + try + { + value = await this.Get(Routes.PreFlightChecks, new RequestQueryParams(), cancellationToken); + } + catch (ChromaException ex) when (ex.StatusCode == HttpStatusCode.NotFound) + { + value = null; + } + checks = new Fact(value); + _server.PreFlightChecks = checks; + } + } + finally + { + _server.PreFlightChecksLock.Release(); + } + } + return checks.Value; + } + + public Task SendAsync(HttpRequestMessage httpRequestMessage, CancellationToken cancellationToken) + { + if (_chromaToken is not null) + { + httpRequestMessage.Headers.Add(ClientConstants.ChromaTokenHeader, _chromaToken); + } + if (_authorization is not null) + { + httpRequestMessage.Headers.Authorization = _authorization; + } + return _httpClient.SendAsync(httpRequestMessage, cancellationToken); + } + + // Each fact is one object, published with a volatile write, so a reader outside the lock sees it whole. + internal sealed class ServerFacts + { + // How long a fact is trusted before it is asked again: the server can be upgraded or replaced while the client lives. + internal static TimeSpan Lifetime { get; set; } = TimeSpan.FromMinutes(2); + + public volatile Fact? Version; + public readonly SemaphoreSlim VersionLock = new(1, 1); + public volatile Fact? PreFlightChecks; + public readonly SemaphoreSlim PreFlightChecksLock = new(1, 1); + public volatile Fact? DeleteLimit; + public volatile Fact? RecordsLimit; + public readonly SemaphoreSlim DeleteLimitLock = new(1, 1); + } + + internal sealed class Fact(T value) + { + private readonly DateTime _expires = DateTime.UtcNow + ServerFacts.Lifetime; + + public T Value { get; } = value; + public bool IsCurrent => DateTime.UtcNow < _expires; + } +} diff --git a/ChromaDB.Client/Common/ChromaInstrumentation.cs b/ChromaDB.Client/Common/ChromaInstrumentation.cs new file mode 100644 index 0000000..5374982 --- /dev/null +++ b/ChromaDB.Client/Common/ChromaInstrumentation.cs @@ -0,0 +1,78 @@ +using System.Diagnostics; +using System.Diagnostics.Metrics; +using System.Globalization; + +namespace ChromaDB.Client.Common; + +// The spans and the durations of the operations, as the OpenTelemetry semantic conventions for database clients describe them: +// a client span "{operation} {collection}" around all the requests of an operation, and its duration in db.client.operation.duration. +internal static class ChromaInstrumentation +{ + private static readonly string? Version = typeof(ChromaInstrumentation).Assembly.GetName().Version?.ToString(); + private static readonly ActivitySource Source = new(ChromaTelemetry.ActivitySourceName, Version); + private static readonly Meter Meter = new(ChromaTelemetry.MeterName, Version); + // In seconds, with the bucket boundaries that the semantic conventions advise for it: without them OpenTelemetry uses its default + // ones, made for milliseconds, and every operation under 5 s falls in the same bucket. InstrumentAdvice is in + // System.Diagnostics.DiagnosticSource 9 and later, which the netstandard2.0 build does not take. +#if NETSTANDARD2_0 + private static readonly Histogram Duration = Meter.CreateHistogram("db.client.operation.duration", "s", "Duration of database client operations."); +#else + private static readonly Histogram Duration = Meter.CreateHistogram("db.client.operation.duration", "s", "Duration of database client operations.", tags: null, + advice: new InstrumentAdvice { HistogramBucketBoundaries = [0.001, 0.005, 0.01, 0.05, 0.1, 0.5, 1, 5, 10] }); +#endif + + public static async Task Run(string operation, string? collection, string? @namespace, Uri server, Func> body) + { + if (!Source.HasListeners() && !Duration.Enabled) + { + return await body(); + } + var tags = new TagList + { + { "db.system.name", "chroma" }, + { "db.operation.name", operation }, + { "server.address", server.Host }, + { "server.port", server.Port }, + }; + if (collection is not null) + { + tags.Add("db.collection.name", collection); + } + if (@namespace is not null) + { + tags.Add("db.namespace", @namespace); + } + var start = Stopwatch.GetTimestamp(); + using var activity = Source.StartActivity(collection is null ? operation : $"{operation} {collection}", ActivityKind.Client, default(ActivityContext), tags); + try + { + return await body(); + } + catch (Exception ex) + { + // The HTTP status when the server answered, otherwise the exception: the one the client wraps, like HttpRequestException. + var status = (ex as ChromaException)?.StatusCode is { } code ? ((int)code).ToString(CultureInfo.InvariantCulture) : null; + var errorType = status ?? (ex is ChromaException { InnerException: { } inner } ? inner : ex).GetType().FullName!; + tags.Add("error.type", errorType); + activity?.SetTag("error.type", errorType); + if (status is not null) + { + tags.Add("db.response.status_code", status); + activity?.SetTag("db.response.status_code", status); + } + activity?.SetStatus(ActivityStatusCode.Error, ex.Message); + throw; + } + finally + { + Duration.Record((Stopwatch.GetTimestamp() - start) / (double)Stopwatch.Frequency, tags); + } + } + + public static Task Run(string operation, string? collection, string? @namespace, Uri server, Func body) + => Run(operation, collection, @namespace, server, async () => + { + await body(); + return null; + }); +} diff --git a/ChromaDB.Client/Common/ChromaJsonContext.cs b/ChromaDB.Client/Common/ChromaJsonContext.cs new file mode 100644 index 0000000..0d19342 --- /dev/null +++ b/ChromaDB.Client/Common/ChromaJsonContext.cs @@ -0,0 +1,116 @@ +using System.Diagnostics.CodeAnalysis; +using System.Text.Json; +using System.Text.Json.Serialization; +using System.Text.Json.Serialization.Metadata; +using ChromaDB.Client.Models; +using ChromaDB.Client.Models.Requests; +using ChromaDB.Client.Models.Responses; + +namespace ChromaDB.Client.Common; + +// The metadata of the types the client writes and reads, generated at build time, so that trimming and NativeAOT +// keep what the serialization needs. The values of metadata and filters are objects: their usual types are listed too. +[JsonSerializable(typeof(CollectionAddRequest))] +[JsonSerializable(typeof(CollectionDeleteRequest))] +[JsonSerializable(typeof(CollectionGetRequest))] +[JsonSerializable(typeof(CollectionModifyRequest))] +[JsonSerializable(typeof(CollectionPeekRequest))] +[JsonSerializable(typeof(CollectionQueryRequest))] +[JsonSerializable(typeof(CollectionUpdateRequest))] +[JsonSerializable(typeof(CollectionUpsertRequest))] +[JsonSerializable(typeof(CreateCollectionRequest))] +[JsonSerializable(typeof(CreateDatabaseRequest))] +[JsonSerializable(typeof(CreateTenantRequest))] +[JsonSerializable(typeof(GetOrCreateCollectionRequest))] +[JsonSerializable(typeof(ResetRequest))] +[JsonSerializable(typeof(ChromaSparseVector))] +[JsonSerializable(typeof(CollectionSearchRequest))] +[JsonSerializable(typeof(CollectionSearchResponse))] +[JsonSerializable(typeof(UpdateTenantRequest))] +[JsonSerializable(typeof(ForkCollectionRequest))] +[JsonSerializable(typeof(AttachFunctionRequest))] +[JsonSerializable(typeof(DetachFunctionRequest))] +[JsonSerializable(typeof(ForkCountResponse))] +[JsonSerializable(typeof(AttachFunctionResponse))] +[JsonSerializable(typeof(GetAttachedFunctionResponse))] +[JsonSerializable(typeof(DetachFunctionResponse))] +[JsonSerializable(typeof(ChromaHealthcheck))] +[JsonSerializable(typeof(ChromaIndexingStatus))] +[JsonSerializable(typeof(CollectionEntriesGetResponse))] +[JsonSerializable(typeof(CollectionEntriesQueryResponse))] +[JsonSerializable(typeof(GeneralError))] +[JsonSerializable(typeof(ChromaCollection))] +[JsonSerializable(typeof(List))] +[JsonSerializable(typeof(ChromaDatabase))] +[JsonSerializable(typeof(List))] +[JsonSerializable(typeof(ChromaTenant))] +[JsonSerializable(typeof(ChromaHeartbeat))] +[JsonSerializable(typeof(ChromaUserIdentity))] +[JsonSerializable(typeof(ChromaPreFlightChecks))] +[JsonSerializable(typeof(string))] +[JsonSerializable(typeof(int))] +[JsonSerializable(typeof(bool))] +// The values of metadata and filters. +[JsonSerializable(typeof(Dictionary))] +[JsonSerializable(typeof(object[]))] +[JsonSerializable(typeof(List))] +[JsonSerializable(typeof(long))] +[JsonSerializable(typeof(short))] +[JsonSerializable(typeof(byte))] +[JsonSerializable(typeof(sbyte))] +[JsonSerializable(typeof(ushort))] +[JsonSerializable(typeof(uint))] +[JsonSerializable(typeof(ulong))] +[JsonSerializable(typeof(double))] +[JsonSerializable(typeof(float))] +[JsonSerializable(typeof(decimal))] +[JsonSerializable(typeof(DateTime))] +[JsonSerializable(typeof(DateTimeOffset))] +[JsonSerializable(typeof(Guid))] +[JsonSerializable(typeof(JsonElement))] +[JsonSerializable(typeof(string[]))] +[JsonSerializable(typeof(int[]))] +[JsonSerializable(typeof(long[]))] +[JsonSerializable(typeof(short[]))] +[JsonSerializable(typeof(byte[]))] +[JsonSerializable(typeof(sbyte[]))] +[JsonSerializable(typeof(ushort[]))] +[JsonSerializable(typeof(uint[]))] +[JsonSerializable(typeof(ulong[]))] +[JsonSerializable(typeof(double[]))] +[JsonSerializable(typeof(float[]))] +[JsonSerializable(typeof(decimal[]))] +[JsonSerializable(typeof(bool[]))] +[JsonSerializable(typeof(List))] +[JsonSerializable(typeof(List))] +[JsonSerializable(typeof(List))] +[JsonSerializable(typeof(List))] +[JsonSerializable(typeof(List))] +[JsonSerializable(typeof(List))] +[JsonSerializable(typeof(List))] +[JsonSerializable(typeof(List))] +[JsonSerializable(typeof(List))] +[JsonSerializable(typeof(List))] +[JsonSerializable(typeof(List))] +[JsonSerializable(typeof(List))] +[JsonSerializable(typeof(List))] +internal partial class ChromaJsonContext : JsonSerializerContext +{ +} + +// In a class of its own: the static initializers of the partial ChromaJsonContext run in no given order, so Default +// could still be null there. +internal static class ChromaJsonResolver +{ + // With reflection enabled, as without trimming, the other types of values keep working as before; + // with trimming or NativeAOT, reflection is off and only the types of ChromaJsonContext are known. + public static IJsonTypeInfoResolver Instance { get; } = JsonSerializer.IsReflectionEnabledByDefault + ? JsonTypeInfoResolver.Combine(ChromaJsonContext.Default, CreateReflectionResolver()) + : ChromaJsonContext.Default; + +#if NET + [UnconditionalSuppressMessage("Trimming", "IL2026", Justification = "Used only when reflection-based serialization is enabled, which trimming and NativeAOT turn off.")] + [UnconditionalSuppressMessage("AOT", "IL3050", Justification = "Used only when reflection-based serialization is enabled, which trimming and NativeAOT turn off.")] +#endif + private static IJsonTypeInfoResolver CreateReflectionResolver() => new DefaultJsonTypeInfoResolver(); +} diff --git a/ChromaDB.Client/Common/ChromaNumberConverters.cs b/ChromaDB.Client/Common/ChromaNumberConverters.cs new file mode 100644 index 0000000..b27718c --- /dev/null +++ b/ChromaDB.Client/Common/ChromaNumberConverters.cs @@ -0,0 +1,70 @@ +using System.Globalization; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace ChromaDB.Client.Common; + +// The numbers of the requests, in the text of ChromaNumbers: the same on every build, and floats stay floats; and the +// numbers of the answers, -0.0 with its sign on .NET Framework too. +internal sealed class ChromaDoubleConverter : JsonConverter +{ + public override double Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) => ChromaNumbers.ReadDouble(ref reader); + + public override void Write(Utf8JsonWriter writer, double value, JsonSerializerOptions options) + => writer.WriteRawValue(ChromaNumbers.Format(value), skipInputValidation: true); +} + +internal sealed class ChromaFloatConverter : JsonConverter +{ + public override float Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) => ChromaNumbers.ReadSingle(ref reader); + + public override void Write(Utf8JsonWriter writer, float value, JsonSerializerOptions options) + => writer.WriteRawValue(ChromaNumbers.Format(value), skipInputValidation: true); +} + +// A decimal as its own digits, which are exact on every runtime, with ".0" when it is whole: Chroma keeps it a float. +internal sealed class ChromaDecimalConverter : JsonConverter +{ + public override decimal Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) => reader.GetDecimal(); + + public override void Write(Utf8JsonWriter writer, decimal value, JsonSerializerOptions options) + { + var text = value.ToString(CultureInfo.InvariantCulture); + writer.WriteRawValue(text.IndexOf('.') < 0 ? text + ".0" : text, skipInputValidation: true); + } +} + +// A ulong above long.MaxValue: Chroma stores integers as 64-bit signed numbers, and would store it as a float. +internal sealed class ChromaUInt64Converter : JsonConverter +{ + public override ulong Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) => reader.GetUInt64(); + + public override void Write(Utf8JsonWriter writer, ulong value, JsonSerializerOptions options) + { + if (value > long.MaxValue) + { + throw new ArgumentOutOfRangeException(nameof(value), value, "Chroma stores integers as 64-bit signed numbers: a ulong above long.MaxValue would become a float."); + } + writer.WriteNumberValue(value); + } +} + +// The strings of the requests, values and keys of metadata, without a lone surrogate (ChromaRequestChecks). +internal sealed class ChromaStringConverter : JsonConverter +{ + public override string? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) => reader.GetString(); + + public override void Write(Utf8JsonWriter writer, string value, JsonSerializerOptions options) + { + ChromaRequestChecks.NoLoneSurrogates(value, "A text of the request"); + writer.WriteStringValue(value); + } + + public override string ReadAsPropertyName(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) => reader.GetString()!; + + public override void WriteAsPropertyName(Utf8JsonWriter writer, string value, JsonSerializerOptions options) + { + ChromaRequestChecks.NoLoneSurrogates(value, "A key of the request"); + writer.WritePropertyName(value); + } +} diff --git a/ChromaDB.Client/Common/ChromaNumbers.cs b/ChromaDB.Client/Common/ChromaNumbers.cs new file mode 100644 index 0000000..eb70a25 --- /dev/null +++ b/ChromaDB.Client/Common/ChromaNumbers.cs @@ -0,0 +1,456 @@ +using System.Buffers; +using System.Globalization; +using System.Numerics; +using System.Text; +using System.Text.Json; + +namespace ChromaDB.Client.Common; + +// The text of the doubles and floats the client sends: the fewest digits that read back as the same number, as the +// Python client and System.Text.Json on .NET 8 write them, with ".0" on a whole number, so that Chroma keeps it a +// float (100.0, not 100). The same text on every build and runtime: System.Text.Json writes 17 digits for a double and +// 9 for a float on .NET Framework and in the netstandard2.0 build, and "0" for -0.0 on .NET Framework; Chroma reads +// "100" as an integer, and the 0.x servers compute the distances on the query as they read it. +internal static class ChromaNumbers +{ + // .NET Core 3.0 and later write the shortest digits that read back as the same number, the nearest among them ("R"); + // .NET Framework and Mono do not, and there the digits come from the exact value. +#if NET + private const bool RuntimeShortest = true; +#else + private static readonly bool RuntimeShortest = Environment.Version.Major is 3 or >= 5; +#endif + + // Works around KD-7 (docs/COMPATIBILITY.md) + public static string Format(double value) + { + if (double.IsNaN(value) || double.IsInfinity(value)) + { + throw new ArgumentException($"{value.ToString(CultureInfo.InvariantCulture)} is not a number JSON can carry: Chroma takes finite numbers only.", nameof(value)); + } + var bits = BitConverter.DoubleToInt64Bits(value); + if (value == 0) + { + return bits < 0 ? "-0.0" : "0.0"; + } + string digits; + int exponent; + // "R" of .NET 10 does not read back as the number for 2^-25 and 2^-958: checked, and the exact digits then. + var text = RuntimeShortest ? Math.Abs(value).ToString("R", CultureInfo.InvariantCulture) : null; + if (text is not null && double.Parse(text, NumberStyles.Float, CultureInfo.InvariantCulture) == Math.Abs(value)) + { + FromText(text, out digits, out exponent); + } + else + { + ExactDigits(bits & long.MaxValue, 52, 1075, out digits, out exponent); + } + return Write(bits < 0, digits, exponent); + } + + public static string Format(float value) + { + if (float.IsNaN(value) || float.IsInfinity(value)) + { + throw new ArgumentException($"{value.ToString(CultureInfo.InvariantCulture)} is not a number JSON can carry: Chroma takes finite numbers only.", nameof(value)); + } + var bits = new SingleBits { Value = value }.Bits; + if (value == 0) + { + return bits < 0 ? "-0.0" : "0.0"; + } + string digits; + int exponent; + var text = RuntimeShortest ? Math.Abs(value).ToString("R", CultureInfo.InvariantCulture) : null; + if (text is not null && float.Parse(text, NumberStyles.Float, CultureInfo.InvariantCulture) == Math.Abs(value)) + { + FromText(text, out digits, out exponent); + } + else if (!FloatDigits(bits & int.MaxValue, out digits, out exponent)) + { + ExactDigits(bits & int.MaxValue, 23, 150, out digits, out exponent); + } + return Write(bits < 0, digits, exponent); + } + + [System.Runtime.InteropServices.StructLayout(System.Runtime.InteropServices.LayoutKind.Explicit)] + private struct SingleBits + { + [System.Runtime.InteropServices.FieldOffset(0)] public float Value; + [System.Runtime.InteropServices.FieldOffset(0)] public int Bits; + } + + // The digits of ExactDigits for a float, with doubles: a float, its neighbours and the midpoints between them are + // exact doubles, and every step here errs by a few parts in 10^16, far below the gap between two floats. Where a + // decision comes closer than that to a bound (a rounding half way, a candidate on a midpoint, two candidates as + // near), it gives up and the exact digits decide: the same result as ExactDigits, on every runtime, about twenty + // times faster on .NET Framework, where the embeddings of a query go through here. + internal static bool FloatDigits(int bits, out string digits, out int exponent) + { + digits = ""; + exponent = 0; + var biased = bits >> 23; + var fraction = bits & ((1 << 23) - 1); + var mantissa = biased == 0 ? fraction : fraction | (1 << 23); + var power = (biased == 0 ? 1 : biased) - 150; + var value = mantissa * Pow2(power); + var high = value + Pow2(power - 1); + var low = value - (fraction == 0 && biased > 1 ? Pow2(power - 2) : Pow2(power - 1)); + var tolerance = value * 1e-12; + + var k = (int)Math.Floor(Math.Log10(value)); + var ratio = value / Pow10Double(k); + if (ratio >= 10) k++; + else if (ratio < 1) k--; + ratio = value / Pow10Double(k); + if (ratio < 1 + 1e-12 && ratio != 1 || ratio > 10 - 1e-11) return false; + + for (var precision = 1; precision <= 9; precision++) + { + var shift = k - precision + 1; + var scaled = value / Pow10Double(shift); + var rounded = Math.Floor(scaled + 0.5); + if (Math.Abs(scaled - Math.Floor(scaled) - 0.5) < 1e-6) return false; + var best = 0.0; + var bestDistance = double.MaxValue; + for (var c = rounded - 1; c <= rounded + 1; c++) + { + if (c <= 0) continue; + var candidate = c * Pow10Double(shift); + var aboveLow = candidate - low; + var belowHigh = high - candidate; + if (Math.Abs(aboveLow) <= tolerance || Math.Abs(belowHigh) <= tolerance) return false; + if (aboveLow < 0 || belowHigh < 0) continue; + var distance = Math.Abs(candidate - value); + if (Math.Abs(distance - bestDistance) <= tolerance) return false; + if (distance < bestDistance) + { + best = c; + bestDistance = distance; + } + } + if (best > 0) + { + var text = ((long)best).ToString(CultureInfo.InvariantCulture); + digits = text.TrimEnd('0'); + exponent = shift + text.Length - 1; + return true; + } + } + return false; + } + + private static double Pow2(int n) => n >= -1022 ? BitConverter.Int64BitsToDouble((long)(n + 1023) << 52) : Math.Pow(2, n); + + // 10^n for the floats, -55 to 40: exact up to 10^22, the nearest double beyond, so that a step errs by one rounding. + private static readonly double[] PowersDouble = MakePowersDouble(); + + private static double[] MakePowersDouble() + { + var powers = new double[96]; + for (var n = -55; n <= 40; n++) + { + powers[n + 55] = double.Parse("1E" + n.ToString(CultureInfo.InvariantCulture), CultureInfo.InvariantCulture); + } + return powers; + } + + private static double Pow10Double(int n) => PowersDouble[n + 55]; + + // A number of an answer, with the sign of -0.0, which .NET Framework drops when it parses "-0.0". On .NET Framework + // System.Text.Json parses with the runtime, which reads about one in 250 of the shortest texts of doubles, the ones + // Chroma writes, as the double next to it: there the digits are read here. + public static double ReadDouble(ref Utf8JsonReader reader) + { + if (!RuntimeShortest && reader.TokenType == JsonTokenType.Number) + { + ReadOnlySpan text = reader.HasValueSequence ? reader.ValueSequence.ToArray() : reader.ValueSpan; + return ParseDouble(text); + } + var value = reader.GetDouble(); + return value == 0 && StartsWithMinus(ref reader) ? -0.0 : value; + } + + // The double nearest to a JSON number, half to even, as the parsers of .NET Core 3.0 and later read it: with doubles + // when that is exact (up to 15 digits and 10^22, a single rounding), with exact integers otherwise. + internal static double ParseDouble(ReadOnlySpan text) + { + var i = 0; + var negative = text.Length > 0 && text[0] == (byte)'-'; + if (negative) i++; + var digits = new StringBuilder(24); + var exponent = 0; + var fraction = false; + for (; i < text.Length; i++) + { + var c = text[i]; + if (c >= (byte)'0' && c <= (byte)'9') + { + if (digits.Length > 0 || c != (byte)'0') digits.Append((char)c); + if (fraction) exponent--; + } + else if (c == (byte)'.') + { + fraction = true; + } + else + { + break; + } + } + if (i < text.Length) + { + i++; + var negativeExponent = false; + if (i < text.Length && (text[i] == (byte)'+' || text[i] == (byte)'-')) + { + negativeExponent = text[i] == (byte)'-'; + i++; + } + var e = 0; + for (; i < text.Length; i++) e = Math.Min(e * 10 + (text[i] - (byte)'0'), 100000); + exponent += negativeExponent ? -e : e; + } + var length = digits.Length; + while (length > 0 && digits[length - 1] == '0') + { + length--; + exponent++; + } + double value; + if (length == 0 || length + exponent < -330) + { + value = 0; + } + else if (length + exponent > 310) + { + value = double.PositiveInfinity; + } + else if (length <= 15 && exponent >= -22 && exponent <= 22) + { + var mantissa = (double)long.Parse(digits.ToString(0, length), CultureInfo.InvariantCulture); + value = exponent >= 0 ? mantissa * ExactPowers[exponent] : mantissa / ExactPowers[-exponent]; + } + else + { + value = Nearest(BigInteger.Parse(digits.ToString(0, length), CultureInfo.InvariantCulture), exponent); + } + return negative ? -value : value; + } + + private static readonly double[] ExactPowers = [1e0, 1e1, 1e2, 1e3, 1e4, 1e5, 1e6, 1e7, 1e8, 1e9, 1e10, 1e11, 1e12, 1e13, 1e14, 1e15, 1e16, 1e17, 1e18, 1e19, 1e20, 1e21, 1e22]; + + // The double nearest to digits * 10^exponent. + private static double Nearest(BigInteger digits, int exponent) + { + var num = exponent >= 0 ? digits * Pow10(exponent) : digits; + var den = exponent >= 0 ? BigInteger.One : Pow10(-exponent); + // The power of two of the 53 bits of the mantissa: estimated, then corrected. + var e2 = (int)Math.Floor(BigInteger.Log(num, 2) - BigInteger.Log(den, 2)) - 52; + while (true) + { + if (e2 < -1074) e2 = -1074; + var q = BigInteger.DivRem(e2 >= 0 ? num : num << -e2, e2 >= 0 ? den << e2 : den, out var r); + if (q >= Two53) + { + e2++; + continue; + } + if (q < Two52 && e2 > -1074) + { + e2--; + continue; + } + var twice = (r * 2).CompareTo(e2 >= 0 ? den << e2 : den); + if (twice > 0 || twice == 0 && !q.IsEven) q++; + if (q == Two53) + { + q = Two52; + e2++; + } + if (e2 > 971) return double.PositiveInfinity; + var bits = q < Two52 ? (long)q : ((long)(e2 + 1075) << 52) | (long)(q - Two52); + return BitConverter.Int64BitsToDouble(bits); + } + } + + private static readonly BigInteger Two52 = BigInteger.One << 52; + private static readonly BigInteger Two53 = BigInteger.One << 53; + + // Works around KD-9 and KD-10 (docs/COMPATIBILITY.md) + // Chroma 1.x sends null for a float it cannot write, NaN or infinite, like an embedding beyond the range of a float in a cosine + // collection: it reads as NaN, so that one record does not cost the whole answer. A number beyond the range of a float, like a + // distance that Chroma 0.6.3 computes as a double, reads as an infinity, as a cast from the double does. + public static float ReadSingle(ref Utf8JsonReader reader) + { + if (reader.TokenType == JsonTokenType.Null) + { + return float.NaN; + } + if (!reader.TryGetSingle(out var value)) + { + return (float)ReadDouble(ref reader); + } + return value == 0 && StartsWithMinus(ref reader) ? -0f : value; + } + + public static float ReadSingle(JsonElement element) + { + if (element.ValueKind == JsonValueKind.Null) + { + return float.NaN; + } + if (!element.TryGetSingle(out var value)) + { + return (float)element.GetDouble(); + } + return value == 0 && element.GetRawText().StartsWith("-", StringComparison.Ordinal) ? -0f : value; + } + + private static bool StartsWithMinus(ref Utf8JsonReader reader) + => (reader.HasValueSequence ? reader.ValueSequence.First.Span : reader.ValueSpan) is { Length: > 0 } span && span[0] == (byte)'-'; + + // "1.2345E-07" or "123.45" into the digits "12345" and the exponent of the first digit, -7 or 2. + internal static void FromText(string text, out string digits, out int exponent) + { + var e = text.IndexOfAny(['E', 'e']); + var mantissa = e < 0 ? text : text.Substring(0, e); + var power = e < 0 ? 0 : int.Parse(text.Substring(e + 1), NumberStyles.AllowLeadingSign, CultureInfo.InvariantCulture); + var point = mantissa.IndexOf('.'); + var whole = point < 0 ? mantissa : mantissa.Substring(0, point); + var all = whole + (point < 0 ? "" : mantissa.Substring(point + 1)); + var lead = 0; + while (lead < all.Length - 1 && all[lead] == '0') lead++; + digits = all.Substring(lead).TrimEnd('0'); + exponent = power + whole.Length - 1 - lead; + } + + // The shortest digits of a positive finite number that read back as it, the nearest to it among those (Steele and + // White, with exact integers). The bits without the sign; the size of the fraction and the bias of the format. + internal static void ExactDigits(long bits, int fractionBits, int bias, out string digits, out int exponent) + { + var biased = (int)(bits >> fractionBits); + var fraction = bits & ((1L << fractionBits) - 1); + var mantissa = biased == 0 ? fraction : fraction | (1L << fractionBits); + var power = (biased == 0 ? 1 : biased) - bias; + // The numbers that read back as this one: between the midpoints with the neighbours, the midpoints included + // when the mantissa is even (round half to even). Below a power of two the neighbour is half as far. + var scale = fraction == 0 && biased > 1 ? 4 : 2; + var value = new BigInteger(mantissa) * scale; + var high = value + scale / 2; + var low = value - 1; + var denominator = new BigInteger(scale); + if (power >= 0) + { + value <<= power; + high <<= power; + low <<= power; + } + else + { + denominator <<= -power; + } + var inclusive = mantissa % 2 == 0; + + var k = (int)Math.Floor(BigInteger.Log10(value) - BigInteger.Log10(denominator)); + while (Compare(value, denominator, k) < 0) k--; + while (Compare(value, denominator, k + 1) >= 0) k++; + + var maxDigits = fractionBits > 23 ? 17 : 9; + for (var precision = 1; precision <= maxDigits; precision++) + { + var shift = k - precision + 1; + var rounded = RoundHalfEven(value, denominator, shift); + BigInteger? best = null; + BigInteger bestDistance = default; + // The nearest number of this many digits, and its neighbours for the uneven bounds below a power of two. + // All in units of 10^shift, so 10^precision (a rounding up to the next power of ten) is one digit too. + foreach (var n in new[] { rounded, rounded - 1, rounded + 1 }) + { + if (n <= 0 || !Inside(n, shift, low, high, denominator, inclusive)) continue; + var distance = BigInteger.Abs(Numerator(n, shift, denominator) - Scale(value, shift)); + if (best is null || distance < bestDistance || distance == bestDistance && n.IsEven) + { + best = n; + bestDistance = distance; + } + } + if (best is { } found) + { + var text = found.ToString(CultureInfo.InvariantCulture); + digits = text.TrimEnd('0'); + exponent = shift + text.Length - 1; + return; + } + } + throw new InvalidOperationException("No digits read back as the number."); + } + + // The powers of ten up to the ones of the smallest subnormal double, computed once. + private static readonly BigInteger[] Powers = MakePowers(400); + + private static BigInteger[] MakePowers(int count) + { + var powers = new BigInteger[count]; + powers[0] = BigInteger.One; + for (var i = 1; i < count; i++) powers[i] = powers[i - 1] * 10; + return powers; + } + + private static BigInteger Pow10(int n) => n < Powers.Length ? Powers[n] : BigInteger.Pow(10, n); + + // value / denominator against 10^k. + private static int Compare(BigInteger value, BigInteger denominator, int k) + => k >= 0 ? value.CompareTo(denominator * Pow10(k)) : (value * Pow10(-k)).CompareTo(denominator); + + // value / denominator / 10^shift, rounded half to even. + private static BigInteger RoundHalfEven(BigInteger value, BigInteger denominator, int shift) + { + var num = shift < 0 ? value * Pow10(-shift) : value; + var den = shift > 0 ? denominator * Pow10(shift) : denominator; + var q = BigInteger.DivRem(num, den, out var r); + var twice = (r * 2).CompareTo(den); + return twice > 0 || twice == 0 && !q.IsEven ? q + 1 : q; + } + + // n * 10^e and a bound x / denominator over the same denominator, denominator * 10^-e when e < 0. + private static BigInteger Numerator(BigInteger n, int e, BigInteger denominator) => e >= 0 ? n * Pow10(e) * denominator : n * denominator; + private static BigInteger Scale(BigInteger x, int e) => e >= 0 ? x : x * Pow10(-e); + + private static bool Inside(BigInteger n, int e, BigInteger low, BigInteger high, BigInteger denominator, bool inclusive) + { + var number = Numerator(n, e, denominator); + var cLow = number.CompareTo(Scale(low, e)); + var cHigh = number.CompareTo(Scale(high, e)); + return inclusive ? cLow >= 0 && cHigh <= 0 : cLow > 0 && cHigh < 0; + } + + // Plain notation from 10^-4 to below 10^15, as .NET writes doubles, d.dddE+XX outside. + private static string Write(bool negative, string digits, int exponent) + { + var sb = new StringBuilder(digits.Length + 8); + if (negative) sb.Append('-'); + if (exponent >= -4 && exponent < 15) + { + if (exponent < 0) + { + sb.Append("0.").Append('0', -exponent - 1).Append(digits); + } + else if (digits.Length <= exponent + 1) + { + sb.Append(digits).Append('0', exponent + 1 - digits.Length).Append(".0"); + } + else + { + sb.Append(digits, 0, exponent + 1).Append('.').Append(digits, exponent + 1, digits.Length - exponent - 1); + } + } + else + { + sb.Append(digits[0]); + if (digits.Length > 1) sb.Append('.').Append(digits, 1, digits.Length - 1); + sb.Append('E').Append(exponent < 0 ? '-' : '+').Append(Math.Abs(exponent).ToString("00", CultureInfo.InvariantCulture)); + } + return sb.ToString(); + } +} diff --git a/ChromaDB.Client/Common/ChromaRequestChecks.cs b/ChromaDB.Client/Common/ChromaRequestChecks.cs new file mode 100644 index 0000000..bf1ad03 --- /dev/null +++ b/ChromaDB.Client/Common/ChromaRequestChecks.cs @@ -0,0 +1,243 @@ +using System.Collections; +using System.Text.Json; + +namespace ChromaDB.Client.Common; + +// What a server would drop, change or fail on, stopped before the request with a message that says why. +internal static class ChromaRequestChecks +{ + // Works around KD-22 (docs/COMPATIBILITY.md) + // An empty list in the metadata of a record: Chroma 0.6.3 and 1.5.9 drop the key without an error, 1.0 to 1.4 + // reject it, Chroma Cloud stores it, and the Python client rejects it. Rejected here too, so that the same code does + // the same on every server. + public static void NoEmptyLists(IReadOnlyList?>? metadatas, string paramName) + { + if (metadatas is null) + { + return; + } + foreach (var metadata in metadatas) + { + if (metadata is null) + { + continue; + } + foreach (var pair in metadata) + { + if (IsList(pair.Value) && IsEmpty(pair.Value)) + { + throw new ArgumentException($"The list of the metadata key \"{pair.Key}\" is empty: Chroma takes lists with at least one value, as the Python client checks, and a single server drops an empty one without an error.", paramName); + } + } + } + } + + // A null value in the metadata of a new record: Chroma 0.x drops the key without an error, and 1.x rejects the request with + // 422. In an update or an upsert a null deletes the key on every tested Chroma, so only AddAsync rejects it. + public static void NoNullValues(IReadOnlyList?>? metadatas, string paramName) + { + if (metadatas is null) + { + return; + } + foreach (var metadata in metadatas) + { + if (metadata is null) + { + continue; + } + foreach (var pair in metadata) + { + if (pair.Value is null) + { + throw new ArgumentException($"The metadata key \"{pair.Key}\" is null: a null deletes a key in UpdateAsync and UpsertAsync, and a new record has none to delete; Chroma 0.x would drop the key, and 1.x rejects the request.", paramName); + } + } + } + } + + // Works around KD-20 (docs/COMPATIBILITY.md) + // No Chroma stores a list in the metadata of a collection: 0.x and 1.0 to 1.4 reject it, Chroma Cloud answers 500 + // and 1.5.9 closes the connection. + public static void NoLists(IReadOnlyDictionary? metadata, string paramName) + { + if (metadata is null) + { + return; + } + foreach (var pair in metadata) + { + if (IsList(pair.Value)) + { + throw new ArgumentException($"The metadata key \"{pair.Key}\" of the collection has a list: Chroma stores lists only in the metadata of records.", paramName); + } + } + } + + // A JsonElement array is what the client returns for a list read with ChromaMetadataValues.Inferred. A byte[] goes + // as a base64 string. + public static bool IsList(object? value) + => value is JsonElement { ValueKind: JsonValueKind.Array } + or IEnumerable and not string and not IDictionary and not byte[]; + + // A JsonElement number or string, what the client returns with ChromaMetadataValues.Inferred and sends as its raw + // value, checked as the double or string it holds. + public static object? Scalar(object? value) + => value switch + { + JsonElement { ValueKind: JsonValueKind.Number } element when element.TryGetDouble(out var number) => number, + JsonElement { ValueKind: JsonValueKind.String } element => element.GetString(), + _ => value, + }; + + // Only from a count: enumerating the list would consume one that can be read once, before it is sent. + public static bool IsEmpty(object? value) + => value is JsonElement element ? element.GetArrayLength() == 0 : value is ICollection { Count: 0 }; + + // Lists of another length than the ids would be cut differently in each batch, so that part of a write would go before the error; + // and the numbers of an embedding must be finite, which JSON numbers and the base64 embeddings of the server need. + public static void SameLengths(ChromaDB.Client.Models.ChromaRecords records, string paramName) + { + var count = records.Ids.Count; + Check(records.Embeddings?.Count, "embeddings"); + Check(records.Metadatas?.Count, "metadatas"); + Check(records.Documents?.Count, "documents"); + Check(records.Uris?.Count, "uris"); + foreach (var embedding in records.Embeddings ?? []) + { + foreach (var number in embedding.Span) + { + if (float.IsNaN(number) || float.IsInfinity(number)) + { + throw new ArgumentException($"An embedding has {number.ToString(System.Globalization.CultureInfo.InvariantCulture)}: Chroma takes finite numbers only.", paramName); + } + } + } + + void Check(int? length, string name) + { + if (length is { } given && given != count) + { + throw new ArgumentException($"The {name} are {given} and the ids {count}: each record needs one, null for none.", paramName); + } + } + } + + // Works around KD-58 (docs/COMPATIBILITY.md) + // Chroma keeps the document of a record in the metadata key chroma:document: a text there sets the document, and another value + // makes Chroma 1.x fail every write and read of the server until it restarts. The Python client rejects the key with any value; + // here only the values that are not texts, so that what works today keeps working. + public const string DocumentKey = "chroma:document"; + + // A text is what the client writes as a JSON string: a string, and a Guid, a date or a byte[] in base64 too. The value is written as + // the request writes it, as an object: without reflection a type the JSON context does not list goes as the type it derives from, + // like a list derived from List. A value whose JSON cannot be told is not a text. + public static bool IsText(object? value) + { + if (value is null) + { + return false; + } + if (value is string or JsonElement { ValueKind: JsonValueKind.String }) + { + return true; + } + try + { + return JsonSerializer.SerializeToElement(value, HttpClientHelpers.PostJsonSerializerOptions.TypeInfo()).ValueKind == JsonValueKind.String; + } + catch (Exception e) when (e is NotSupportedException or InvalidOperationException or ArgumentException) + { + return false; + } + } + + // The metadata a document copy key or a document deletion key adds after the given one is checked: under chroma:document, a + // deleted document or a mark would go as a value that is not a text. + public static void GeneratedDocumentKey(IReadOnlyList?>? metadatas, string paramName) + { + foreach (var metadata in metadatas ?? []) + { + if (metadata is not null && metadata.TryGetValue(DocumentKey, out var value) && !IsText(value)) + { + throw new ArgumentException($"The document copy key or the document deletion key of the collection client is \"{DocumentKey}\", which holds the document of the record in Chroma: a deleted document or a mark would write a value that is not a text there, and that makes Chroma 1.x fail every write and read of the server until it restarts. Choose another key.", paramName); + } + } + } + + // What the Python client checks too: a list holds values of one type, strings, integers, floating-point numbers or Booleans. The + // server answers with an error that does not say what is wrong. A list that can be read once is not read here. + public static void MetadataValues(IReadOnlyList?>? metadatas, string paramName) + { + foreach (var metadata in metadatas ?? []) + { + foreach (var pair in metadata ?? Enumerable.Empty>()) + { + if (pair.Key == DocumentKey && !IsText(pair.Value)) + { + throw new ArgumentException($"The metadata key \"{DocumentKey}\" holds the document of the record in Chroma and takes a text only: another value makes Chroma 1.x fail every write and read of the server until it restarts.", paramName); + } + if (IsList(pair.Value) && pair.Value is JsonElement or ICollection && Kinds(pair.Value).Distinct().Skip(1).Any()) + { + throw new ArgumentException($"The list of the metadata key \"{pair.Key}\" holds values of more than one type: Chroma takes strings, integers, floating-point numbers or Booleans, one type in a list.", paramName); + } + } + } + } + + // The type of each value of a list, as Chroma tells them: null for one it does not take. + private static IEnumerable Kinds(object value) + { + if (value is JsonElement element) + { + foreach (var item in element.EnumerateArray()) + { + yield return item.ValueKind switch + { + JsonValueKind.String => "string", + JsonValueKind.True or JsonValueKind.False => "bool", + JsonValueKind.Number => item.TryGetInt64(out _) ? "int" : "float", + _ => null, + }; + } + yield break; + } + foreach (var item in (IEnumerable)value) + { + yield return item switch + { + string => "string", + bool => "bool", + sbyte or byte or short or ushort or int or uint or long or ulong => "int", + float or double or decimal => "float", + JsonElement json => json.ValueKind switch + { + JsonValueKind.String => "string", + JsonValueKind.True or JsonValueKind.False => "bool", + JsonValueKind.Number => json.TryGetInt64(out _) ? "int" : "float", + _ => null, + }, + _ => null, + }; + } + } + + // A lone half of a surrogate pair: UTF-8 has no form for it, and System.Text.Json would send U+FFFD in its place, so + // an id or a document would come back changed. + public static void NoLoneSurrogates(string value, string what) + { + for (var i = 0; i < value.Length; i++) + { + var c = value[i]; + if (char.IsHighSurrogate(c) && i + 1 < value.Length && char.IsLowSurrogate(value[i + 1])) + { + i++; + continue; + } + if (char.IsSurrogate(c)) + { + throw new ArgumentException($"{what} has a lone surrogate, U+{(int)c:X4} at {i}: UTF-8 cannot carry it, and it would reach the server as U+FFFD."); + } + } + } +} diff --git a/ChromaDB.Client/Common/ChromaRoutes.cs b/ChromaDB.Client/Common/ChromaRoutes.cs new file mode 100644 index 0000000..4124d31 --- /dev/null +++ b/ChromaDB.Client/Common/ChromaRoutes.cs @@ -0,0 +1,67 @@ +namespace ChromaDB.Client.Common; + +// The request paths of each version of the Chroma API, relative to the base URI. +internal sealed class ChromaRoutes +{ + public static ChromaRoutes V2 { get; } = new() + { + Heartbeat = "heartbeat", + Healthcheck = "healthcheck", + Version = "version", + Reset = "reset", + UserIdentity = "auth/identity", + PreFlightChecks = "pre-flight-checks", + Tenants = "tenants", + Tenant = "tenants/{tenant}", + CollectionByCrn = "collections/{crn}", + Databases = "tenants/{tenant}/databases", + Database = "tenants/{tenant}/databases/{database}", + Collections = "tenants/{tenant}/databases/{database}/collections", + CollectionByName = "tenants/{tenant}/databases/{database}/collections/{collectionName}", + CollectionById = "tenants/{tenant}/databases/{database}/collections/by-id/{collection_id}", + CollectionsCount = "tenants/{tenant}/databases/{database}/collections_count", + Collection = "tenants/{tenant}/databases/{database}/collections/{collection_id}", + }; + + // In the v1 API the tenant and the database are query parameters, and the requests on a collection need only its id. + public static ChromaRoutes V1 { get; } = new() + { + Heartbeat = "heartbeat", + Healthcheck = "healthcheck", + Version = "version", + Reset = "reset", + UserIdentity = "auth/identity", + PreFlightChecks = "pre-flight-checks", + Tenants = "tenants", + Tenant = "tenants/{tenant}", + CollectionByCrn = "collections/{crn}", + Databases = "databases?tenant={tenant}", + Database = "databases/{database}?tenant={tenant}", + Collections = "collections?tenant={tenant}&database={database}", + CollectionByName = "collections/{collectionName}?tenant={tenant}&database={database}", + CollectionById = "collections/by-id/{collection_id}?tenant={tenant}&database={database}", + CollectionsCount = "count_collections?tenant={tenant}&database={database}", + Collection = "collections/{collection_id}", + }; + + public required string Heartbeat { get; init; } + // Only the v2 API of Chroma 1.0.0 and later has it. + public required string Healthcheck { get; init; } + public required string Version { get; init; } + public required string Reset { get; init; } + // Only the v2 API has it. + public required string UserIdentity { get; init; } + public required string PreFlightChecks { get; init; } + public required string Tenants { get; init; } + public required string Tenant { get; init; } + // Only Chroma Cloud has it. + public required string CollectionByCrn { get; init; } + public required string Databases { get; init; } + public required string Database { get; init; } + public required string Collections { get; init; } + public required string CollectionByName { get; init; } + // Only the v2 API of Chroma 1.5.7 and later has it. + public required string CollectionById { get; init; } + public required string CollectionsCount { get; init; } + public required string Collection { get; init; } +} diff --git a/ChromaDB.Client/Common/ChromaSpaceNames.cs b/ChromaDB.Client/Common/ChromaSpaceNames.cs new file mode 100644 index 0000000..f4b1dc7 --- /dev/null +++ b/ChromaDB.Client/Common/ChromaSpaceNames.cs @@ -0,0 +1,22 @@ +namespace ChromaDB.Client.Common; + +internal static class ChromaSpaceNames +{ + public const string MetadataKey = "hnsw:space"; + + public static string ToName(ChromaSpace space) => space switch + { + ChromaSpace.L2 => "l2", + ChromaSpace.Cosine => "cosine", + ChromaSpace.InnerProduct => "ip", + _ => throw new ArgumentOutOfRangeException(nameof(space)), + }; + + public static ChromaSpace? FromName(string? name) => name switch + { + "l2" => ChromaSpace.L2, + "cosine" => ChromaSpace.Cosine, + "ip" => ChromaSpace.InnerProduct, + _ => null, + }; +} diff --git a/ChromaDB.Client/Common/ChromaSparseVectorConverter.cs b/ChromaDB.Client/Common/ChromaSparseVectorConverter.cs new file mode 100644 index 0000000..8cb8b58 --- /dev/null +++ b/ChromaDB.Client/Common/ChromaSparseVectorConverter.cs @@ -0,0 +1,67 @@ +using System.Text.Json; +using System.Text.Json.Serialization; +using ChromaDB.Client.Models; + +namespace ChromaDB.Client.Common; + +// {"#type": "sparse_vector", "indices": [...], "values": [...], "tokens": [...]}: the server takes it also without "#type", and always +// sends it with it. Written by hand, so trimming and NativeAOT keep what it needs. +internal sealed class ChromaSparseVectorConverter : JsonConverter +{ + public const string TypeKey = "#type"; + public const string TypeValue = "sparse_vector"; + + public override ChromaSparseVector Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + => FromJson(JsonDocument.ParseValue(ref reader).RootElement); + + // Whether the object is a sparse vector, with "#type": "sparse_vector", without reading its values. + public static bool IsTagged(JsonElement element) + => element.ValueKind == JsonValueKind.Object && element.TryGetProperty(TypeKey, out var type) && type.ValueKind == JsonValueKind.String && type.ValueEquals(TypeValue); + + // Null when the object is not a sparse vector. + public static ChromaSparseVector? FromTaggedJson(JsonElement element) + => IsTagged(element) ? FromJson(element) : null; + + static ChromaSparseVector FromJson(JsonElement element) + { + if (element.ValueKind != JsonValueKind.Object || !element.TryGetProperty("indices", out var indices) || !element.TryGetProperty("values", out var values)) + { + throw new JsonException("A sparse vector needs indices and values."); + } + var tokens = element.TryGetProperty("tokens", out var t) && t.ValueKind == JsonValueKind.Array + ? t.EnumerateArray().Select(x => x.GetString()!).ToList() + : null; + return new ChromaSparseVector( + indices.EnumerateArray().Select(x => x.GetUInt32()).ToList(), + values.EnumerateArray().Select(ChromaNumbers.ReadSingle).ToList(), + tokens); + } + + public override void Write(Utf8JsonWriter writer, ChromaSparseVector value, JsonSerializerOptions options) + { + writer.WriteStartObject(); + writer.WriteString(TypeKey, TypeValue); + writer.WriteStartArray("indices"); + foreach (var index in value.Indices) + { + writer.WriteNumberValue(index); + } + writer.WriteEndArray(); + writer.WriteStartArray("values"); + foreach (var v in value.Values) + { + writer.WriteRawValue(ChromaNumbers.Format(v), skipInputValidation: true); + } + writer.WriteEndArray(); + if (value.Tokens is { } tokens) + { + writer.WriteStartArray("tokens"); + foreach (var token in tokens) + { + writer.WriteStringValue(token); + } + writer.WriteEndArray(); + } + writer.WriteEndObject(); + } +} diff --git a/ChromaDB.Client/Common/ClientConstants.cs b/ChromaDB.Client/Common/ClientConstants.cs index 4827194..f36d4ce 100644 --- a/ChromaDB.Client/Common/ClientConstants.cs +++ b/ChromaDB.Client/Common/ClientConstants.cs @@ -6,7 +6,8 @@ internal static class ClientConstants { public const string DefaultTenantName = "default_tenant"; public const string DefaultDatabaseName = "default_database"; - public const string DefaultUri = "http://localhost:8000/api/v1/"; + // The address of the server: the client adds the path of the API version. + public const string DefaultUri = "http://localhost:8000"; public const string ChromaTokenHeader = "X-Chroma-Token"; public static ChromaTenant DefaultTenant { get; } = new(DefaultTenantName); diff --git a/ChromaDB.Client/Common/CollectionEntryMapper.cs b/ChromaDB.Client/Common/CollectionEntryMapper.cs index 4925086..10fb7a0 100644 --- a/ChromaDB.Client/Common/CollectionEntryMapper.cs +++ b/ChromaDB.Client/Common/CollectionEntryMapper.cs @@ -5,16 +5,15 @@ namespace ChromaDB.Client.Common; internal static class CollectionEntryMapper { - public static List Map(this CollectionEntriesGetResponse response) + public static List Map(this CollectionEntriesGetResponse response, DocumentReader copy = default) { return response.Ids .Select((id, i) => new ChromaCollectionEntry(id) { - Embeddings = response.Embeddings?[i], - Metadata = response.Metadatas?[i], - Document = response.Documents?[i], - Uris = response.Uris?[i], - Data = response.Data, + Embedding = response.Embeddings?[i], + Metadata = copy.Metadata(response.Metadatas is { } metadatas ? metadatas[i] ?? DocumentReader.NoKeys : null), + Document = copy.Document(response.Documents?[i], response.Metadatas?[i]), + Uri = response.Uris?[i], }) .ToList(); } diff --git a/ChromaDB.Client/Common/CollectionQueryEntryMapper.cs b/ChromaDB.Client/Common/CollectionQueryEntryMapper.cs index 34a55d0..ce7b2de 100644 --- a/ChromaDB.Client/Common/CollectionQueryEntryMapper.cs +++ b/ChromaDB.Client/Common/CollectionQueryEntryMapper.cs @@ -5,18 +5,17 @@ namespace ChromaDB.Client.Common; internal static class CollectionQueryEntryMapper { - public static List> Map(this CollectionEntriesQueryResponse response) + public static List> Map(this CollectionEntriesQueryResponse response, DocumentReader copy = default) { return response.Ids .Select((_, i) => response.Ids[i] .Select((id, j) => new ChromaCollectionQueryEntry(id) { - Distance = response.Distances[i].Span[j], - Metadata = response.Metadatas?[i][j], - Embeddings = response.Embeddings?[i][j], - Document = response.Documents?[i][j], - Uris = response.Uris?[i][j], - Data = response.Data, + Distance = response.Distances?[i][j], + Metadata = copy.Metadata(response.Metadatas is { } metadatas ? metadatas[i][j] ?? DocumentReader.NoKeys : null), + Embedding = response.Embeddings?[i][j], + Document = copy.Document(response.Documents?[i][j], response.Metadatas?[i][j]), + Uri = response.Uris?[i][j], }) .ToList()) .ToList(); diff --git a/ChromaDB.Client/Common/DocumentReader.cs b/ChromaDB.Client/Common/DocumentReader.cs new file mode 100644 index 0000000..7d7fc9a --- /dev/null +++ b/ChromaDB.Client/Common/DocumentReader.cs @@ -0,0 +1,33 @@ +namespace ChromaDB.Client.Common; + +// How a client with a document copy key or a document deletion key reads the documents. Chroma cannot delete a document, so for one +// deleted with NullDocumentsDelete the client wrote an empty one: an empty document is a deleted one when its copy is not in the metadata, +// or when the metadata has its mark, true under the deletion key. The metadata, read for it, goes away from the results that did not ask +// for it. The default reads the documents and the metadata as they are. +internal readonly struct DocumentReader +{ + private readonly string? _key; + private readonly bool _mark; + private readonly bool _dropMetadata; + + public DocumentReader(string key, bool mark, bool keepMetadata) + { + _key = key; + _mark = mark; + _dropMetadata = !keepMetadata; + } + + // The metadata of a record without keys, when the metadata is read: empty, not null. + public static readonly IReadOnlyDictionary NoKeys = new System.Collections.ObjectModel.ReadOnlyDictionary(new Dictionary()); + + // Whether the documents need the metadata. + public bool ReadsMetadata => _key is not null; + + public string? Document(string? document, IReadOnlyDictionary? metadata) + => _key is null || document is not "" ? document + : _mark ? (metadata is not null && metadata.TryGetValue(_key, out var mark) && mark is true ? null : document) + : metadata?.ContainsKey(_key) == true ? document : null; + + public IReadOnlyDictionary? Metadata(IReadOnlyDictionary? metadata) + => _dropMetadata ? null : metadata; +} diff --git a/ChromaDB.Client/Common/HttpClientHelpers.cs b/ChromaDB.Client/Common/HttpClientHelpers.cs index 344a0cb..6258678 100644 --- a/ChromaDB.Client/Common/HttpClientHelpers.cs +++ b/ChromaDB.Client/Common/HttpClientHelpers.cs @@ -1,8 +1,8 @@ -using System.Net; -using System.Net.Http.Headers; +using System.Net.Http.Headers; using System.Text; using System.Text.Json; using System.Text.Json.Serialization; +using System.Text.Json.Serialization.Metadata; using System.Text.RegularExpressions; using ChromaDB.Client.Models.Requests; using ChromaDB.Client.Models.Responses; @@ -11,11 +11,22 @@ namespace ChromaDB.Client.Common; internal static partial class HttpClientHelpers { - private static readonly JsonSerializerOptions PostJsonSerializerOptions = new() + internal static readonly JsonSerializerOptions PostJsonSerializerOptions = new() { AllowTrailingCommas = false, ReferenceHandler = ReferenceHandler.IgnoreCycles, ReadCommentHandling = JsonCommentHandling.Skip, + TypeInfoResolver = ChromaJsonResolver.Instance, + // The doubles, floats and decimals of metadata, filters, ranks and queries, the same text on every build; and + // what Chroma would store changed: ulongs above long.MaxValue, texts with a lone surrogate. + Converters = + { + new ChromaDoubleConverter(), + new ChromaFloatConverter(), + new ChromaDecimalConverter(), + new ChromaUInt64Converter(), + new ChromaStringConverter(), + }, }; private static readonly JsonSerializerOptions DeserializerJsonSerializerOptions = new() @@ -23,154 +34,217 @@ internal static partial class HttpClientHelpers Converters = { new ObjectToInferredTypesJsonConverter(), + new ChromaDoubleConverter(), + new ChromaFloatConverter(), }, + TypeInfoResolver = ChromaJsonResolver.Instance, }; - public static async Task Get(this HttpClient httpClient, string endpoint, RequestQueryParams queryParams) + private static readonly JsonSerializerOptions ExactDeserializerJsonSerializerOptions = new() { - using var httpRequestMessage = new HttpRequestMessage(HttpMethod.Get, requestUri: ValidateAndPrepareEndpoint(endpoint, queryParams)); - return await Send(httpClient, httpRequestMessage); + Converters = + { + new ObjectToExactTypesJsonConverter(), + new ChromaDoubleConverter(), + new ChromaFloatConverter(), + }, + TypeInfoResolver = ChromaJsonResolver.Instance, + }; + + // The generated metadata of the type: serialization without reflection, which trimming and NativeAOT keep. + internal static JsonTypeInfo TypeInfo(this JsonSerializerOptions options) + => (JsonTypeInfo)options.GetTypeInfo(typeof(T)); + + public static JsonSerializerOptions DeserializerOptions(ChromaMetadataValues metadataValues) + => metadataValues == ChromaMetadataValues.Exact ? ExactDeserializerJsonSerializerOptions : DeserializerJsonSerializerOptions; + + public static async Task Get(this ChromaHttpClient httpClient, string endpoint, RequestQueryParams queryParams, CancellationToken cancellationToken) + { + using var httpRequestMessage = new HttpRequestMessage(HttpMethod.Get, requestUri: httpClient.CreateUri(ValidateAndPrepareEndpoint(endpoint, queryParams))); + return await Send(httpClient, httpRequestMessage, cancellationToken); } - public static async Task Get(this HttpClient httpClient, string endpoint, RequestQueryParams queryParams) + public static async Task Get(this ChromaHttpClient httpClient, string endpoint, RequestQueryParams queryParams, CancellationToken cancellationToken) { - using var httpRequestMessage = new HttpRequestMessage(HttpMethod.Get, requestUri: ValidateAndPrepareEndpoint(endpoint, queryParams)); - await Send(httpClient, httpRequestMessage); + using var httpRequestMessage = new HttpRequestMessage(HttpMethod.Get, requestUri: httpClient.CreateUri(ValidateAndPrepareEndpoint(endpoint, queryParams))); + await Send(httpClient, httpRequestMessage, cancellationToken); } - public static async Task Post(this HttpClient httpClient, string endpoint, TInput? input, RequestQueryParams queryParams) + public static async Task Post(this ChromaHttpClient httpClient, string endpoint, TInput? input, RequestQueryParams queryParams, CancellationToken cancellationToken) { - using var content = new StringContent(JsonSerializer.Serialize(input, PostJsonSerializerOptions) ?? string.Empty, Encoding.UTF8, "application/json"); - using var httpRequestMessage = new HttpRequestMessage(HttpMethod.Post, requestUri: ValidateAndPrepareEndpoint(endpoint, queryParams)) + using var content = new StringContent(JsonSerializer.Serialize(input, PostJsonSerializerOptions.TypeInfo()), Encoding.UTF8, "application/json"); + using var httpRequestMessage = new HttpRequestMessage(HttpMethod.Post, requestUri: httpClient.CreateUri(ValidateAndPrepareEndpoint(endpoint, queryParams))) { Content = content, Headers = { Accept = { new MediaTypeWithQualityHeaderValue("application/json") } } }; - return await Send(httpClient, httpRequestMessage); + return await Send(httpClient, httpRequestMessage, cancellationToken); } - public static async Task Post(this HttpClient httpClient, string endpoint, TInput? input, RequestQueryParams queryParams) + public static async Task Post(this ChromaHttpClient httpClient, string endpoint, TInput? input, RequestQueryParams queryParams, CancellationToken cancellationToken) { - using var content = new StringContent(JsonSerializer.Serialize(input, PostJsonSerializerOptions) ?? string.Empty, Encoding.UTF8, "application/json"); - using var httpRequestMessage = new HttpRequestMessage(HttpMethod.Post, requestUri: ValidateAndPrepareEndpoint(endpoint, queryParams)) + using var content = new StringContent(JsonSerializer.Serialize(input, PostJsonSerializerOptions.TypeInfo()), Encoding.UTF8, "application/json"); + using var httpRequestMessage = new HttpRequestMessage(HttpMethod.Post, requestUri: httpClient.CreateUri(ValidateAndPrepareEndpoint(endpoint, queryParams))) { Content = content, Headers = { Accept = { new MediaTypeWithQualityHeaderValue("application/json") } } }; - await Send(httpClient, httpRequestMessage); + await Send(httpClient, httpRequestMessage, cancellationToken); } - public static async Task Put(this HttpClient httpClient, string endpoint, TInput? input, RequestQueryParams queryParams) + public static async Task Put(this ChromaHttpClient httpClient, string endpoint, TInput? input, RequestQueryParams queryParams, CancellationToken cancellationToken) { - using var content = new StringContent(JsonSerializer.Serialize(input, PostJsonSerializerOptions) ?? string.Empty, Encoding.UTF8, "application/json"); - using var httpRequestMessage = new HttpRequestMessage(HttpMethod.Put, requestUri: ValidateAndPrepareEndpoint(endpoint, queryParams)) + using var content = new StringContent(JsonSerializer.Serialize(input, PostJsonSerializerOptions.TypeInfo()), Encoding.UTF8, "application/json"); + using var httpRequestMessage = new HttpRequestMessage(HttpMethod.Put, requestUri: httpClient.CreateUri(ValidateAndPrepareEndpoint(endpoint, queryParams))) { Content = content, Headers = { Accept = { new MediaTypeWithQualityHeaderValue("application/json") } } }; - return await Send(httpClient, httpRequestMessage); + return await Send(httpClient, httpRequestMessage, cancellationToken); } - public static async Task Put(this HttpClient httpClient, string endpoint, TInput? input, RequestQueryParams queryParams) + public static async Task Put(this ChromaHttpClient httpClient, string endpoint, TInput? input, RequestQueryParams queryParams, CancellationToken cancellationToken) { - using var content = new StringContent(JsonSerializer.Serialize(input, PostJsonSerializerOptions) ?? string.Empty, Encoding.UTF8, "application/json"); - using var httpRequestMessage = new HttpRequestMessage(HttpMethod.Put, requestUri: ValidateAndPrepareEndpoint(endpoint, queryParams)) + using var content = new StringContent(JsonSerializer.Serialize(input, PostJsonSerializerOptions.TypeInfo()), Encoding.UTF8, "application/json"); + using var httpRequestMessage = new HttpRequestMessage(HttpMethod.Put, requestUri: httpClient.CreateUri(ValidateAndPrepareEndpoint(endpoint, queryParams))) { Content = content, Headers = { Accept = { new MediaTypeWithQualityHeaderValue("application/json") } } }; - await Send(httpClient, httpRequestMessage); + await Send(httpClient, httpRequestMessage, cancellationToken); } - public static async Task Delete(this HttpClient httpClient, string endpoint, RequestQueryParams queryParams) + public static async Task Patch(this ChromaHttpClient httpClient, string endpoint, TInput? input, RequestQueryParams queryParams, CancellationToken cancellationToken) { - using var httpRequestMessage = new HttpRequestMessage(HttpMethod.Delete, requestUri: ValidateAndPrepareEndpoint(endpoint, queryParams)); - return await Send(httpClient, httpRequestMessage); + using var content = new StringContent(JsonSerializer.Serialize(input, PostJsonSerializerOptions.TypeInfo()), Encoding.UTF8, "application/json"); + // HttpMethod.Patch is missing from netstandard2.0. + using var httpRequestMessage = new HttpRequestMessage(new HttpMethod("PATCH"), requestUri: httpClient.CreateUri(ValidateAndPrepareEndpoint(endpoint, queryParams))) + { + Content = content, + Headers = { Accept = { new MediaTypeWithQualityHeaderValue("application/json") } } + }; + await Send(httpClient, httpRequestMessage, cancellationToken); } - public static async Task Delete(this HttpClient httpClient, string endpoint, RequestQueryParams queryParams) + + public static async Task Delete(this ChromaHttpClient httpClient, string endpoint, RequestQueryParams queryParams, CancellationToken cancellationToken) { - using var httpRequestMessage = new HttpRequestMessage(HttpMethod.Delete, requestUri: ValidateAndPrepareEndpoint(endpoint, queryParams)); - await Send(httpClient, httpRequestMessage); + using var httpRequestMessage = new HttpRequestMessage(HttpMethod.Delete, requestUri: httpClient.CreateUri(ValidateAndPrepareEndpoint(endpoint, queryParams))); + return await Send(httpClient, httpRequestMessage, cancellationToken); + } + public static async Task Delete(this ChromaHttpClient httpClient, string endpoint, RequestQueryParams queryParams, CancellationToken cancellationToken) + { + using var httpRequestMessage = new HttpRequestMessage(HttpMethod.Delete, requestUri: httpClient.CreateUri(ValidateAndPrepareEndpoint(endpoint, queryParams))); + await Send(httpClient, httpRequestMessage, cancellationToken); } - private static async Task Send(HttpClient httpClient, HttpRequestMessage httpRequestMessage) + private static async Task Send(ChromaHttpClient httpClient, HttpRequestMessage httpRequestMessage, CancellationToken cancellationToken) { try { - using var httpResponseMessage = await httpClient.SendAsync(httpRequestMessage); + using var httpResponseMessage = await httpClient.SendAsync(httpRequestMessage, cancellationToken); return (int)httpResponseMessage.StatusCode switch { - >= 200 and <= 299 => JsonSerializer.Deserialize(await httpResponseMessage.Content.ReadAsStringAsync(), DeserializerJsonSerializerOptions)!, - _ => throw await HandleErrorStatusCode(httpResponseMessage), + >= 200 and <= 299 => JsonSerializer.Deserialize(await httpResponseMessage.Content.ReadAsStringAsync(), httpClient.DeserializerOptions.TypeInfo())!, + _ => throw await HandleErrorStatusCode(httpRequestMessage, httpResponseMessage), }; } - catch (Exception ex) when (ex is not ChromaException) + catch (Exception ex) when (IsRequestFailure(ex, cancellationToken)) { throw new ChromaException(ex.Message, ex); } } - private static async Task Send(HttpClient httpClient, HttpRequestMessage httpRequestMessage) + private static async Task Send(ChromaHttpClient httpClient, HttpRequestMessage httpRequestMessage, CancellationToken cancellationToken) { try { - using var httpResponseMessage = await httpClient.SendAsync(httpRequestMessage); + using var httpResponseMessage = await httpClient.SendAsync(httpRequestMessage, cancellationToken); switch ((int)httpResponseMessage.StatusCode) { case >= 200 and <= 299: return; default: - throw await HandleErrorStatusCode(httpResponseMessage); + throw await HandleErrorStatusCode(httpRequestMessage, httpResponseMessage); }; } - catch (Exception ex) when (ex is not ChromaException) + catch (Exception ex) when (IsRequestFailure(ex, cancellationToken)) { throw new ChromaException(ex.Message, ex); } } - private static async Task HandleErrorStatusCode(HttpResponseMessage httpResponseMessage) + // What went wrong with the request itself: the network, an answer that is not the expected JSON, or a timeout, which is a + // cancellation the caller did not ask for. Any other exception, like an assembly that does not load or a disposed HttpClient, + // is not about Chroma and goes as it is. + private static bool IsRequestFailure(Exception exception, CancellationToken cancellationToken) + => exception is HttpRequestException or JsonException or IOException and not (FileLoadException or FileNotFoundException) + || exception is OperationCanceledException && !cancellationToken.IsCancellationRequested; + + private static async Task HandleErrorStatusCode(HttpRequestMessage httpRequestMessage, HttpResponseMessage httpResponseMessage) { - return httpResponseMessage.StatusCode switch + var errorMessageBody = await httpResponseMessage.Content.ReadAsStringAsync(); + var (message, errorType) = ParseErrorMessageBody(errorMessageBody); + // A bare 404 or 405 usually means that this version of Chroma does not have the endpoint: name the request. + if ((int)httpResponseMessage.StatusCode is 404 or 405 && message is null or "Not Found" or "Method Not Allowed") { - HttpStatusCode.BadRequest -#if NETSTANDARD2_0 - or (HttpStatusCode)422 -#else - or HttpStatusCode.UnprocessableContent -#endif - or HttpStatusCode.InternalServerError - => new ChromaException(ParseErrorMessageBody(await httpResponseMessage.Content.ReadAsStringAsync())), - _ => new ChromaException($"Unexpected status code: {httpResponseMessage.StatusCode}."), - }; + // Works around KD-41 (docs/COMPATIBILITY.md) + return new ChromaException($"{message ?? httpResponseMessage.StatusCode.ToString()}: {httpRequestMessage.Method} {httpRequestMessage.RequestUri?.AbsolutePath}") { StatusCode = httpResponseMessage.StatusCode, ErrorType = errorType }; + } + return new ChromaException(message ?? $"Unexpected status code: {httpResponseMessage.StatusCode}.") { StatusCode = httpResponseMessage.StatusCode, ErrorType = errorType }; } - private static string? ParseErrorMessageBody(string? errorMessageBody) + private static (string? Message, string? ErrorType) ParseErrorMessageBody(string? errorMessageBody) { if (errorMessageBody is null or []) { - return null; + return (null, null); } try { - var deserialized = JsonSerializer.Deserialize(errorMessageBody, DeserializerJsonSerializerOptions)!; -#if NETSTANDARD2_0 + var deserialized = JsonSerializer.Deserialize(errorMessageBody, DeserializerJsonSerializerOptions.TypeInfo())!; + // v1 API: {"error": "ValueError('...')"}: the kind and the message in one string. +#if !NET var match = ParseErrorMessageBodyRegex.Match(deserialized?.Error ?? string.Empty); #else var match = ParseErrorMessageBodyRegex().Match(deserialized?.Error ?? string.Empty); #endif - - return match.Success - ? match.Groups["errorMessage"]?.Value + // v2 API: {"error": "NotFoundError", "message": "..."}; the kind also without a message. + var errorType = match.Success + ? (match.Groups["errorType"].Value is { Length: > 0 } type ? type : null) + : (deserialized?.Error is { Length: > 0 } error ? error : null); + // Errors of the 0.x servers outside the API, like a 500: {"detail": "..."}. + var message = deserialized?.Message is { Length: > 0 } text ? text + : DetailMessage(deserialized?.Detail) is { Length: > 0 } detail ? detail + : match.Success ? match.Groups["errorMessage"]?.Value : $"Couldn't identify the error message: {errorMessageBody}"; + return (message, errorType); } catch { - return $"Couldn't parse the incoming error message body: {errorMessageBody}"; + return ($"Couldn't parse the incoming error message body: {errorMessageBody}", null); + } + } + + // The detail of an error of the 0.x servers: a string, or the validation errors of FastAPI, each as "body.n_results: Input should be ...". + private static string? DetailMessage(JsonElement? detail) => detail switch + { + { ValueKind: JsonValueKind.String } text => text.GetString(), + { ValueKind: JsonValueKind.Array } errors => string.Join("; ", errors.EnumerateArray().Select(ValidationErrorMessage)), + _ => null, + }; + + private static string ValidationErrorMessage(JsonElement error) + { + if (error.ValueKind != JsonValueKind.Object || !error.TryGetProperty("msg", out var msg) || msg.ValueKind != JsonValueKind.String) + { + return error.GetRawText(); } + return error.TryGetProperty("loc", out var loc) && loc.ValueKind == JsonValueKind.Array + ? $"{string.Join(".", loc.EnumerateArray().Select(x => x.ValueKind == JsonValueKind.String ? x.GetString() : x.GetRawText()))}: {msg.GetString()}" + : msg.GetString()!; } private static List PrepareQueryParams(string input) { -#if NETSTANDARD2_0 - return ParseErrorMessageBodyRegex.Matches(input) +#if !NET + return PrepareQueryParamsRegex.Matches(input) .Cast() .Select(x => x.Value) .ToList(); @@ -181,14 +255,14 @@ private static List PrepareQueryParams(string input) #endif } -#if NETSTANDARD2_0 - private static readonly Regex ParseErrorMessageBodyRegex = new(@"\('(?.*)'\)", RegexOptions.IgnoreCase | RegexOptions.Singleline | RegexOptions.CultureInvariant | RegexOptions.Compiled); +#if !NET + private static readonly Regex ParseErrorMessageBodyRegex = new(@"^(?\w*)\('(?.*)'\)", RegexOptions.IgnoreCase | RegexOptions.Singleline | RegexOptions.CultureInvariant | RegexOptions.Compiled); #else - [GeneratedRegex(@"\('(?.*)'\)", RegexOptions.IgnoreCase | RegexOptions.Singleline | RegexOptions.CultureInvariant)] + [GeneratedRegex(@"^(?\w*)\('(?.*)'\)", RegexOptions.IgnoreCase | RegexOptions.Singleline | RegexOptions.CultureInvariant)] private static partial Regex ParseErrorMessageBodyRegex(); #endif -#if NETSTANDARD2_0 +#if !NET private static readonly Regex PrepareQueryParamsRegex = new(@"{[a-zA-Z0-9\-_]+}", RegexOptions.CultureInvariant | RegexOptions.Compiled); #else [GeneratedRegex(@"{[a-zA-Z0-9\-_]+}", RegexOptions.CultureInvariant)] @@ -208,6 +282,13 @@ private static string FormatRequestUri(string endpoint, RequestQueryParams query var formattedEndpoint = endpoint; foreach (var (key, value) in queryParams) { + // Uri drops "." and ".." from a path, escaped or not, and an empty name leaves the parent: GetCollectionAsync("..") + // would read the database as a collection. Chroma takes none of them as a name. + if (value is "" or "." or "..") + { + throw new ArgumentException($"\"{value}\" cannot be the {key.Trim('{', '}')} of a request: the path of the URL drops it."); + } + ChromaRequestChecks.NoLoneSurrogates(value, $"The {key.Trim('{', '}')} of the request"); var urlEncodedQueryParam = Uri.EscapeDataString(value); formattedEndpoint = formattedEndpoint.Replace(key, urlEncodedQueryParam); } diff --git a/ChromaDB.Client/Common/MurmurHash3.cs b/ChromaDB.Client/Common/MurmurHash3.cs new file mode 100644 index 0000000..bfdba33 --- /dev/null +++ b/ChromaDB.Client/Common/MurmurHash3.cs @@ -0,0 +1,54 @@ +using System.Text; + +namespace ChromaDB.Client.Common; + +// MurmurHash3 x86_32 of the UTF-8 bytes of a string, as mmh3.hash(text, seed) of Python returns it: a signed 32-bit integer. +internal static class MurmurHash3 +{ + public static int Hash32(string text, uint seed = 0) + { + var data = Encoding.UTF8.GetBytes(text); + const uint c1 = 0xcc9e2d51; + const uint c2 = 0x1b873593; + var h1 = seed; + var blocks = data.Length / 4; + for (var i = 0; i < blocks; i++) + { + var k1 = (uint)(data[i * 4] | data[i * 4 + 1] << 8 | data[i * 4 + 2] << 16 | data[i * 4 + 3] << 24); + k1 *= c1; + k1 = RotateLeft(k1, 15); + k1 *= c2; + h1 ^= k1; + h1 = RotateLeft(h1, 13); + h1 = h1 * 5 + 0xe6546b64; + } + var tail = blocks * 4; + uint k = 0; + switch (data.Length & 3) + { + case 3: + k ^= (uint)data[tail + 2] << 16; + goto case 2; + case 2: + k ^= (uint)data[tail + 1] << 8; + goto case 1; + case 1: + k ^= data[tail]; + k *= c1; + k = RotateLeft(k, 15); + k *= c2; + h1 ^= k; + break; + } + h1 ^= (uint)data.Length; + h1 ^= h1 >> 16; + h1 *= 0x85ebca6b; + h1 ^= h1 >> 13; + h1 *= 0xc2b2ae35; + h1 ^= h1 >> 16; + return unchecked((int)h1); + } + + static uint RotateLeft(uint x, int r) + => x << r | x >> (32 - r); +} diff --git a/ChromaDB.Client/Common/ObjectToExactTypesJsonConverter.cs b/ChromaDB.Client/Common/ObjectToExactTypesJsonConverter.cs new file mode 100644 index 0000000..545bd54 --- /dev/null +++ b/ChromaDB.Client/Common/ObjectToExactTypesJsonConverter.cs @@ -0,0 +1,45 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace ChromaDB.Client.Common; + +// Reads the values of metadata as they are: strings stay strings, lists become lists of the same types as the single values, and +// a sparse vector, {"#type": "sparse_vector", ...}, becomes a ChromaSparseVector. +internal class ObjectToExactTypesJsonConverter : JsonConverter +{ + public override object Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + => ReadValue(ref reader)!; + + static object? ReadValue(ref Utf8JsonReader reader) + { + switch (reader.TokenType) + { + case JsonTokenType.True: + return true; + case JsonTokenType.False: + return false; + case JsonTokenType.Number when reader.TryGetInt64(out var l): + return l; + case JsonTokenType.Number: + return ChromaNumbers.ReadDouble(ref reader); + case JsonTokenType.String: + return reader.GetString()!; + case JsonTokenType.Null: + return null; + case JsonTokenType.StartArray: + var list = new List(); + while (reader.Read() && reader.TokenType != JsonTokenType.EndArray) + { + list.Add(ReadValue(ref reader)!); + } + return list; + case JsonTokenType.StartObject: + var element = JsonDocument.ParseValue(ref reader).RootElement.Clone(); + return (object?)ChromaSparseVectorConverter.FromTaggedJson(element) ?? element; + default: + return JsonDocument.ParseValue(ref reader).RootElement.Clone(); + } + } + + public override void Write(Utf8JsonWriter writer, object objectToWrite, JsonSerializerOptions options) => throw new NotSupportedException(); +} diff --git a/ChromaDB.Client/Common/ObjectToInferredTypesJsonConverter.cs b/ChromaDB.Client/Common/ObjectToInferredTypesJsonConverter.cs index bb0bfeb..3037f77 100644 --- a/ChromaDB.Client/Common/ObjectToInferredTypesJsonConverter.cs +++ b/ChromaDB.Client/Common/ObjectToInferredTypesJsonConverter.cs @@ -11,7 +11,7 @@ public override object Read(ref Utf8JsonReader reader, Type typeToConvert, JsonS JsonTokenType.True => true, JsonTokenType.False => false, JsonTokenType.Number when reader.TryGetInt64(out var l) => l, - JsonTokenType.Number => reader.GetDouble(), + JsonTokenType.Number => ChromaNumbers.ReadDouble(ref reader), JsonTokenType.String when reader.TryGetDateTime(out var datetime) => datetime, JsonTokenType.String => reader.GetString()!, _ => JsonDocument.ParseValue(ref reader).RootElement.Clone() diff --git a/ChromaDB.Client/Common/RegexMinimumLength.cs b/ChromaDB.Client/Common/RegexMinimumLength.cs new file mode 100644 index 0000000..59bf897 --- /dev/null +++ b/ChromaDB.Client/Common/RegexMinimumLength.cs @@ -0,0 +1,515 @@ +namespace ChromaDB.Client.Common; + +// The minimum length of a match of a pattern, in characters, as the Rust crate regex reads it, the one of Chroma: its syntax at +// https://docs.rs/regex/1.12.3/regex/#syntax, read as its parser regex-syntax 0.8 reads it. Anchors and word boundaries match no character, +// a class or an escaped character one, and a repetition the minimum of its count. Null for a pattern that the client cannot read with that +// syntax: Chroma rejects it too. +internal static class RegexMinimumLength +{ + // The deepest nesting of groups and classes that regex-syntax accepts: a pattern nested deeper is one that Chroma rejects. + private const int MaxDepth = 250; + + private static readonly string[] AsciiClasses = + ["alnum", "alpha", "ascii", "blank", "cntrl", "digit", "graph", "lower", "print", "punct", "space", "upper", "word", "xdigit"]; + + public static int? Of(string pattern) + { + var parser = new Parser(pattern); + try + { + var length = parser.Alternation(); + return parser.AtEnd ? length : null; + } + catch (FormatException) + { + return null; + } + } + + private sealed class Parser(string pattern) + { + private int _position; + private int _depth; + // The flag x: blanks and # comments do not count, until the end of the group that sets it. + private bool _verbose; + + public bool AtEnd + { + get + { + SkipBlanks(); + return _position == pattern.Length; + } + } + + private char? Peek() => _position < pattern.Length ? pattern[_position] : null; + + private char Next() => _position < pattern.Length ? pattern[_position++] : throw new FormatException(); + + private bool Take(string text) + { + if (string.CompareOrdinal(pattern, _position, text, 0, text.Length) != 0) + { + return false; + } + _position += text.Length; + return true; + } + + private void SkipBlanks() + { + while (_verbose && Peek() is { } c) + { + if (char.IsWhiteSpace(c)) + { + _position++; + } + else if (c == '#') + { + while (Peek() is { } comment && comment != '\n') + { + _position++; + } + } + else + { + return; + } + } + } + + // The next character after the one at the position, past the blanks in verbose mode. + private char? PeekAfter() + { + var position = _position; + _position++; + SkipBlanks(); + var c = Peek(); + _position = position; + return c; + } + + private void Enter() + { + if (++_depth > MaxDepth) + { + throw new FormatException(); + } + } + + // a|b|c: the shortest branch, and an empty branch matches no character. + public int Alternation() + { + var length = Concatenation(); + while (Peek() == '|') + { + _position++; + length = Math.Min(length, Concatenation()); + } + return length; + } + + private int Concatenation() + { + var length = 0; + while (true) + { + SkipBlanks(); + if (Peek() is not { } c || c == '|' || c == ')') + { + return length; + } + length = Add(length, Repetition()); + } + } + + private int Repetition() + { + var length = Atom(); + while (true) + { + SkipBlanks(); + switch (Peek()) + { + case '*' or '?': + _position++; + length = 0; + break; + case '+': + _position++; + break; + case '{': + _position++; + SkipBlanks(); + var minimum = Number(); + if (Peek() == ',') + { + _position++; + SkipBlanks(); + if (Peek() != '}' && Number() < minimum) + { + throw new FormatException(); + } + } + if (Next() != '}') + { + throw new FormatException(); + } + length = Multiply(length, minimum); + // The lazy form of a count may come after blanks in verbose mode. + SkipBlanks(); + break; + default: + return length; + } + // The lazy form, like x*?, has the same minimum. After *, + and ? it is right after them: in verbose mode x+ ? is (x+)?. + if (Peek() == '?') + { + _position++; + } + } + } + + // A count: blanks around it are allowed also out of verbose mode, and between its digits in verbose mode. + private int Number() + { + while (Peek() is { } blank && char.IsWhiteSpace(blank)) + { + _position++; + } + var number = 0L; + var digits = 0; + while (Peek() is >= '0' and <= '9' and var digit) + { + number = Math.Min(number * 10 + (digit - '0'), (long)uint.MaxValue + 1); + digits++; + _position++; + SkipBlanks(); + } + while (Peek() is { } blank && char.IsWhiteSpace(blank)) + { + _position++; + SkipBlanks(); + } + return digits > 0 && number <= uint.MaxValue ? (int)Math.Min(number, int.MaxValue) : throw new FormatException(); + } + + private int Atom() + { + var c = Next(); + switch (c) + { + case '^' or '$': + return 0; + case '(': + return Group(); + case '[': + Class(); + return 1; + case '\\': + return Escape(inClass: false); + case '*' or '+' or '?' or '{' or ')' or '|': + throw new FormatException(); + default: + // A surrogate pair is one character. + if (char.IsHighSurrogate(c) && Peek() is { } low && char.IsLowSurrogate(low)) + { + _position++; + } + return 1; + } + } + + // (exp), (?:exp), (?Pexp), (?exp), (?flags) and (?flags:exp). The flags of a group last until its end; (?flags) sets + // them for the rest of the group around it. + private int Group() + { + Enter(); + var verbose = _verbose; + SkipBlanks(); + if (Take("?=") || Take("?!") || Take("?<=") || Take("?') + { + throw new FormatException(); + } + } + else if (Take("?")) + { + var flags = _verbose; + var negated = false; + var dangling = false; + var start = _position; + while (Peek() is { } flag && flag != ':' && flag != ')') + { + if (flag == '-' && !negated) + { + negated = true; + } + else if (flag == 'x') + { + flags = !negated; + } + else if ("imsRUu".IndexOf(flag) < 0) + { + throw new FormatException(); + } + dangling = flag == '-'; + _position++; + } + // A negation with no flag after it is not a group, nor (?) with no flag. + var empty = _position == start; + if (dangling || (Next() == ')' && empty)) + { + throw new FormatException(); + } + if (pattern[_position - 1] == ')') + { + // (?flags) matches no character. + _verbose = flags; + _depth--; + return 0; + } + _verbose = flags; + } + var length = Alternation(); + _verbose = verbose; + _depth--; + return Next() == ')' ? length : throw new FormatException(); + } + + // [xyz], [^xyz], ranges, [[:alpha:]], nested classes, &&, -- and ~~: a class matches one character, so only its end matters. + private void Class() + { + Enter(); + SkipBlanks(); + if (Take("^")) + { + SkipBlanks(); + } + // Any '-' at the start is a character of the class; a ']' right after '[' or '[^' too. + var dashes = false; + while (Take("-")) + { + dashes = true; + SkipBlanks(); + } + if (!dashes && Take("]")) + { + SkipBlanks(); + } + while (true) + { + SkipBlanks(); + switch (Next()) + { + case ']': + _depth--; + return; + case '[': + if (!AsciiClass()) + { + Class(); + } + break; + case '&' when Peek() == '&': + case '-' when Peek() == '-': + case '~' when Peek() == '~': + _position++; + break; + default: + _position--; + Range(); + break; + } + } + } + + // [:alpha:] and [:^alpha:] inside a class, with one of the names of the syntax; otherwise the '[' opens a nested class. + private bool AsciiClass() + { + var start = _position; + if (Take(":")) + { + Take("^"); + var name = _position; + while (Peek() is { } c && c != ':') + { + _position++; + } + if (Array.IndexOf(AsciiClasses, pattern.Substring(name, _position - name)) >= 0 && Take(":]")) + { + return true; + } + } + _position = start; + return false; + } + + // A character or an escape, or a range of two: a '-' before ']' or another '-' is not a range. + private void Range() + { + Item(); + SkipBlanks(); + if (Peek() == '-' && PeekAfter() is not (']' or '-')) + { + _position++; + SkipBlanks(); + Item(); + } + } + + private void Item() + { + if (Next() == '\\') + { + Escape(inClass: true); + } + } + + private int Escape(bool inClass) + { + var c = Next(); + switch (c) + { + case 'x': + Hex(2); + return 1; + case 'u': + Hex(4); + return 1; + case 'U': + Hex(8); + return 1; + case 'p' or 'P': + SkipBlanks(); + if (Take("{")) + { + while (true) + { + SkipBlanks(); + if (Next() == '}') + { + break; + } + } + } + else if (Next() == '\\') + { + throw new FormatException(); + } + return 1; + case 'd' or 'D' or 'w' or 'W' or 's' or 'S': + return 1; + // Any ASCII character other than a letter, a digit, '<' and '>' is a character when escaped, like \* or \[ or an escaped + // blank. + case < (char)128 when !char.IsLetterOrDigit(c) && c != '<' && c != '>': + return 1; + case 'a' or 'f' or 't' or 'n' or 'r' or 'v': + return 1; + // Assertions: no character, and not in a class. + case 'A' or 'z' or 'B' or '<' or '>' when !inClass: + return 0; + case 'b' when !inClass: + SpecialWordBoundary(); + return 0; + default: + // Octal escapes and backreferences, and the letters of no escape, which Chroma does not read either. + throw new FormatException(); + } + } + + // \b{start}, \b{end}, \b{start-half} and \b{end-half}. A '{' that does not start with a letter or '-' is a count of \b, as \b{2}. + private void SpecialWordBoundary() + { + var start = _position; + if (!Take("{")) + { + return; + } + SkipBlanks(); + if (Peek() is not (>= 'a' and <= 'z') and not (>= 'A' and <= 'Z') and not '-') + { + _position = start; + return; + } + var name = new System.Text.StringBuilder(); + while (Peek() is { } c && c is (>= 'a' and <= 'z') or (>= 'A' and <= 'Z') or '-') + { + name.Append(c); + _position++; + SkipBlanks(); + } + if (Next() != '}' || name.ToString() is not ("start" or "end" or "start-half" or "end-half")) + { + throw new FormatException(); + } + } + + // \x7F, \x{10FFFF} and the same for \u and \U: a code point that is not a surrogate. + private void Hex(int digits) + { + SkipBlanks(); + var value = 0L; + if (Take("{")) + { + var count = 0; + while (true) + { + SkipBlanks(); + var c = Next(); + if (c == '}') + { + break; + } + value = Math.Min(value * 16 + HexValue(c), 0x110000); + count++; + } + if (count == 0) + { + throw new FormatException(); + } + } + else + { + for (var i = 0; i < digits; i++) + { + if (i > 0) + { + SkipBlanks(); + } + value = Math.Min(value * 16 + HexValue(Next()), 0x110000); + } + } + if (value > 0x10FFFF || value is >= 0xD800 and <= 0xDFFF) + { + throw new FormatException(); + } + } + + private static int HexValue(char c) + => c switch + { + >= '0' and <= '9' => c - '0', + >= 'a' and <= 'f' => c - 'a' + 10, + >= 'A' and <= 'F' => c - 'A' + 10, + _ => throw new FormatException(), + }; + + private static int Add(int a, int b) => a > int.MaxValue - b ? int.MaxValue : a + b; + + private static int Multiply(int a, int b) => a == 0 || b == 0 ? 0 : a > int.MaxValue / b ? int.MaxValue : a * b; + } +} diff --git a/ChromaDB.Client/Common/SnowballEnglishStemmer.cs b/ChromaDB.Client/Common/SnowballEnglishStemmer.cs new file mode 100644 index 0000000..2f16522 --- /dev/null +++ b/ChromaDB.Client/Common/SnowballEnglishStemmer.cs @@ -0,0 +1,853 @@ +// Copyright (c) 2001, Dr Martin Porter +// Copyright (c) 2004,2005, Richard Boulton +// Copyright (c) 2013, Yoshiki Shibukawa +// Copyright (c) 2006-2025, Olly Betts +// All rights reserved. +// +// Redistribution and use in source and binary forms, with or without +// modification, are permitted provided that the following conditions +// are met: +// +// 1. Redistributions of source code must retain the above copyright notice, +// this list of conditions and the following disclaimer. +// 2. Redistributions in binary form must reproduce the above copyright notice, +// this list of conditions and the following disclaimer in the documentation +// and/or other materials provided with the distribution. +// 3. Neither the name of the Snowball project nor the names of its contributors +// may be used to endorse or promote products derived from this software +// without specific prior written permission. +// +// THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +// ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +// WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +// DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR +// ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +// (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +// LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON +// ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +// (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +// SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +// Port of english_stemmer.py of the Python package snowballstemmer 3.1.1. + +namespace ChromaDB.Client.Common; + +// The Snowball English (Porter2) stemmer, with the stems of snowballstemmer 3.1.1 (generated from english.sbl by Snowball 3.1.1). +// It keeps no state between calls, so one instance can stem from several threads at once. +internal sealed class SnowballEnglishStemmer +{ + // stemWord. + public string Stem(string word) + { + var program = new EnglishProgram(word); + program.Stem(); + return program.GetCurrent(); + } + + // EnglishStemmer of english_stemmer.py: the routines in the same order, with the same names of the variables. + sealed class EnglishProgram : SnowballProgram + { + // g_aeo, g_v, g_v_WXY and g_valid_LI. + const string GAeo = "aeo"; + const string GV = "aeiouy"; + const string GVWXY = "Yaeiouwxy"; + const string GValidLI = "cdeghkmnrt"; + + bool _yFound; + int _p2; + int _p1; + + public EnglishProgram(string word) + : base(word) + { } + + bool Prelude() + { + _yFound = false; + var v1 = cursor; + bra = cursor; + if (cursor != limit && current[cursor] == '\'') + { + cursor++; + ket = cursor; + SliceDel(); + } + cursor = v1; + var v2 = cursor; + bra = cursor; + if (cursor != limit && current[cursor] == 'y') + { + cursor++; + ket = cursor; + SliceFrom("Y"); + _yFound = true; + } + cursor = v2; + var v3 = cursor; + while (true) + { + var v4 = cursor; + var found = false; + while (true) + { + var v5 = cursor; + if (InGrouping(GV)) + { + bra = cursor; + if (cursor != limit && current[cursor] == 'y') + { + cursor++; + ket = cursor; + cursor = v5; + found = true; + break; + } + } + cursor = v5; + if (cursor >= limit) + { + break; + } + cursor++; + } + if (!found) + { + cursor = v4; + break; + } + SliceFrom("Y"); + _yFound = true; + } + cursor = v3; + return true; + } + + bool MarkRegions() + { + _p1 = limit; + _p2 = limit; + var v1 = cursor; + do + { + var v2 = cursor; + if (FindAmong(A0) == 0) + { + cursor = v2; + if (!GoOutGrouping(GV)) + { + break; + } + cursor++; + if (!GoInGrouping(GV)) + { + break; + } + cursor++; + } + _p1 = cursor; + if (!GoOutGrouping(GV)) + { + break; + } + cursor++; + if (!GoInGrouping(GV)) + { + break; + } + cursor++; + _p2 = cursor; + } + while (false); + cursor = v1; + return true; + } + + bool Shortv() + { + var v1 = limit - cursor; + if (OutGroupingB(GVWXY) && InGroupingB(GV) && OutGroupingB(GV)) + { + return true; + } + cursor = limit - v1; + if (OutGroupingB(GV) && InGroupingB(GV) && cursor <= limitBackward) + { + return true; + } + cursor = limit - v1; + if (!EqSB("past")) + { + return false; + } + return true; + } + + bool R1() => _p1 <= cursor; + + bool R2() => _p2 <= cursor; + + bool Step1a() + { + var v1 = limit - cursor; + ket = cursor; + if (FindAmongB(A1) == 0) + { + cursor = limit - v1; + } + else + { + bra = cursor; + SliceDel(); + } + ket = cursor; + var amongVar = FindAmongB(A2); + if (amongVar == 0) + { + return false; + } + bra = cursor; + if (amongVar == 1) + { + SliceFrom("ss"); + } + else if (amongVar == 2) + { + var v2 = limit - cursor; + if (cursor - 2 >= limitBackward) + { + cursor -= 2; + SliceFrom("i"); + } + else + { + cursor = limit - v2; + SliceFrom("ie"); + } + } + else if (amongVar == 3) + { + if (cursor <= limitBackward) + { + return false; + } + cursor--; + if (!GoOutGroupingB(GV)) + { + return false; + } + cursor--; + SliceDel(); + } + return true; + } + + bool Step1b() + { + ket = cursor; + var amongVar = FindAmongB(A5); + bra = cursor; + var v1 = limit - cursor; + // The first alternative: a break goes to the second one. + do + { + if (amongVar == 1) + { + var v2 = limit - cursor; + if (R1()) + { + var v3 = limit - cursor; + if (FindAmongB(A3) == 0 || cursor > limitBackward) + { + cursor = limit - v3; + SliceFrom("ee"); + } + } + cursor = limit - v2; + } + else if (amongVar == 2) + { + break; + } + else if (amongVar == 3) + { + amongVar = FindAmongB(A4); + if (amongVar == 0) + { + break; + } + if (amongVar == 1) + { + var v4 = limit - cursor; + if (!OutGroupingB(GV)) + { + break; + } + if (cursor > limitBackward) + { + break; + } + cursor = limit - v4; + bra = cursor; + SliceFrom("ie"); + } + else + { + if (cursor > limitBackward) + { + break; + } + } + } + return true; + } + while (false); + cursor = limit - v1; + var v5 = limit - cursor; + if (!GoOutGroupingB(GV)) + { + return false; + } + cursor--; + cursor = limit - v5; + SliceDel(); + ket = cursor; + bra = cursor; + var v6 = limit - cursor; + amongVar = FindAmongB(A6); + if (amongVar == 1) + { + SliceFrom("e"); + return false; + } + else if (amongVar == 2) + { + var v7 = limit - cursor; + if (InGroupingB(GAeo) && cursor <= limitBackward) + { + return false; + } + cursor = limit - v7; + } + else + { + if (cursor != _p1) + { + return false; + } + var v8 = limit - cursor; + if (!Shortv()) + { + return false; + } + cursor = limit - v8; + SliceFrom("e"); + return false; + } + cursor = limit - v6; + ket = cursor; + if (cursor <= limitBackward) + { + return false; + } + cursor--; + bra = cursor; + SliceDel(); + return true; + } + + bool Step1c() + { + ket = cursor; + if (cursor > limitBackward && current[cursor - 1] == 'y') + { + cursor--; + } + else + { + if (cursor <= limitBackward || current[cursor - 1] != 'Y') + { + return false; + } + cursor--; + } + bra = cursor; + if (!OutGroupingB(GV)) + { + return false; + } + if (cursor <= limitBackward) + { + return false; + } + SliceFrom("i"); + return true; + } + + bool Step2() + { + ket = cursor; + var amongVar = FindAmongB(A7); + if (amongVar == 0) + { + return false; + } + bra = cursor; + if (!R1()) + { + return false; + } + switch (amongVar) + { + case 1: + SliceFrom("tion"); + break; + case 2: + SliceFrom("ence"); + break; + case 3: + SliceFrom("ance"); + break; + case 4: + SliceFrom("able"); + break; + case 5: + SliceFrom("ent"); + break; + case 6: + SliceFrom("ize"); + break; + case 7: + SliceFrom("ate"); + break; + case 8: + SliceFrom("al"); + break; + case 9: + SliceFrom("ful"); + break; + case 10: + SliceFrom("ous"); + break; + case 11: + SliceFrom("ive"); + break; + case 12: + SliceFrom("ble"); + break; + case 13: + SliceFrom("og"); + break; + case 14: + if (cursor <= limitBackward || current[cursor - 1] != 'l') + { + return false; + } + cursor--; + SliceFrom("og"); + break; + case 15: + SliceFrom("less"); + break; + default: + if (!InGroupingB(GValidLI)) + { + return false; + } + SliceDel(); + break; + } + return true; + } + + bool Step3() + { + ket = cursor; + var amongVar = FindAmongB(A8); + if (amongVar == 0) + { + return false; + } + bra = cursor; + if (!R1()) + { + return false; + } + switch (amongVar) + { + case 1: + SliceFrom("tion"); + break; + case 2: + SliceFrom("ate"); + break; + case 3: + SliceFrom("al"); + break; + case 4: + SliceFrom("ic"); + break; + case 5: + SliceDel(); + break; + default: + if (!R2()) + { + return false; + } + SliceDel(); + break; + } + return true; + } + + bool Step4() + { + ket = cursor; + var amongVar = FindAmongB(A9); + if (amongVar == 0) + { + return false; + } + bra = cursor; + if (!R2()) + { + return false; + } + if (amongVar == 1) + { + SliceDel(); + } + else + { + if (cursor > limitBackward && current[cursor - 1] == 's') + { + cursor--; + } + else + { + if (cursor <= limitBackward || current[cursor - 1] != 't') + { + return false; + } + cursor--; + } + SliceDel(); + } + return true; + } + + bool Step5() + { + ket = cursor; + var amongVar = FindAmongB(A10); + if (amongVar == 0) + { + return false; + } + bra = cursor; + if (amongVar == 1) + { + if (!R2()) + { + if (!R1()) + { + return false; + } + var v1 = limit - cursor; + if (Shortv()) + { + return false; + } + cursor = limit - v1; + } + SliceDel(); + } + else + { + if (!R2()) + { + return false; + } + if (cursor <= limitBackward || current[cursor - 1] != 'l') + { + return false; + } + cursor--; + SliceDel(); + } + return true; + } + + bool Exception1() + { + bra = cursor; + var amongVar = FindAmong(A11); + if (amongVar == 0) + { + return false; + } + ket = cursor; + if (cursor < limit) + { + return false; + } + if (amongVar > 0) + { + SliceFrom(As11[amongVar - 1]); + } + return true; + } + + bool Postlude() + { + if (!_yFound) + { + return false; + } + while (true) + { + var v1 = cursor; + var found = false; + while (true) + { + var v2 = cursor; + bra = cursor; + if (cursor != limit && current[cursor] == 'Y') + { + cursor++; + ket = cursor; + cursor = v2; + found = true; + break; + } + cursor = v2; + if (cursor >= limit) + { + break; + } + cursor++; + } + if (!found) + { + cursor = v1; + break; + } + SliceFrom("y"); + } + return true; + } + + // _stem. + public bool Stem() + { + var v1 = cursor; + if (Exception1()) + { + return true; + } + cursor = v1; + // Words shorter than 3 characters stay as they are. + if (cursor + 3 > limit) + { + return true; + } + Prelude(); + MarkRegions(); + limitBackward = cursor; + cursor = limit; + var v2 = limit - cursor; + Step1a(); + cursor = limit - v2; + var v3 = limit - cursor; + Step1b(); + cursor = limit - v3; + var v4 = limit - cursor; + Step1c(); + cursor = limit - v4; + var v5 = limit - cursor; + Step2(); + cursor = limit - v5; + var v6 = limit - cursor; + Step3(); + cursor = limit - v6; + var v7 = limit - cursor; + Step4(); + cursor = limit - v7; + var v8 = limit - cursor; + Step5(); + cursor = limit - v8; + cursor = limitBackward; + var v9 = cursor; + Postlude(); + cursor = v9; + return true; + } + + static readonly Among[] A0 = + [ + new("arsen", -1, -1), + new("commun", -1, -1), + new("emerg", -1, -1), + new("gener", -1, -1), + new("inter", -1, -1), + new("later", -1, -1), + new("organ", -1, -1), + new("past", -1, -1), + new("univers", -1, -1), + ]; + + static readonly Among[] A1 = + [ + new("'", -1, 1), + new("'s'", 0, 1), + new("'s", -1, 1), + ]; + + static readonly Among[] A2 = + [ + new("ied", -1, 2), + new("s", -1, 3), + new("ies", 1, 2), + new("sses", 1, 1), + new("ss", 1, -1), + new("us", 1, -1), + ]; + + static readonly Among[] A3 = + [ + new("succ", -1, 1), + new("proc", -1, 1), + new("exc", -1, 1), + ]; + + static readonly Among[] A4 = + [ + new("even", -1, 2), + new("cann", -1, 2), + new("inn", -1, 2), + new("earr", -1, 2), + new("herr", -1, 2), + new("out", -1, 2), + new("y", -1, 1), + ]; + + static readonly Among[] A5 = + [ + new("", -1, -1), + new("ed", 0, 2), + new("eed", 1, 1), + new("ing", 0, 3), + new("edly", 0, 2), + new("eedly", 4, 1), + new("ingly", 0, 2), + ]; + + static readonly Among[] A6 = + [ + new("", -1, 3), + new("bb", 0, 2), + new("dd", 0, 2), + new("ff", 0, 2), + new("gg", 0, 2), + new("bl", 0, 1), + new("mm", 0, 2), + new("nn", 0, 2), + new("pp", 0, 2), + new("rr", 0, 2), + new("at", 0, 1), + new("tt", 0, 2), + new("iz", 0, 1), + ]; + + static readonly Among[] A7 = + [ + new("anci", -1, 3), + new("enci", -1, 2), + new("ogi", -1, 14), + new("li", -1, 16), + new("bli", 3, 12), + new("abli", 4, 4), + new("alli", 3, 8), + new("fulli", 3, 9), + new("lessli", 3, 15), + new("ousli", 3, 10), + new("entli", 3, 5), + new("aliti", -1, 8), + new("biliti", -1, 12), + new("iviti", -1, 11), + new("tional", -1, 1), + new("ational", 14, 7), + new("alism", -1, 8), + new("ation", -1, 7), + new("ization", 17, 6), + new("izer", -1, 6), + new("ator", -1, 7), + new("iveness", -1, 11), + new("fulness", -1, 9), + new("ousness", -1, 10), + new("ogist", -1, 13), + ]; + + static readonly Among[] A8 = + [ + new("icate", -1, 4), + new("ative", -1, 6), + new("alize", -1, 3), + new("iciti", -1, 4), + new("ical", -1, 4), + new("tional", -1, 1), + new("ational", 5, 2), + new("ful", -1, 5), + new("ness", -1, 5), + ]; + + static readonly Among[] A9 = + [ + new("ic", -1, 1), + new("ance", -1, 1), + new("ence", -1, 1), + new("able", -1, 1), + new("ible", -1, 1), + new("ate", -1, 1), + new("ive", -1, 1), + new("ize", -1, 1), + new("iti", -1, 1), + new("al", -1, 1), + new("ism", -1, 1), + new("ion", -1, 2), + new("er", -1, 1), + new("ous", -1, 1), + new("ant", -1, 1), + new("ent", -1, 1), + new("ment", 15, 1), + new("ement", 16, 1), + ]; + + static readonly Among[] A10 = + [ + new("e", -1, 1), + new("l", -1, 2), + ]; + + static readonly Among[] A11 = + [ + new("andes", -1, -1), + new("atlas", -1, -1), + new("bias", -1, -1), + new("cosmos", -1, -1), + new("early", -1, 6), + new("gently", -1, 4), + new("howe", -1, -1), + new("idly", -1, 3), + new("news", -1, -1), + new("only", -1, 7), + new("singly", -1, 8), + new("skies", -1, 2), + new("skis", -1, 1), + new("sky", -1, -1), + new("ugly", -1, 5), + ]; + + static readonly string[] As11 = ["ski", "sky", "idl", "gentl", "ugli", "earli", "onli", "singl"]; + } +} diff --git a/ChromaDB.Client/Common/SnowballProgram.cs b/ChromaDB.Client/Common/SnowballProgram.cs new file mode 100644 index 0000000..d047b7b --- /dev/null +++ b/ChromaDB.Client/Common/SnowballProgram.cs @@ -0,0 +1,414 @@ +// Copyright (c) 2001, Dr Martin Porter +// Copyright (c) 2004,2005, Richard Boulton +// Copyright (c) 2013, Yoshiki Shibukawa +// Copyright (c) 2006-2025, Olly Betts +// All rights reserved. +// +// Redistribution and use in source and binary forms, with or without +// modification, are permitted provided that the following conditions +// are met: +// +// 1. Redistributions of source code must retain the above copyright notice, +// this list of conditions and the following disclaimer. +// 2. Redistributions in binary form must reproduce the above copyright notice, +// this list of conditions and the following disclaimer in the documentation +// and/or other materials provided with the distribution. +// 3. Neither the name of the Snowball project nor the names of its contributors +// may be used to endorse or promote products derived from this software +// without specific prior written permission. +// +// THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +// ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +// WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +// DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR +// ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +// (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +// LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON +// ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +// (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +// SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +// Port of basestemmer.py and among.py of the Python package snowballstemmer 3.1.1, the base of the port of english_stemmer.py. + +using System.Diagnostics; + +namespace ChromaDB.Client.Common; + +// The state of one stemming and the operations of the generated code on it, as BaseStemmer of snowballstemmer, with only the +// operations the English stemmer uses. An instance stems one word: SnowballEnglishStemmer creates one for each call. +internal abstract class SnowballProgram +{ + // The word as code points, not UTF-16 code units: Python indexes a string by code point, so a character outside the BMP is one + // position, as in snowballstemmer. A surrogate without its pair stays a code point of its own, as in a Python string. + protected int[] current; + int _length; + protected int cursor; + protected int limit; + protected int limitBackward; + protected int bra; + protected int ket; + + // set_current. + protected SnowballProgram(string value) + { + // The English replacements never make the word longer than it was (the "e" of step 1b comes after the removal of a longer + // suffix), and ReplaceS grows the array if one did. + current = new int[value.Length]; + for (var i = 0; i < value.Length; i++) + { + var c = value[i]; + if (char.IsHighSurrogate(c) && i + 1 < value.Length && char.IsLowSurrogate(value[i + 1])) + { + current[_length++] = char.ConvertToUtf32(c, value[i + 1]); + i++; + } + else + { + current[_length++] = c; + } + } + cursor = 0; + limit = _length; + limitBackward = 0; + bra = cursor; + ket = limit; + } + + // get_current. + public string GetCurrent() + { + var chars = new char[_length * 2]; + var n = 0; + for (var i = 0; i < _length; i++) + { + var c = current[i]; + if (c > char.MaxValue) + { + c -= 0x10000; + chars[n++] = (char)(0xD800 + (c >> 10)); + chars[n++] = (char)(0xDC00 + (c & 0x3FF)); + } + else + { + chars[n++] = (char)c; + } + } + return new string(chars, 0, n); + } + + // The groupings are strings of characters, as "aeo" in the Python code. + static bool InSet(string s, int c) => c <= char.MaxValue && s.IndexOf((char)c) >= 0; + + protected bool InGrouping(string s) + { + if (cursor >= limit) + { + return false; + } + if (!InSet(s, current[cursor])) + { + return false; + } + cursor++; + return true; + } + + protected bool GoInGrouping(string s) + { + while (cursor < limit) + { + if (!InSet(s, current[cursor])) + { + return true; + } + cursor++; + } + return false; + } + + protected bool InGroupingB(string s) + { + if (cursor <= limitBackward) + { + return false; + } + if (!InSet(s, current[cursor - 1])) + { + return false; + } + cursor--; + return true; + } + + protected bool GoOutGrouping(string s) + { + while (cursor < limit) + { + if (InSet(s, current[cursor])) + { + return true; + } + cursor++; + } + return false; + } + + protected bool OutGroupingB(string s) + { + if (cursor <= limitBackward) + { + return false; + } + if (!InSet(s, current[cursor - 1])) + { + cursor--; + return true; + } + return false; + } + + protected bool GoOutGroupingB(string s) + { + while (cursor > limitBackward) + { + if (InSet(s, current[cursor - 1])) + { + return true; + } + cursor--; + } + return false; + } + + // eq_s_b: current.endswith(s, limit_backward, cursor). + protected bool EqSB(string s) + { + if (cursor - limitBackward < s.Length) + { + return false; + } + var start = cursor - s.Length; + for (var i = 0; i < s.Length; i++) + { + if (current[start + i] != s[i]) + { + return false; + } + } + cursor -= s.Length; + return true; + } + + protected int FindAmong(Among[] v) + { + var i = 0; + var j = v.Length; + + var c = cursor; + var l = limit; + + var commonI = 0; + var commonJ = 0; + + var firstKeyInspected = false; + + while (true) + { + var k = i + ((j - i) >> 1); + var diff = 0; + var common = Math.Min(commonI, commonJ); // smaller + var w = v[k]; + for (var i2 = common; i2 < w.S.Length; i2++) + { + if (c + common == l) + { + diff = -1; + break; + } + diff = current[c + common] - w.S[i2]; + if (diff != 0) + { + break; + } + common++; + } + if (diff < 0) + { + j = k; + commonJ = common; + } + else + { + i = k; + commonI = common; + } + if (j - i <= 1) + { + if (i > 0) + { + break; // v->s has been inspected + } + if (j == i) + { + break; // only one item in v + } + // - but now we need to go round once more to get + // v->s inspected. This looks messy, but is actually + // the optimal approach. + if (firstKeyInspected) + { + break; + } + firstKeyInspected = true; + } + } + while (true) + { + var w = v[i]; + if (commonI >= w.S.Length) + { + // The English tables have no among functions: w.method is always None. + cursor = c + w.S.Length; + return w.Result; + } + i = w.SubstringI; + if (i < 0) + { + return 0; + } + } + } + + // find_among_b is for backwards processing. Same comments apply. + protected int FindAmongB(Among[] v) + { + var i = 0; + var j = v.Length; + + var c = cursor; + var lb = limitBackward; + + var commonI = 0; + var commonJ = 0; + + var firstKeyInspected = false; + + while (true) + { + var k = i + ((j - i) >> 1); + var diff = 0; + var common = Math.Min(commonI, commonJ); + var w = v[k]; + for (var i2 = w.S.Length - 1 - common; i2 >= 0; i2--) + { + if (c - common == lb) + { + diff = -1; + break; + } + diff = current[c - 1 - common] - w.S[i2]; + if (diff != 0) + { + break; + } + common++; + } + if (diff < 0) + { + j = k; + commonJ = common; + } + else + { + i = k; + commonI = common; + } + if (j - i <= 1) + { + if (i > 0) + { + break; + } + if (j == i) + { + break; + } + if (firstKeyInspected) + { + break; + } + firstKeyInspected = true; + } + } + while (true) + { + var w = v[i]; + if (commonI >= w.S.Length) + { + cursor = c - w.S.Length; + return w.Result; + } + i = w.SubstringI; + if (i < 0) + { + return 0; + } + } + } + + // Replaces the characters between cBra and cKet by the characters of s. + int ReplaceS(int cBra, int cKet, string s) + { + var adjustment = s.Length - (cKet - cBra); + var length = _length + adjustment; + if (length > current.Length) + { + Array.Resize(ref current, length); + } + // Array.Copy copies overlapping ranges as if through a temporary array. + Array.Copy(current, cKet, current, cKet + adjustment, _length - cKet); + for (var i = 0; i < s.Length; i++) + { + current[cBra + i] = s[i]; + } + _length = length; + limit += adjustment; + if (cursor >= cKet) + { + cursor += adjustment; + } + else if (cursor > cBra) + { + cursor = cBra; + } + return adjustment; + } + + protected void SliceFrom(string s) + { + Debug.Assert(bra >= 0); + Debug.Assert(bra <= ket); + Debug.Assert(ket <= limit); + Debug.Assert(limit <= _length); + ReplaceS(bra, ket, s); + ket = bra + s.Length; + } + + protected void SliceDel() => SliceFrom(""); + + // An entry of a table of the generated code: the string, the index of the longest entry that is a substring of it (-1 when + // there is none) and the result of the lookup. + internal sealed class Among + { + public Among(string s, int substringI, int result) + { + S = s; + SubstringI = substringI; + Result = result; + } + + public string S { get; } + public int SubstringI { get; } + public int Result { get; } + } +} diff --git a/ChromaDB.Client/Models/Requests/AttachFunctionRequest.cs b/ChromaDB.Client/Models/Requests/AttachFunctionRequest.cs new file mode 100644 index 0000000..145dc01 --- /dev/null +++ b/ChromaDB.Client/Models/Requests/AttachFunctionRequest.cs @@ -0,0 +1,19 @@ +using System.Text.Json.Serialization; + +namespace ChromaDB.Client.Models.Requests; + +internal class AttachFunctionRequest +{ + [JsonPropertyName("name")] + public required string Name { get; init; } + + // The name of the function, like "statistics". + [JsonPropertyName("function_id")] + public required string FunctionId { get; init; } + + [JsonPropertyName("output_collection")] + public required string OutputCollection { get; init; } + + [JsonPropertyName("params")] + public IReadOnlyDictionary? Params { get; init; } +} diff --git a/ChromaDB.Client/Models/Requests/CollectionAddRequest.cs b/ChromaDB.Client/Models/Requests/CollectionAddRequest.cs index 4d3dbaa..30dfbfe 100644 --- a/ChromaDB.Client/Models/Requests/CollectionAddRequest.cs +++ b/ChromaDB.Client/Models/Requests/CollectionAddRequest.cs @@ -5,14 +5,19 @@ namespace ChromaDB.Client.Models.Requests; internal class CollectionAddRequest { [JsonPropertyName("ids")] - public required List Ids { get; init; } + public required IReadOnlyList Ids { get; init; } [JsonPropertyName("embeddings")] - public List>? Embeddings { get; init; } + public Common.ChromaEmbeddings? Embeddings { get; init; } [JsonPropertyName("metadatas")] - public List>? Metadatas { get; init; } + public IReadOnlyList?>? Metadatas { get; init; } [JsonPropertyName("documents")] - public List? Documents { get; init; } + public IReadOnlyList? Documents { get; init; } + + // Left out when null, so that the requests without URIs stay the same for the servers that do not know them. + [JsonPropertyName("uris")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public IReadOnlyList? Uris { get; init; } } diff --git a/ChromaDB.Client/Models/Requests/CollectionConfigurationUpdateRequest.cs b/ChromaDB.Client/Models/Requests/CollectionConfigurationUpdateRequest.cs new file mode 100644 index 0000000..a4b5dad --- /dev/null +++ b/ChromaDB.Client/Models/Requests/CollectionConfigurationUpdateRequest.cs @@ -0,0 +1,19 @@ +using System.Text.Json.Serialization; + +namespace ChromaDB.Client.Models.Requests; + +// new_configuration of a modify: the settings of ChromaCollectionConfigurationUpdate, with the embedding function as Chroma writes it. +internal class CollectionConfigurationUpdateRequest +{ + [JsonPropertyName("hnsw")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public ChromaHnswConfigurationUpdate? Hnsw { get; init; } + + [JsonPropertyName("spann")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public ChromaSpannConfigurationUpdate? Spann { get; init; } + + [JsonPropertyName("embedding_function")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public Dictionary? EmbeddingFunction { get; init; } +} diff --git a/ChromaDB.Client/Models/Requests/CollectionDeleteRequest.cs b/ChromaDB.Client/Models/Requests/CollectionDeleteRequest.cs index 707ec6e..a702069 100644 --- a/ChromaDB.Client/Models/Requests/CollectionDeleteRequest.cs +++ b/ChromaDB.Client/Models/Requests/CollectionDeleteRequest.cs @@ -4,12 +4,19 @@ namespace ChromaDB.Client.Models.Requests; internal class CollectionDeleteRequest { + // Null to delete by the filters only. [JsonPropertyName("ids")] - public required List Ids { get; init; } + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public IReadOnlyList? Ids { get; init; } [JsonPropertyName("where")] public Dictionary? Where { get; init; } [JsonPropertyName("where_document")] public Dictionary? WhereDocument { get; init; } + + // Sent only when set: the earlier servers do not know it. + [JsonPropertyName("limit")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public int? Limit { get; init; } } diff --git a/ChromaDB.Client/Models/Requests/CollectionGetRequest.cs b/ChromaDB.Client/Models/Requests/CollectionGetRequest.cs index 44ef224..b8adbe8 100644 --- a/ChromaDB.Client/Models/Requests/CollectionGetRequest.cs +++ b/ChromaDB.Client/Models/Requests/CollectionGetRequest.cs @@ -5,7 +5,7 @@ namespace ChromaDB.Client.Models.Requests; internal class CollectionGetRequest { [JsonPropertyName("ids")] - public List? Ids { get; init; } + public IReadOnlyList? Ids { get; init; } [JsonPropertyName("where")] public Dictionary? Where { get; init; } diff --git a/ChromaDB.Client/Models/Requests/CollectionModifyRequest.cs b/ChromaDB.Client/Models/Requests/CollectionModifyRequest.cs index cd632b2..3e649f7 100644 --- a/ChromaDB.Client/Models/Requests/CollectionModifyRequest.cs +++ b/ChromaDB.Client/Models/Requests/CollectionModifyRequest.cs @@ -4,9 +4,14 @@ namespace ChromaDB.Client.Models.Requests; internal class CollectionModifyRequest { - [JsonPropertyName("name")] + [JsonPropertyName("new_name")] public string? Name { get; init; } - [JsonPropertyName("metadata")] - public Dictionary? Metadata { get; init; } + [JsonPropertyName("new_metadata")] + public IReadOnlyDictionary? Metadata { get; init; } + + // Sent only when set: the request stays as before for the servers that do not know it. + [JsonPropertyName("new_configuration")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public CollectionConfigurationUpdateRequest? Configuration { get; init; } } diff --git a/ChromaDB.Client/Models/Requests/CollectionQueryRequest.cs b/ChromaDB.Client/Models/Requests/CollectionQueryRequest.cs index 1eb26ab..a1b1ea2 100644 --- a/ChromaDB.Client/Models/Requests/CollectionQueryRequest.cs +++ b/ChromaDB.Client/Models/Requests/CollectionQueryRequest.cs @@ -5,7 +5,7 @@ namespace ChromaDB.Client.Models.Requests; internal class CollectionQueryRequest { [JsonPropertyName("query_embeddings")] - public required List> QueryEmbeddings { get; init; } + public required IReadOnlyList> QueryEmbeddings { get; init; } [JsonPropertyName("n_results")] public int NResults { get; init; } = 10; @@ -18,4 +18,8 @@ internal class CollectionQueryRequest [JsonPropertyName("include")] public required List Include { get; init; } + + [JsonPropertyName("ids")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public IReadOnlyList? Ids { get; init; } } diff --git a/ChromaDB.Client/Models/Requests/CollectionSearchRequest.cs b/ChromaDB.Client/Models/Requests/CollectionSearchRequest.cs new file mode 100644 index 0000000..b020f4a --- /dev/null +++ b/ChromaDB.Client/Models/Requests/CollectionSearchRequest.cs @@ -0,0 +1,51 @@ +using System.Text.Json.Serialization; + +namespace ChromaDB.Client.Models.Requests; + +// The request of the Search API, as the Python client of Chroma sends it: "filter" is the where clause itself, not the +// {"where_clause": ...} of the OpenAPI description of Chroma 1.5.9, which Chroma Cloud rejects with 422. +internal class CollectionSearchRequest +{ + [JsonPropertyName("searches")] + public required List Searches { get; init; } + + // Sent only when set. + [JsonPropertyName("read_level")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? ReadLevel { get; init; } +} + +internal class CollectionSearchPayload +{ + [JsonPropertyName("filter")] + public Dictionary? Filter { get; init; } + + [JsonPropertyName("rank")] + public Dictionary? Rank { get; init; } + + // {} when there is no grouping, as in the Python client. + [JsonPropertyName("group_by")] + public required Dictionary GroupBy { get; init; } + + [JsonPropertyName("limit")] + public required CollectionSearchLimit Limit { get; init; } + + [JsonPropertyName("select")] + public required CollectionSearchSelect Select { get; init; } +} + +internal class CollectionSearchLimit +{ + [JsonPropertyName("offset")] + public int Offset { get; init; } + + [JsonPropertyName("limit")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public int? Limit { get; init; } +} + +internal class CollectionSearchSelect +{ + [JsonPropertyName("keys")] + public required List Keys { get; init; } +} diff --git a/ChromaDB.Client/Models/Requests/CollectionUpdateRequest.cs b/ChromaDB.Client/Models/Requests/CollectionUpdateRequest.cs index 3cf18ba..f9afe29 100644 --- a/ChromaDB.Client/Models/Requests/CollectionUpdateRequest.cs +++ b/ChromaDB.Client/Models/Requests/CollectionUpdateRequest.cs @@ -5,14 +5,19 @@ namespace ChromaDB.Client.Models.Requests; internal class CollectionUpdateRequest { [JsonPropertyName("ids")] - public required List Ids { get; init; } + public required IReadOnlyList Ids { get; init; } [JsonPropertyName("embeddings")] - public List>? Embeddings { get; init; } + public Common.ChromaEmbeddings? Embeddings { get; init; } [JsonPropertyName("metadatas")] - public List>? Metadatas { get; init; } + public IReadOnlyList?>? Metadatas { get; init; } [JsonPropertyName("documents")] - public List? Documents { get; init; } + public IReadOnlyList? Documents { get; init; } + + // Left out when null, so that the requests without URIs stay the same for the servers that do not know them. + [JsonPropertyName("uris")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public IReadOnlyList? Uris { get; init; } } diff --git a/ChromaDB.Client/Models/Requests/CollectionUpsertRequest.cs b/ChromaDB.Client/Models/Requests/CollectionUpsertRequest.cs index 9e89633..9056d4f 100644 --- a/ChromaDB.Client/Models/Requests/CollectionUpsertRequest.cs +++ b/ChromaDB.Client/Models/Requests/CollectionUpsertRequest.cs @@ -5,14 +5,19 @@ namespace ChromaDB.Client.Models.Requests; internal class CollectionUpsertRequest { [JsonPropertyName("ids")] - public required List Ids { get; init; } + public required IReadOnlyList Ids { get; init; } [JsonPropertyName("embeddings")] - public List>? Embeddings { get; init; } + public Common.ChromaEmbeddings? Embeddings { get; init; } [JsonPropertyName("metadatas")] - public List>? Metadatas { get; init; } + public IReadOnlyList?>? Metadatas { get; init; } [JsonPropertyName("documents")] - public List? Documents { get; init; } + public IReadOnlyList? Documents { get; init; } + + // Left out when null, so that the requests without URIs stay the same for the servers that do not know them. + [JsonPropertyName("uris")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public IReadOnlyList? Uris { get; init; } } diff --git a/ChromaDB.Client/Models/Requests/CreateCollectionRequest.cs b/ChromaDB.Client/Models/Requests/CreateCollectionRequest.cs index 9b88d86..7689d3a 100644 --- a/ChromaDB.Client/Models/Requests/CreateCollectionRequest.cs +++ b/ChromaDB.Client/Models/Requests/CreateCollectionRequest.cs @@ -1,6 +1,10 @@ -namespace ChromaDB.Client.Models.Requests; +using System.Text.Json.Serialization; + +namespace ChromaDB.Client.Models.Requests; internal class CreateCollectionRequest : GetOrCreateCollectionRequestBase { - protected override bool GetOrCreate { get; } = false; + // The name of the base property: without it, the serialization writes the override as GetOrCreate too. + [JsonPropertyName("get_or_create")] + public override bool GetOrCreate { get; } = false; } diff --git a/ChromaDB.Client/Models/Requests/CreateDatabaseRequest.cs b/ChromaDB.Client/Models/Requests/CreateDatabaseRequest.cs new file mode 100644 index 0000000..9b4c113 --- /dev/null +++ b/ChromaDB.Client/Models/Requests/CreateDatabaseRequest.cs @@ -0,0 +1,9 @@ +using System.Text.Json.Serialization; + +namespace ChromaDB.Client.Models.Requests; + +internal class CreateDatabaseRequest +{ + [JsonPropertyName("name")] + public required string Name { get; init; } +} diff --git a/ChromaDB.Client/Models/Requests/CreateTenantRequest.cs b/ChromaDB.Client/Models/Requests/CreateTenantRequest.cs new file mode 100644 index 0000000..37a2340 --- /dev/null +++ b/ChromaDB.Client/Models/Requests/CreateTenantRequest.cs @@ -0,0 +1,9 @@ +using System.Text.Json.Serialization; + +namespace ChromaDB.Client.Models.Requests; + +internal class CreateTenantRequest +{ + [JsonPropertyName("name")] + public required string Name { get; init; } +} diff --git a/ChromaDB.Client/Models/Requests/DetachFunctionRequest.cs b/ChromaDB.Client/Models/Requests/DetachFunctionRequest.cs new file mode 100644 index 0000000..8d4d7f6 --- /dev/null +++ b/ChromaDB.Client/Models/Requests/DetachFunctionRequest.cs @@ -0,0 +1,9 @@ +using System.Text.Json.Serialization; + +namespace ChromaDB.Client.Models.Requests; + +internal class DetachFunctionRequest +{ + [JsonPropertyName("delete_output")] + public bool DeleteOutput { get; init; } +} diff --git a/ChromaDB.Client/Models/Requests/ForkCollectionRequest.cs b/ChromaDB.Client/Models/Requests/ForkCollectionRequest.cs new file mode 100644 index 0000000..9c106f5 --- /dev/null +++ b/ChromaDB.Client/Models/Requests/ForkCollectionRequest.cs @@ -0,0 +1,9 @@ +using System.Text.Json.Serialization; + +namespace ChromaDB.Client.Models.Requests; + +internal class ForkCollectionRequest +{ + [JsonPropertyName("new_name")] + public required string NewName { get; init; } +} diff --git a/ChromaDB.Client/Models/Requests/GetOrCreateCollectionRequest.cs b/ChromaDB.Client/Models/Requests/GetOrCreateCollectionRequest.cs index b97cce4..bca3e72 100644 --- a/ChromaDB.Client/Models/Requests/GetOrCreateCollectionRequest.cs +++ b/ChromaDB.Client/Models/Requests/GetOrCreateCollectionRequest.cs @@ -1,6 +1,10 @@ -namespace ChromaDB.Client.Models.Requests; +using System.Text.Json.Serialization; + +namespace ChromaDB.Client.Models.Requests; internal class GetOrCreateCollectionRequest : GetOrCreateCollectionRequestBase { - protected override bool GetOrCreate { get; } = true; + // The name of the base property: without it, the serialization writes the override as GetOrCreate too. + [JsonPropertyName("get_or_create")] + public override bool GetOrCreate { get; } = true; } diff --git a/ChromaDB.Client/Models/Requests/GetOrCreateCollectionRequestBase.cs b/ChromaDB.Client/Models/Requests/GetOrCreateCollectionRequestBase.cs index 0c248d4..4f8371a 100644 --- a/ChromaDB.Client/Models/Requests/GetOrCreateCollectionRequestBase.cs +++ b/ChromaDB.Client/Models/Requests/GetOrCreateCollectionRequestBase.cs @@ -8,9 +8,19 @@ internal abstract class GetOrCreateCollectionRequestBase public required string Name { get; init; } [JsonPropertyName("metadata")] - public Dictionary? Metadata { get; init; } + public IReadOnlyDictionary? Metadata { get; init; } - [JsonInclude] + // Sent only when set: the request stays as before for the servers that do not know it. + [JsonPropertyName("configuration")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public Dictionary? Configuration { get; init; } + + // Sent only when set: the request stays as before for the servers that do not know it. + [JsonPropertyName("schema")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public Dictionary? Schema { get; init; } + + // Public, so that the generated serialization sees it. [JsonPropertyName("get_or_create")] - protected abstract bool GetOrCreate { get; } + public abstract bool GetOrCreate { get; } } diff --git a/ChromaDB.Client/Models/Requests/UpdateTenantRequest.cs b/ChromaDB.Client/Models/Requests/UpdateTenantRequest.cs new file mode 100644 index 0000000..e6f8e43 --- /dev/null +++ b/ChromaDB.Client/Models/Requests/UpdateTenantRequest.cs @@ -0,0 +1,9 @@ +using System.Text.Json.Serialization; + +namespace ChromaDB.Client.Models.Requests; + +internal class UpdateTenantRequest +{ + [JsonPropertyName("resource_name")] + public required string ResourceName { get; init; } +} diff --git a/ChromaDB.Client/Models/Responses/AttachFunctionResponse.cs b/ChromaDB.Client/Models/Responses/AttachFunctionResponse.cs new file mode 100644 index 0000000..99ff597 --- /dev/null +++ b/ChromaDB.Client/Models/Responses/AttachFunctionResponse.cs @@ -0,0 +1,15 @@ +using System.Text.Json.Serialization; + +namespace ChromaDB.Client.Models.Responses; + +internal class AttachFunctionResponse +{ + [JsonPropertyName("attached_function")] + public ChromaAttachedFunction AttachedFunction { get; init; } = null!; + + // False when a function with the same name was already attached: the request is idempotent. Null when the answer does not + // say it, which AttachFunction reads as true, like the Python client of Chroma; a default value of an init property would + // not survive the generated deserialization, which sets every init property. + [JsonPropertyName("created")] + public bool? Created { get; init; } +} diff --git a/ChromaDB.Client/Models/Responses/CollectionEntriesGetResponse.cs b/ChromaDB.Client/Models/Responses/CollectionEntriesGetResponse.cs index cfb4506..131759b 100644 --- a/ChromaDB.Client/Models/Responses/CollectionEntriesGetResponse.cs +++ b/ChromaDB.Client/Models/Responses/CollectionEntriesGetResponse.cs @@ -2,23 +2,21 @@ namespace ChromaDB.Client.Models.Responses; +// Only "ids" is always there: Chroma 0.4.10 to 0.4.15 do not send "uris", and the fields that are not included can be missing. internal class CollectionEntriesGetResponse { [JsonPropertyName("ids")] public required List Ids { get; init; } [JsonPropertyName("embeddings")] - public required List?> Embeddings { get; init; } + public List?>? Embeddings { get; init; } [JsonPropertyName("metadatas")] - public required List?> Metadatas { get; init; } + public List?>? Metadatas { get; init; } [JsonPropertyName("documents")] - public required List Documents { get; init; } + public List? Documents { get; init; } [JsonPropertyName("uris")] - public required List?> Uris { get; init; } - - [JsonPropertyName("data")] - public required dynamic? Data { get; init; } + public List? Uris { get; init; } } diff --git a/ChromaDB.Client/Models/Responses/CollectionEntriesQueryResponse.cs b/ChromaDB.Client/Models/Responses/CollectionEntriesQueryResponse.cs index c5880b0..b76f2c7 100644 --- a/ChromaDB.Client/Models/Responses/CollectionEntriesQueryResponse.cs +++ b/ChromaDB.Client/Models/Responses/CollectionEntriesQueryResponse.cs @@ -2,26 +2,24 @@ namespace ChromaDB.Client.Models.Responses; +// Only "ids" is always there: Chroma 0.4.10 to 0.4.15 do not send "uris", and the fields that are not included can be missing. internal class CollectionEntriesQueryResponse { [JsonPropertyName("ids")] public required List> Ids { get; init; } [JsonPropertyName("distances")] - public required List> Distances { get; init; } + public List>? Distances { get; init; } [JsonPropertyName("metadatas")] - public required List>>? Metadatas { get; init; } + public List>>? Metadatas { get; init; } [JsonPropertyName("embeddings")] - public required List>>? Embeddings { get; init; } + public List>>? Embeddings { get; init; } [JsonPropertyName("documents")] - public required List>? Documents { get; init; } + public List>? Documents { get; init; } [JsonPropertyName("uris")] - public required List>>? Uris { get; init; } - - [JsonPropertyName("data")] - public required dynamic? Data { get; init; } + public List>? Uris { get; init; } } diff --git a/ChromaDB.Client/Models/Responses/CollectionSearchResponse.cs b/ChromaDB.Client/Models/Responses/CollectionSearchResponse.cs new file mode 100644 index 0000000..39e101b --- /dev/null +++ b/ChromaDB.Client/Models/Responses/CollectionSearchResponse.cs @@ -0,0 +1,22 @@ +using System.Text.Json.Serialization; + +namespace ChromaDB.Client.Models.Responses; + +// One list for each search; a field that was not selected is null for the whole search. +internal class CollectionSearchResponse +{ + [JsonPropertyName("ids")] + public required List> Ids { get; init; } + + [JsonPropertyName("documents")] + public List?>? Documents { get; init; } + + [JsonPropertyName("embeddings")] + public List?>?>? Embeddings { get; init; } + + [JsonPropertyName("metadatas")] + public List?>?>? Metadatas { get; init; } + + [JsonPropertyName("scores")] + public List?>? Scores { get; init; } +} diff --git a/ChromaDB.Client/Models/Responses/DetachFunctionResponse.cs b/ChromaDB.Client/Models/Responses/DetachFunctionResponse.cs new file mode 100644 index 0000000..739ed4f --- /dev/null +++ b/ChromaDB.Client/Models/Responses/DetachFunctionResponse.cs @@ -0,0 +1,9 @@ +using System.Text.Json.Serialization; + +namespace ChromaDB.Client.Models.Responses; + +internal class DetachFunctionResponse +{ + [JsonPropertyName("success")] + public bool Success { get; init; } +} diff --git a/ChromaDB.Client/Models/Responses/ForkCountResponse.cs b/ChromaDB.Client/Models/Responses/ForkCountResponse.cs new file mode 100644 index 0000000..ffd3db3 --- /dev/null +++ b/ChromaDB.Client/Models/Responses/ForkCountResponse.cs @@ -0,0 +1,9 @@ +using System.Text.Json.Serialization; + +namespace ChromaDB.Client.Models.Responses; + +internal class ForkCountResponse +{ + [JsonPropertyName("count")] + public int Count { get; init; } +} diff --git a/ChromaDB.Client/Models/Responses/GeneralError.cs b/ChromaDB.Client/Models/Responses/GeneralError.cs index 720e077..370d7d5 100644 --- a/ChromaDB.Client/Models/Responses/GeneralError.cs +++ b/ChromaDB.Client/Models/Responses/GeneralError.cs @@ -1,4 +1,5 @@ -using System.Text.Json.Serialization; +using System.Text.Json; +using System.Text.Json.Serialization; namespace ChromaDB.Client.Models.Responses; @@ -6,4 +7,11 @@ internal class GeneralError { [JsonPropertyName("error")] public string? Error { get; init; } + + [JsonPropertyName("message")] + public string? Message { get; init; } + + // A string, or on the 0.x servers the list of the validation errors of FastAPI: [{"loc": ["body", "n_results"], "msg": "..."}]. + [JsonPropertyName("detail")] + public JsonElement? Detail { get; init; } } diff --git a/ChromaDB.Client/Models/Responses/GetAttachedFunctionResponse.cs b/ChromaDB.Client/Models/Responses/GetAttachedFunctionResponse.cs new file mode 100644 index 0000000..c6c0954 --- /dev/null +++ b/ChromaDB.Client/Models/Responses/GetAttachedFunctionResponse.cs @@ -0,0 +1,9 @@ +using System.Text.Json.Serialization; + +namespace ChromaDB.Client.Models.Responses; + +internal class GetAttachedFunctionResponse +{ + [JsonPropertyName("attached_function")] + public ChromaAttachedFunction AttachedFunction { get; init; } = null!; +} diff --git a/ChromaDB.Client/Properties/AssemblyInfo.cs b/ChromaDB.Client/Properties/AssemblyInfo.cs new file mode 100644 index 0000000..fea6ea8 --- /dev/null +++ b/ChromaDB.Client/Properties/AssemblyInfo.cs @@ -0,0 +1,5 @@ +using System.Runtime.CompilerServices; + +// The tests reach internal parts: ChromaHttpClient.ServerFacts.Lifetime, ChromaNumbers. The framework tests run some of them on .NET Framework. +[assembly: InternalsVisibleTo("ChromaDB.Client.Tests")] +[assembly: InternalsVisibleTo("ChromaDB.Client.FrameworkTests")] diff --git a/ChromaDB.Client/PublicAPI.Shipped.txt b/ChromaDB.Client/PublicAPI.Shipped.txt new file mode 100644 index 0000000..b29fd51 --- /dev/null +++ b/ChromaDB.Client/PublicAPI.Shipped.txt @@ -0,0 +1,534 @@ +#nullable enable +ChromaDB.Client.ChromaApiVersion +ChromaDB.Client.ChromaApiVersion.V1 = 1 -> ChromaDB.Client.ChromaApiVersion +ChromaDB.Client.ChromaApiVersion.V2 = 0 -> ChromaDB.Client.ChromaApiVersion +ChromaDB.Client.ChromaBm25 +ChromaDB.Client.ChromaBm25.AvgDocLength.get -> double +ChromaDB.Client.ChromaBm25.B.get -> double +ChromaDB.Client.ChromaBm25.ChromaBm25(double k = 1.2, double b = 0.75, double avgDocLength = 256, int tokenMaxLength = 40, System.Collections.Generic.IEnumerable? stopwords = null, bool includeTokens = false) -> void +ChromaDB.Client.ChromaBm25.Embed(System.Collections.Generic.IEnumerable! texts) -> System.Collections.Generic.IReadOnlyList! +ChromaDB.Client.ChromaBm25.Embed(string! text) -> ChromaDB.Client.Models.ChromaSparseVector! +ChromaDB.Client.ChromaBm25.IncludeTokens.get -> bool +ChromaDB.Client.ChromaBm25.K.get -> double +ChromaDB.Client.ChromaBm25.Reference.get -> ChromaDB.Client.Models.ChromaEmbeddingFunctionReference! +ChromaDB.Client.ChromaBm25.Stopwords.get -> System.Collections.Generic.IReadOnlyList? +ChromaDB.Client.ChromaBm25.TokenMaxLength.get -> int +ChromaDB.Client.ChromaClient +ChromaDB.Client.ChromaClient.ChromaClient() -> void +ChromaDB.Client.ChromaClient.ChromaClient(ChromaDB.Client.ChromaConfigurationOptions! options) -> void +ChromaDB.Client.ChromaClient.ChromaClient(ChromaDB.Client.ChromaConfigurationOptions! options, System.Net.Http.HttpClient! httpClient) -> void +ChromaDB.Client.ChromaClient.ChromaClient(string! uri) -> void +ChromaDB.Client.ChromaClient.Dispose() -> void +ChromaDB.Client.ChromaCollectionClient +ChromaDB.Client.ChromaCollectionClient.ChromaCollectionClient() -> void +ChromaDB.Client.ChromaCollectionClient.ChromaCollectionClient(ChromaDB.Client.Models.ChromaCollection! collection, ChromaDB.Client.ChromaConfigurationOptions! options, System.Net.Http.HttpClient! httpClient) -> void +ChromaDB.Client.ChromaCollectionClient.ChromaCollectionClient(System.Guid collectionId, string! collectionName, ChromaDB.Client.ChromaConfigurationOptions! options, System.Net.Http.HttpClient! httpClient) -> void +ChromaDB.Client.ChromaConfigurationOptions +ChromaDB.Client.ChromaConfigurationOptions.ApiVersion.get -> ChromaDB.Client.ChromaApiVersion +ChromaDB.Client.ChromaConfigurationOptions.ApiVersion.init -> void +ChromaDB.Client.ChromaConfigurationOptions.BasicAuthPassword.get -> string? +ChromaDB.Client.ChromaConfigurationOptions.BasicAuthPassword.init -> void +ChromaDB.Client.ChromaConfigurationOptions.BasicAuthUsername.get -> string? +ChromaDB.Client.ChromaConfigurationOptions.BasicAuthUsername.init -> void +ChromaDB.Client.ChromaConfigurationOptions.BatchSplitting.get -> bool +ChromaDB.Client.ChromaConfigurationOptions.BatchSplitting.init -> void +ChromaDB.Client.ChromaConfigurationOptions.ChromaConfigurationOptions() -> void +ChromaDB.Client.ChromaConfigurationOptions.ChromaConfigurationOptions(System.Uri! uri, string? tenant = null, string? database = null, string? chromaToken = null) -> void +ChromaDB.Client.ChromaConfigurationOptions.ChromaConfigurationOptions(string! uri, string? tenant = null, string? database = null, string? chromaToken = null) -> void +ChromaDB.Client.ChromaConfigurationOptions.ChromaToken.get -> string? +ChromaDB.Client.ChromaConfigurationOptions.ChromaToken.init -> void +ChromaDB.Client.ChromaConfigurationOptions.ChromaTokenTransportHeader.get -> ChromaDB.Client.ChromaTokenTransportHeader +ChromaDB.Client.ChromaConfigurationOptions.ChromaTokenTransportHeader.init -> void +ChromaDB.Client.ChromaConfigurationOptions.Database.get -> string? +ChromaDB.Client.ChromaConfigurationOptions.Database.init -> void +ChromaDB.Client.ChromaConfigurationOptions.MaxBatchSize.get -> int? +ChromaDB.Client.ChromaConfigurationOptions.MaxBatchSize.init -> void +ChromaDB.Client.ChromaConfigurationOptions.MetadataValues.get -> ChromaDB.Client.ChromaMetadataValues +ChromaDB.Client.ChromaConfigurationOptions.MetadataValues.init -> void +ChromaDB.Client.ChromaConfigurationOptions.Tenant.get -> string? +ChromaDB.Client.ChromaConfigurationOptions.Tenant.init -> void +ChromaDB.Client.ChromaConfigurationOptions.Uri.get -> System.Uri! +ChromaDB.Client.ChromaConfigurationOptions.Uri.init -> void +ChromaDB.Client.ChromaConfigurationOptions.WithApiVersion(ChromaDB.Client.ChromaApiVersion apiVersion) -> ChromaDB.Client.ChromaConfigurationOptions! +ChromaDB.Client.ChromaConfigurationOptions.WithBasicAuth(string! username, string! password) -> ChromaDB.Client.ChromaConfigurationOptions! +ChromaDB.Client.ChromaConfigurationOptions.WithBatchSplitting(bool batchSplitting = true) -> ChromaDB.Client.ChromaConfigurationOptions! +ChromaDB.Client.ChromaConfigurationOptions.WithBatchSplitting(int maxBatchSize) -> ChromaDB.Client.ChromaConfigurationOptions! +ChromaDB.Client.ChromaConfigurationOptions.WithChromaToken(string! chromaToken) -> ChromaDB.Client.ChromaConfigurationOptions! +ChromaDB.Client.ChromaConfigurationOptions.WithChromaToken(string! chromaToken, ChromaDB.Client.ChromaTokenTransportHeader transportHeader) -> ChromaDB.Client.ChromaConfigurationOptions! +ChromaDB.Client.ChromaConfigurationOptions.WithDatabase(string! database) -> ChromaDB.Client.ChromaConfigurationOptions! +ChromaDB.Client.ChromaConfigurationOptions.WithMetadataValues(ChromaDB.Client.ChromaMetadataValues metadataValues) -> ChromaDB.Client.ChromaConfigurationOptions! +ChromaDB.Client.ChromaConfigurationOptions.WithTenant(string! tenant) -> ChromaDB.Client.ChromaConfigurationOptions! +ChromaDB.Client.ChromaConfigurationOptions.WithUri(System.Uri! uri) -> ChromaDB.Client.ChromaConfigurationOptions! +ChromaDB.Client.ChromaConfigurationOptions.WithUri(string! uri) -> ChromaDB.Client.ChromaConfigurationOptions! +ChromaDB.Client.ChromaException +ChromaDB.Client.ChromaException.ChromaException() -> void +ChromaDB.Client.ChromaException.ChromaException(string? message) -> void +ChromaDB.Client.ChromaException.ChromaException(string? message, System.Exception? inner) -> void +ChromaDB.Client.ChromaException.ErrorType.get -> string? +ChromaDB.Client.ChromaException.ErrorType.init -> void +ChromaDB.Client.ChromaException.StatusCode.get -> System.Net.HttpStatusCode? +ChromaDB.Client.ChromaException.StatusCode.init -> void +ChromaDB.Client.ChromaFunctions +ChromaDB.Client.ChromaGetInclude +ChromaDB.Client.ChromaGetInclude.Documents = 4 -> ChromaDB.Client.ChromaGetInclude +ChromaDB.Client.ChromaGetInclude.Embeddings = 1 -> ChromaDB.Client.ChromaGetInclude +ChromaDB.Client.ChromaGetInclude.Metadatas = 2 -> ChromaDB.Client.ChromaGetInclude +ChromaDB.Client.ChromaGetInclude.None = 0 -> ChromaDB.Client.ChromaGetInclude +ChromaDB.Client.ChromaGetInclude.Uris = 8 -> ChromaDB.Client.ChromaGetInclude +ChromaDB.Client.ChromaMetadataValues +ChromaDB.Client.ChromaMetadataValues.Exact = 1 -> ChromaDB.Client.ChromaMetadataValues +ChromaDB.Client.ChromaMetadataValues.Inferred = 0 -> ChromaDB.Client.ChromaMetadataValues +ChromaDB.Client.ChromaQueryInclude +ChromaDB.Client.ChromaQueryInclude.Distances = 8 -> ChromaDB.Client.ChromaQueryInclude +ChromaDB.Client.ChromaQueryInclude.Documents = 4 -> ChromaDB.Client.ChromaQueryInclude +ChromaDB.Client.ChromaQueryInclude.Embeddings = 1 -> ChromaDB.Client.ChromaQueryInclude +ChromaDB.Client.ChromaQueryInclude.Metadatas = 2 -> ChromaDB.Client.ChromaQueryInclude +ChromaDB.Client.ChromaQueryInclude.None = 0 -> ChromaDB.Client.ChromaQueryInclude +ChromaDB.Client.ChromaQueryInclude.Uris = 16 -> ChromaDB.Client.ChromaQueryInclude +ChromaDB.Client.ChromaRank +ChromaDB.Client.ChromaReadLevel +ChromaDB.Client.ChromaReadLevel.IndexAndBoundedWal = 2 -> ChromaDB.Client.ChromaReadLevel +ChromaDB.Client.ChromaReadLevel.IndexAndWal = 0 -> ChromaDB.Client.ChromaReadLevel +ChromaDB.Client.ChromaReadLevel.IndexOnly = 1 -> ChromaDB.Client.ChromaReadLevel +ChromaDB.Client.ChromaSearchAggregate +ChromaDB.Client.ChromaSearchGroupBy +ChromaDB.Client.ChromaSearchGroupBy.ChromaSearchGroupBy(ChromaDB.Client.ChromaSearchAggregate! aggregate, params string![]! keys) -> void +ChromaDB.Client.ChromaSearchKeys +ChromaDB.Client.ChromaSpace +ChromaDB.Client.ChromaSpace.Cosine = 1 -> ChromaDB.Client.ChromaSpace +ChromaDB.Client.ChromaSpace.InnerProduct = 2 -> ChromaDB.Client.ChromaSpace +ChromaDB.Client.ChromaSpace.L2 = 0 -> ChromaDB.Client.ChromaSpace +ChromaDB.Client.ChromaTelemetry +ChromaDB.Client.ChromaTokenTransportHeader +ChromaDB.Client.ChromaTokenTransportHeader.Authorization = 1 -> ChromaDB.Client.ChromaTokenTransportHeader +ChromaDB.Client.ChromaTokenTransportHeader.XChromaToken = 0 -> ChromaDB.Client.ChromaTokenTransportHeader +ChromaDB.Client.ChromaWhereDocumentOperator +ChromaDB.Client.ChromaWhereDocumentOperator.ChromaWhereDocumentOperator(string! operator) -> void +ChromaDB.Client.ChromaWhereDocumentOperator.Operator.get -> string! +ChromaDB.Client.ChromaWhereOperator +ChromaDB.Client.ChromaWhereOperator.ChromaWhereOperator(string! operator) -> void +ChromaDB.Client.ChromaWhereOperator.Operator.get -> string! +ChromaDB.Client.Models.ChromaAttachedFunction +ChromaDB.Client.Models.ChromaAttachedFunction.ChromaAttachedFunction() -> void +ChromaDB.Client.Models.ChromaAttachedFunction.CompletionOffset.get -> long? +ChromaDB.Client.Models.ChromaAttachedFunction.CompletionOffset.init -> void +ChromaDB.Client.Models.ChromaAttachedFunction.Database.get -> string? +ChromaDB.Client.Models.ChromaAttachedFunction.Database.init -> void +ChromaDB.Client.Models.ChromaAttachedFunction.FunctionName.get -> string! +ChromaDB.Client.Models.ChromaAttachedFunction.FunctionName.init -> void +ChromaDB.Client.Models.ChromaAttachedFunction.Id.get -> System.Guid +ChromaDB.Client.Models.ChromaAttachedFunction.Id.init -> void +ChromaDB.Client.Models.ChromaAttachedFunction.InputCollectionId.get -> System.Guid? +ChromaDB.Client.Models.ChromaAttachedFunction.InputCollectionId.init -> void +ChromaDB.Client.Models.ChromaAttachedFunction.MinRecordsForInvocation.get -> long? +ChromaDB.Client.Models.ChromaAttachedFunction.MinRecordsForInvocation.init -> void +ChromaDB.Client.Models.ChromaAttachedFunction.Name.get -> string! +ChromaDB.Client.Models.ChromaAttachedFunction.Name.init -> void +ChromaDB.Client.Models.ChromaAttachedFunction.OutputCollection.get -> string? +ChromaDB.Client.Models.ChromaAttachedFunction.OutputCollection.init -> void +ChromaDB.Client.Models.ChromaAttachedFunction.OutputCollectionId.get -> System.Guid? +ChromaDB.Client.Models.ChromaAttachedFunction.OutputCollectionId.init -> void +ChromaDB.Client.Models.ChromaAttachedFunction.Params.get -> string? +ChromaDB.Client.Models.ChromaAttachedFunction.Params.init -> void +ChromaDB.Client.Models.ChromaAttachedFunction.Tenant.get -> string? +ChromaDB.Client.Models.ChromaAttachedFunction.Tenant.init -> void +ChromaDB.Client.Models.ChromaCollection +ChromaDB.Client.Models.ChromaCollection.ChromaCollection(string! name) -> void +ChromaDB.Client.Models.ChromaCollection.ConfigurationJson.get -> System.Text.Json.JsonElement? +ChromaDB.Client.Models.ChromaCollection.ConfigurationJson.init -> void +ChromaDB.Client.Models.ChromaCollection.Database.get -> string? +ChromaDB.Client.Models.ChromaCollection.Database.init -> void +ChromaDB.Client.Models.ChromaCollection.Dimension.get -> int? +ChromaDB.Client.Models.ChromaCollection.Dimension.init -> void +ChromaDB.Client.Models.ChromaCollection.Id.get -> System.Guid +ChromaDB.Client.Models.ChromaCollection.Id.init -> void +ChromaDB.Client.Models.ChromaCollection.LogPosition.get -> long? +ChromaDB.Client.Models.ChromaCollection.LogPosition.init -> void +ChromaDB.Client.Models.ChromaCollection.Metadata.get -> System.Collections.Generic.IReadOnlyDictionary? +ChromaDB.Client.Models.ChromaCollection.Metadata.init -> void +ChromaDB.Client.Models.ChromaCollection.Name.get -> string! +ChromaDB.Client.Models.ChromaCollection.SchemaJson.get -> System.Text.Json.JsonElement? +ChromaDB.Client.Models.ChromaCollection.SchemaJson.init -> void +ChromaDB.Client.Models.ChromaCollection.Space.get -> ChromaDB.Client.ChromaSpace? +ChromaDB.Client.Models.ChromaCollection.SparseVectorIndexes.get -> System.Collections.Generic.IReadOnlyList! +ChromaDB.Client.Models.ChromaCollection.Tenant.get -> string? +ChromaDB.Client.Models.ChromaCollection.Tenant.init -> void +ChromaDB.Client.Models.ChromaCollection.Version.get -> int? +ChromaDB.Client.Models.ChromaCollection.Version.init -> void +ChromaDB.Client.Models.ChromaCollectionConfiguration +ChromaDB.Client.Models.ChromaCollectionConfiguration.ChromaCollectionConfiguration() -> void +ChromaDB.Client.Models.ChromaCollectionConfiguration.EmbeddingFunction.get -> ChromaDB.Client.Models.ChromaEmbeddingFunctionReference? +ChromaDB.Client.Models.ChromaCollectionConfiguration.EmbeddingFunction.init -> void +ChromaDB.Client.Models.ChromaCollectionConfiguration.Hnsw.get -> ChromaDB.Client.Models.ChromaHnswConfiguration? +ChromaDB.Client.Models.ChromaCollectionConfiguration.Hnsw.init -> void +ChromaDB.Client.Models.ChromaCollectionConfiguration.Space.get -> ChromaDB.Client.ChromaSpace? +ChromaDB.Client.Models.ChromaCollectionConfiguration.Space.init -> void +ChromaDB.Client.Models.ChromaCollectionConfiguration.Spann.get -> ChromaDB.Client.Models.ChromaSpannConfiguration? +ChromaDB.Client.Models.ChromaCollectionConfiguration.Spann.init -> void +ChromaDB.Client.Models.ChromaCollectionConfigurationUpdate +ChromaDB.Client.Models.ChromaCollectionConfigurationUpdate.ChromaCollectionConfigurationUpdate() -> void +ChromaDB.Client.Models.ChromaCollectionConfigurationUpdate.EmbeddingFunction.get -> ChromaDB.Client.Models.ChromaEmbeddingFunctionReference? +ChromaDB.Client.Models.ChromaCollectionConfigurationUpdate.EmbeddingFunction.init -> void +ChromaDB.Client.Models.ChromaCollectionConfigurationUpdate.Hnsw.get -> ChromaDB.Client.Models.ChromaHnswConfigurationUpdate? +ChromaDB.Client.Models.ChromaCollectionConfigurationUpdate.Hnsw.init -> void +ChromaDB.Client.Models.ChromaCollectionConfigurationUpdate.Spann.get -> ChromaDB.Client.Models.ChromaSpannConfigurationUpdate? +ChromaDB.Client.Models.ChromaCollectionConfigurationUpdate.Spann.init -> void +ChromaDB.Client.Models.ChromaCollectionDefinition +ChromaDB.Client.Models.ChromaCollectionDefinition.ChromaCollectionDefinition(string! name) -> void +ChromaDB.Client.Models.ChromaCollectionDefinition.Configuration.get -> ChromaDB.Client.Models.ChromaCollectionConfiguration? +ChromaDB.Client.Models.ChromaCollectionDefinition.Configuration.init -> void +ChromaDB.Client.Models.ChromaCollectionDefinition.Metadata.get -> System.Collections.Generic.IReadOnlyDictionary? +ChromaDB.Client.Models.ChromaCollectionDefinition.Metadata.init -> void +ChromaDB.Client.Models.ChromaCollectionDefinition.Name.get -> string! +ChromaDB.Client.Models.ChromaCollectionDefinition.Schema.get -> ChromaDB.Client.Models.ChromaCollectionSchema? +ChromaDB.Client.Models.ChromaCollectionDefinition.Schema.init -> void +ChromaDB.Client.Models.ChromaCollectionEntry +ChromaDB.Client.Models.ChromaCollectionEntry.ChromaCollectionEntry(string! id) -> void +ChromaDB.Client.Models.ChromaCollectionEntry.Document.get -> string? +ChromaDB.Client.Models.ChromaCollectionEntry.Document.init -> void +ChromaDB.Client.Models.ChromaCollectionEntry.Embedding.get -> System.ReadOnlyMemory? +ChromaDB.Client.Models.ChromaCollectionEntry.Embedding.init -> void +ChromaDB.Client.Models.ChromaCollectionEntry.Id.get -> string! +ChromaDB.Client.Models.ChromaCollectionEntry.Metadata.get -> System.Collections.Generic.IReadOnlyDictionary? +ChromaDB.Client.Models.ChromaCollectionEntry.Metadata.init -> void +ChromaDB.Client.Models.ChromaCollectionEntry.Uri.get -> string? +ChromaDB.Client.Models.ChromaCollectionEntry.Uri.init -> void +ChromaDB.Client.Models.ChromaCollectionQueryEntry +ChromaDB.Client.Models.ChromaCollectionQueryEntry.ChromaCollectionQueryEntry(string! id) -> void +ChromaDB.Client.Models.ChromaCollectionQueryEntry.Distance.get -> float? +ChromaDB.Client.Models.ChromaCollectionQueryEntry.Distance.init -> void +ChromaDB.Client.Models.ChromaCollectionQueryEntry.Document.get -> string? +ChromaDB.Client.Models.ChromaCollectionQueryEntry.Document.init -> void +ChromaDB.Client.Models.ChromaCollectionQueryEntry.Embedding.get -> System.ReadOnlyMemory? +ChromaDB.Client.Models.ChromaCollectionQueryEntry.Embedding.init -> void +ChromaDB.Client.Models.ChromaCollectionQueryEntry.Id.get -> string! +ChromaDB.Client.Models.ChromaCollectionQueryEntry.Metadata.get -> System.Collections.Generic.IReadOnlyDictionary? +ChromaDB.Client.Models.ChromaCollectionQueryEntry.Metadata.init -> void +ChromaDB.Client.Models.ChromaCollectionQueryEntry.Uri.get -> string? +ChromaDB.Client.Models.ChromaCollectionQueryEntry.Uri.init -> void +ChromaDB.Client.Models.ChromaCollectionSchema +ChromaDB.Client.Models.ChromaCollectionSchema.ChromaCollectionSchema() -> void +ChromaDB.Client.Models.ChromaCollectionSchema.WithGcpCmek(string! resource) -> ChromaDB.Client.Models.ChromaCollectionSchema! +ChromaDB.Client.Models.ChromaCollectionSchema.WithIndex(ChromaDB.Client.Models.ChromaSchemaIndex index, string? key = null) -> ChromaDB.Client.Models.ChromaCollectionSchema! +ChromaDB.Client.Models.ChromaCollectionSchema.WithSparseVectorIndex(string! key, ChromaDB.Client.Models.ChromaSparseIndexAlgorithm algorithm, string? sourceKey = null, bool bm25 = false, ChromaDB.Client.Models.ChromaEmbeddingFunctionReference? embeddingFunction = null) -> ChromaDB.Client.Models.ChromaCollectionSchema! +ChromaDB.Client.Models.ChromaCollectionSchema.WithSparseVectorIndex(string! key, string? sourceKey = null, bool bm25 = false, ChromaDB.Client.Models.ChromaEmbeddingFunctionReference? embeddingFunction = null) -> ChromaDB.Client.Models.ChromaCollectionSchema! +ChromaDB.Client.Models.ChromaCollectionSchema.WithoutIndex(ChromaDB.Client.Models.ChromaSchemaIndex index, string? key = null) -> ChromaDB.Client.Models.ChromaCollectionSchema! +ChromaDB.Client.Models.ChromaDatabase +ChromaDB.Client.Models.ChromaDatabase.ChromaDatabase(string! name) -> void +ChromaDB.Client.Models.ChromaDatabase.Id.get -> System.Guid +ChromaDB.Client.Models.ChromaDatabase.Id.init -> void +ChromaDB.Client.Models.ChromaDatabase.Name.get -> string! +ChromaDB.Client.Models.ChromaDatabase.Tenant.get -> string? +ChromaDB.Client.Models.ChromaDatabase.Tenant.init -> void +ChromaDB.Client.Models.ChromaDelete +ChromaDB.Client.Models.ChromaDelete.ChromaDelete() -> void +ChromaDB.Client.Models.ChromaDelete.Ids.get -> System.Collections.Generic.IReadOnlyList? +ChromaDB.Client.Models.ChromaDelete.Ids.init -> void +ChromaDB.Client.Models.ChromaDelete.Limit.get -> int? +ChromaDB.Client.Models.ChromaDelete.Limit.init -> void +ChromaDB.Client.Models.ChromaDelete.Where.get -> ChromaDB.Client.ChromaWhereOperator? +ChromaDB.Client.Models.ChromaDelete.Where.init -> void +ChromaDB.Client.Models.ChromaDelete.WhereDocument.get -> ChromaDB.Client.ChromaWhereDocumentOperator? +ChromaDB.Client.Models.ChromaDelete.WhereDocument.init -> void +ChromaDB.Client.Models.ChromaEmbeddingFunctionReference +ChromaDB.Client.Models.ChromaHealthcheck +ChromaDB.Client.Models.ChromaHealthcheck.ChromaHealthcheck() -> void +ChromaDB.Client.Models.ChromaHealthcheck.IsExecutorReady.get -> bool? +ChromaDB.Client.Models.ChromaHealthcheck.IsExecutorReady.init -> void +ChromaDB.Client.Models.ChromaHealthcheck.IsLogClientReady.get -> bool? +ChromaDB.Client.Models.ChromaHealthcheck.IsLogClientReady.init -> void +ChromaDB.Client.Models.ChromaHeartbeat +ChromaDB.Client.Models.ChromaHeartbeat.ChromaHeartbeat() -> void +ChromaDB.Client.Models.ChromaHeartbeat.NanosecondHeartbeat.get -> long +ChromaDB.Client.Models.ChromaHeartbeat.NanosecondHeartbeat.set -> void +ChromaDB.Client.Models.ChromaHnswConfiguration +ChromaDB.Client.Models.ChromaHnswConfiguration.BatchSize.get -> int? +ChromaDB.Client.Models.ChromaHnswConfiguration.BatchSize.init -> void +ChromaDB.Client.Models.ChromaHnswConfiguration.ChromaHnswConfiguration() -> void +ChromaDB.Client.Models.ChromaHnswConfiguration.EfConstruction.get -> int? +ChromaDB.Client.Models.ChromaHnswConfiguration.EfConstruction.init -> void +ChromaDB.Client.Models.ChromaHnswConfiguration.EfSearch.get -> int? +ChromaDB.Client.Models.ChromaHnswConfiguration.EfSearch.init -> void +ChromaDB.Client.Models.ChromaHnswConfiguration.MaxNeighbors.get -> int? +ChromaDB.Client.Models.ChromaHnswConfiguration.MaxNeighbors.init -> void +ChromaDB.Client.Models.ChromaHnswConfiguration.NumThreads.get -> int? +ChromaDB.Client.Models.ChromaHnswConfiguration.NumThreads.init -> void +ChromaDB.Client.Models.ChromaHnswConfiguration.ResizeFactor.get -> double? +ChromaDB.Client.Models.ChromaHnswConfiguration.ResizeFactor.init -> void +ChromaDB.Client.Models.ChromaHnswConfiguration.SyncThreshold.get -> int? +ChromaDB.Client.Models.ChromaHnswConfiguration.SyncThreshold.init -> void +ChromaDB.Client.Models.ChromaHnswConfigurationUpdate +ChromaDB.Client.Models.ChromaHnswConfigurationUpdate.BatchSize.get -> int? +ChromaDB.Client.Models.ChromaHnswConfigurationUpdate.BatchSize.init -> void +ChromaDB.Client.Models.ChromaHnswConfigurationUpdate.ChromaHnswConfigurationUpdate() -> void +ChromaDB.Client.Models.ChromaHnswConfigurationUpdate.EfSearch.get -> int? +ChromaDB.Client.Models.ChromaHnswConfigurationUpdate.EfSearch.init -> void +ChromaDB.Client.Models.ChromaHnswConfigurationUpdate.MaxNeighbors.get -> int? +ChromaDB.Client.Models.ChromaHnswConfigurationUpdate.MaxNeighbors.init -> void +ChromaDB.Client.Models.ChromaHnswConfigurationUpdate.NumThreads.get -> int? +ChromaDB.Client.Models.ChromaHnswConfigurationUpdate.NumThreads.init -> void +ChromaDB.Client.Models.ChromaHnswConfigurationUpdate.ResizeFactor.get -> double? +ChromaDB.Client.Models.ChromaHnswConfigurationUpdate.ResizeFactor.init -> void +ChromaDB.Client.Models.ChromaHnswConfigurationUpdate.SyncThreshold.get -> int? +ChromaDB.Client.Models.ChromaHnswConfigurationUpdate.SyncThreshold.init -> void +ChromaDB.Client.Models.ChromaIndexingStatus +ChromaDB.Client.Models.ChromaIndexingStatus.ChromaIndexingStatus() -> void +ChromaDB.Client.Models.ChromaIndexingStatus.NumIndexedOps.get -> long +ChromaDB.Client.Models.ChromaIndexingStatus.NumIndexedOps.init -> void +ChromaDB.Client.Models.ChromaIndexingStatus.NumUnindexedOps.get -> long +ChromaDB.Client.Models.ChromaIndexingStatus.NumUnindexedOps.init -> void +ChromaDB.Client.Models.ChromaIndexingStatus.OpIndexingProgress.get -> float +ChromaDB.Client.Models.ChromaIndexingStatus.OpIndexingProgress.init -> void +ChromaDB.Client.Models.ChromaIndexingStatus.TotalOps.get -> long +ChromaDB.Client.Models.ChromaIndexingStatus.TotalOps.init -> void +ChromaDB.Client.Models.ChromaPreFlightChecks +ChromaDB.Client.Models.ChromaPreFlightChecks.ChromaPreFlightChecks() -> void +ChromaDB.Client.Models.ChromaPreFlightChecks.MaxBatchSize.get -> int +ChromaDB.Client.Models.ChromaPreFlightChecks.MaxBatchSize.init -> void +ChromaDB.Client.Models.ChromaPreFlightChecks.SupportsBase64Encoding.get -> bool? +ChromaDB.Client.Models.ChromaPreFlightChecks.SupportsBase64Encoding.init -> void +ChromaDB.Client.Models.ChromaQuery +ChromaDB.Client.Models.ChromaQuery.ChromaQuery(System.Collections.Generic.IReadOnlyList>! queryEmbeddings) -> void +ChromaDB.Client.Models.ChromaQuery.Ids.get -> System.Collections.Generic.IReadOnlyList? +ChromaDB.Client.Models.ChromaQuery.Ids.init -> void +ChromaDB.Client.Models.ChromaQuery.Include.get -> ChromaDB.Client.ChromaQueryInclude? +ChromaDB.Client.Models.ChromaQuery.Include.init -> void +ChromaDB.Client.Models.ChromaQuery.NResults.get -> int +ChromaDB.Client.Models.ChromaQuery.NResults.init -> void +ChromaDB.Client.Models.ChromaQuery.QueryEmbeddings.get -> System.Collections.Generic.IReadOnlyList>! +ChromaDB.Client.Models.ChromaQuery.Where.get -> ChromaDB.Client.ChromaWhereOperator? +ChromaDB.Client.Models.ChromaQuery.Where.init -> void +ChromaDB.Client.Models.ChromaQuery.WhereDocument.get -> ChromaDB.Client.ChromaWhereDocumentOperator? +ChromaDB.Client.Models.ChromaQuery.WhereDocument.init -> void +ChromaDB.Client.Models.ChromaRecords +ChromaDB.Client.Models.ChromaRecords.ChromaRecords(System.Collections.Generic.IReadOnlyList! ids) -> void +ChromaDB.Client.Models.ChromaRecords.Documents.get -> System.Collections.Generic.IReadOnlyList? +ChromaDB.Client.Models.ChromaRecords.Documents.init -> void +ChromaDB.Client.Models.ChromaRecords.Embeddings.get -> System.Collections.Generic.IReadOnlyList>? +ChromaDB.Client.Models.ChromaRecords.Embeddings.init -> void +ChromaDB.Client.Models.ChromaRecords.Ids.get -> System.Collections.Generic.IReadOnlyList! +ChromaDB.Client.Models.ChromaRecords.Metadatas.get -> System.Collections.Generic.IReadOnlyList!>? +ChromaDB.Client.Models.ChromaRecords.Metadatas.init -> void +ChromaDB.Client.Models.ChromaRecords.Uris.get -> System.Collections.Generic.IReadOnlyList? +ChromaDB.Client.Models.ChromaRecords.Uris.init -> void +ChromaDB.Client.Models.ChromaSchemaIndex +ChromaDB.Client.Models.ChromaSchemaIndex.BoolInverted = 4 -> ChromaDB.Client.Models.ChromaSchemaIndex +ChromaDB.Client.Models.ChromaSchemaIndex.FloatInverted = 3 -> ChromaDB.Client.Models.ChromaSchemaIndex +ChromaDB.Client.Models.ChromaSchemaIndex.FullTextSearch = 0 -> ChromaDB.Client.Models.ChromaSchemaIndex +ChromaDB.Client.Models.ChromaSchemaIndex.IntInverted = 2 -> ChromaDB.Client.Models.ChromaSchemaIndex +ChromaDB.Client.Models.ChromaSchemaIndex.StringInverted = 1 -> ChromaDB.Client.Models.ChromaSchemaIndex +ChromaDB.Client.Models.ChromaSearch +ChromaDB.Client.Models.ChromaSearch.ChromaSearch() -> void +ChromaDB.Client.Models.ChromaSearch.GroupBy.get -> ChromaDB.Client.ChromaSearchGroupBy? +ChromaDB.Client.Models.ChromaSearch.GroupBy.init -> void +ChromaDB.Client.Models.ChromaSearch.Ids.get -> System.Collections.Generic.IReadOnlyList? +ChromaDB.Client.Models.ChromaSearch.Ids.init -> void +ChromaDB.Client.Models.ChromaSearch.Limit.get -> int? +ChromaDB.Client.Models.ChromaSearch.Limit.init -> void +ChromaDB.Client.Models.ChromaSearch.Offset.get -> int +ChromaDB.Client.Models.ChromaSearch.Offset.init -> void +ChromaDB.Client.Models.ChromaSearch.Rank.get -> ChromaDB.Client.ChromaRank? +ChromaDB.Client.Models.ChromaSearch.Rank.init -> void +ChromaDB.Client.Models.ChromaSearch.Select.get -> System.Collections.Generic.IReadOnlyList? +ChromaDB.Client.Models.ChromaSearch.Select.init -> void +ChromaDB.Client.Models.ChromaSearch.Where.get -> ChromaDB.Client.ChromaWhereOperator? +ChromaDB.Client.Models.ChromaSearch.Where.init -> void +ChromaDB.Client.Models.ChromaSearch.WhereDocument.get -> ChromaDB.Client.ChromaWhereDocumentOperator? +ChromaDB.Client.Models.ChromaSearch.WhereDocument.init -> void +ChromaDB.Client.Models.ChromaSearchEntry +ChromaDB.Client.Models.ChromaSearchEntry.ChromaSearchEntry(string! id) -> void +ChromaDB.Client.Models.ChromaSearchEntry.Document.get -> string? +ChromaDB.Client.Models.ChromaSearchEntry.Document.init -> void +ChromaDB.Client.Models.ChromaSearchEntry.Embedding.get -> System.ReadOnlyMemory? +ChromaDB.Client.Models.ChromaSearchEntry.Embedding.init -> void +ChromaDB.Client.Models.ChromaSearchEntry.Id.get -> string! +ChromaDB.Client.Models.ChromaSearchEntry.Metadata.get -> System.Collections.Generic.IReadOnlyDictionary? +ChromaDB.Client.Models.ChromaSearchEntry.Metadata.init -> void +ChromaDB.Client.Models.ChromaSearchEntry.Score.get -> float? +ChromaDB.Client.Models.ChromaSearchEntry.Score.init -> void +ChromaDB.Client.Models.ChromaSpannConfiguration +ChromaDB.Client.Models.ChromaSpannConfiguration.CenterDriftThreshold.get -> double? +ChromaDB.Client.Models.ChromaSpannConfiguration.CenterDriftThreshold.init -> void +ChromaDB.Client.Models.ChromaSpannConfiguration.ChromaSpannConfiguration() -> void +ChromaDB.Client.Models.ChromaSpannConfiguration.EfConstruction.get -> int? +ChromaDB.Client.Models.ChromaSpannConfiguration.EfConstruction.init -> void +ChromaDB.Client.Models.ChromaSpannConfiguration.EfSearch.get -> int? +ChromaDB.Client.Models.ChromaSpannConfiguration.EfSearch.init -> void +ChromaDB.Client.Models.ChromaSpannConfiguration.MaxNeighbors.get -> int? +ChromaDB.Client.Models.ChromaSpannConfiguration.MaxNeighbors.init -> void +ChromaDB.Client.Models.ChromaSpannConfiguration.MergeThreshold.get -> int? +ChromaDB.Client.Models.ChromaSpannConfiguration.MergeThreshold.init -> void +ChromaDB.Client.Models.ChromaSpannConfiguration.NreplicaCount.get -> int? +ChromaDB.Client.Models.ChromaSpannConfiguration.NreplicaCount.init -> void +ChromaDB.Client.Models.ChromaSpannConfiguration.NumCentersToMergeTo.get -> int? +ChromaDB.Client.Models.ChromaSpannConfiguration.NumCentersToMergeTo.init -> void +ChromaDB.Client.Models.ChromaSpannConfiguration.NumSamplesKmeans.get -> int? +ChromaDB.Client.Models.ChromaSpannConfiguration.NumSamplesKmeans.init -> void +ChromaDB.Client.Models.ChromaSpannConfiguration.ReassignNeighborCount.get -> int? +ChromaDB.Client.Models.ChromaSpannConfiguration.ReassignNeighborCount.init -> void +ChromaDB.Client.Models.ChromaSpannConfiguration.SearchNprobe.get -> int? +ChromaDB.Client.Models.ChromaSpannConfiguration.SearchNprobe.init -> void +ChromaDB.Client.Models.ChromaSpannConfiguration.SearchRngEpsilon.get -> double? +ChromaDB.Client.Models.ChromaSpannConfiguration.SearchRngEpsilon.init -> void +ChromaDB.Client.Models.ChromaSpannConfiguration.SplitThreshold.get -> int? +ChromaDB.Client.Models.ChromaSpannConfiguration.SplitThreshold.init -> void +ChromaDB.Client.Models.ChromaSpannConfiguration.WriteNprobe.get -> int? +ChromaDB.Client.Models.ChromaSpannConfiguration.WriteNprobe.init -> void +ChromaDB.Client.Models.ChromaSpannConfiguration.WriteRngEpsilon.get -> double? +ChromaDB.Client.Models.ChromaSpannConfiguration.WriteRngEpsilon.init -> void +ChromaDB.Client.Models.ChromaSpannConfigurationUpdate +ChromaDB.Client.Models.ChromaSpannConfigurationUpdate.ChromaSpannConfigurationUpdate() -> void +ChromaDB.Client.Models.ChromaSpannConfigurationUpdate.EfSearch.get -> int? +ChromaDB.Client.Models.ChromaSpannConfigurationUpdate.EfSearch.init -> void +ChromaDB.Client.Models.ChromaSpannConfigurationUpdate.SearchNprobe.get -> int? +ChromaDB.Client.Models.ChromaSpannConfigurationUpdate.SearchNprobe.init -> void +ChromaDB.Client.Models.ChromaSparseIndexAlgorithm +ChromaDB.Client.Models.ChromaSparseIndexAlgorithm.MaxScore = 1 -> ChromaDB.Client.Models.ChromaSparseIndexAlgorithm +ChromaDB.Client.Models.ChromaSparseIndexAlgorithm.Wand = 0 -> ChromaDB.Client.Models.ChromaSparseIndexAlgorithm +ChromaDB.Client.Models.ChromaSparseVector +ChromaDB.Client.Models.ChromaSparseVector.ChromaSparseVector(System.Collections.Generic.IReadOnlyList! indices, System.Collections.Generic.IReadOnlyList! values, System.Collections.Generic.IReadOnlyList? tokens = null) -> void +ChromaDB.Client.Models.ChromaSparseVector.Indices.get -> System.Collections.Generic.IReadOnlyList! +ChromaDB.Client.Models.ChromaSparseVector.Tokens.get -> System.Collections.Generic.IReadOnlyList? +ChromaDB.Client.Models.ChromaSparseVector.Values.get -> System.Collections.Generic.IReadOnlyList! +ChromaDB.Client.Models.ChromaSparseVectorIndex +ChromaDB.Client.Models.ChromaSparseVectorIndex.Algorithm.get -> ChromaDB.Client.Models.ChromaSparseIndexAlgorithm +ChromaDB.Client.Models.ChromaSparseVectorIndex.Bm25.get -> bool +ChromaDB.Client.Models.ChromaSparseVectorIndex.Bm25Function.get -> ChromaDB.Client.ChromaBm25? +ChromaDB.Client.Models.ChromaSparseVectorIndex.EmbeddingFunction.get -> string? +ChromaDB.Client.Models.ChromaSparseVectorIndex.EmbeddingFunctionConfig.get -> System.Text.Json.JsonElement? +ChromaDB.Client.Models.ChromaSparseVectorIndex.Key.get -> string! +ChromaDB.Client.Models.ChromaSparseVectorIndex.SourceKey.get -> string? +ChromaDB.Client.Models.ChromaTenant +ChromaDB.Client.Models.ChromaTenant.ChromaTenant(string! name) -> void +ChromaDB.Client.Models.ChromaTenant.Name.get -> string! +ChromaDB.Client.Models.ChromaTenant.ResourceName.get -> string? +ChromaDB.Client.Models.ChromaTenant.ResourceName.init -> void +ChromaDB.Client.Models.ChromaUserIdentity +ChromaDB.Client.Models.ChromaUserIdentity.ChromaUserIdentity() -> void +ChromaDB.Client.Models.ChromaUserIdentity.Databases.get -> System.Collections.Generic.IReadOnlyList? +ChromaDB.Client.Models.ChromaUserIdentity.Databases.init -> void +ChromaDB.Client.Models.ChromaUserIdentity.Tenant.get -> string? +ChromaDB.Client.Models.ChromaUserIdentity.Tenant.init -> void +ChromaDB.Client.Models.ChromaUserIdentity.UserId.get -> string? +ChromaDB.Client.Models.ChromaUserIdentity.UserId.init -> void +const ChromaDB.Client.ChromaFunctions.RecordCounter = "record_counter" -> string! +const ChromaDB.Client.ChromaFunctions.Statistics = "statistics" -> string! +const ChromaDB.Client.ChromaSearchKeys.Document = "#document" -> string! +const ChromaDB.Client.ChromaSearchKeys.Embedding = "#embedding" -> string! +const ChromaDB.Client.ChromaSearchKeys.Id = "#id" -> string! +const ChromaDB.Client.ChromaSearchKeys.Metadata = "#metadata" -> string! +const ChromaDB.Client.ChromaSearchKeys.Score = "#score" -> string! +const ChromaDB.Client.ChromaTelemetry.ActivitySourceName = "ChromaDB.Client" -> string! +const ChromaDB.Client.ChromaTelemetry.MeterName = "ChromaDB.Client" -> string! +override ChromaDB.Client.ChromaRank.ToString() -> string! +override ChromaDB.Client.ChromaWhereDocumentOperator.ToString() -> string! +override ChromaDB.Client.ChromaWhereOperator.ToString() -> string! +override ChromaDB.Client.Models.ChromaCollectionSchema.ToString() -> string! +override ChromaDB.Client.Models.ChromaSparseVector.ToString() -> string! +static ChromaDB.Client.ChromaBm25.DefaultStopwords.get -> System.Collections.Generic.IReadOnlyList! +static ChromaDB.Client.ChromaConfigurationOptions.FromConnectionString(string! connectionString) -> ChromaDB.Client.ChromaConfigurationOptions! +static ChromaDB.Client.ChromaRank.Abs(ChromaDB.Client.ChromaRank! rank) -> ChromaDB.Client.ChromaRank! +static ChromaDB.Client.ChromaRank.Exp(ChromaDB.Client.ChromaRank! rank) -> ChromaDB.Client.ChromaRank! +static ChromaDB.Client.ChromaRank.Knn(System.ReadOnlyMemory query, string! key = "#embedding", int limit = 16, double? defaultScore = null, bool returnRank = false) -> ChromaDB.Client.ChromaRank! +static ChromaDB.Client.ChromaRank.Log(ChromaDB.Client.ChromaRank! rank) -> ChromaDB.Client.ChromaRank! +static ChromaDB.Client.ChromaRank.Max(params ChromaDB.Client.ChromaRank![]! ranks) -> ChromaDB.Client.ChromaRank! +static ChromaDB.Client.ChromaRank.Min(params ChromaDB.Client.ChromaRank![]! ranks) -> ChromaDB.Client.ChromaRank! +static ChromaDB.Client.ChromaRank.Multiply(params ChromaDB.Client.ChromaRank![]! ranks) -> ChromaDB.Client.ChromaRank! +static ChromaDB.Client.ChromaRank.Rrf(System.Collections.Generic.IReadOnlyList! ranks, double k = 60, System.Collections.Generic.IReadOnlyList? weights = null, bool normalize = false) -> ChromaDB.Client.ChromaRank! +static ChromaDB.Client.ChromaRank.SparseKnn(ChromaDB.Client.Models.ChromaSparseVector! query, string! key, int limit = 16, double? defaultScore = null, bool returnRank = false) -> ChromaDB.Client.ChromaRank! +static ChromaDB.Client.ChromaRank.SparseKnn(string! query, string! key, int limit = 16, double? defaultScore = null, bool returnRank = false) -> ChromaDB.Client.ChromaRank! +static ChromaDB.Client.ChromaRank.Sum(params ChromaDB.Client.ChromaRank![]! ranks) -> ChromaDB.Client.ChromaRank! +static ChromaDB.Client.ChromaRank.Value(double value) -> ChromaDB.Client.ChromaRank! +static ChromaDB.Client.ChromaRank.implicit operator ChromaDB.Client.ChromaRank!(double value) -> ChromaDB.Client.ChromaRank! +static ChromaDB.Client.ChromaRank.operator *(ChromaDB.Client.ChromaRank! left, ChromaDB.Client.ChromaRank! right) -> ChromaDB.Client.ChromaRank! +static ChromaDB.Client.ChromaRank.operator +(ChromaDB.Client.ChromaRank! left, ChromaDB.Client.ChromaRank! right) -> ChromaDB.Client.ChromaRank! +static ChromaDB.Client.ChromaRank.operator -(ChromaDB.Client.ChromaRank! left, ChromaDB.Client.ChromaRank! right) -> ChromaDB.Client.ChromaRank! +static ChromaDB.Client.ChromaRank.operator -(ChromaDB.Client.ChromaRank! rank) -> ChromaDB.Client.ChromaRank! +static ChromaDB.Client.ChromaRank.operator /(ChromaDB.Client.ChromaRank! left, ChromaDB.Client.ChromaRank! right) -> ChromaDB.Client.ChromaRank! +static ChromaDB.Client.ChromaSearchAggregate.MaxK(int k, params string![]! keys) -> ChromaDB.Client.ChromaSearchAggregate! +static ChromaDB.Client.ChromaSearchAggregate.MinK(int k, params string![]! keys) -> ChromaDB.Client.ChromaSearchAggregate! +static ChromaDB.Client.ChromaWhereDocumentOperator.Contains(char value) -> ChromaDB.Client.ChromaWhereDocumentOperator! +static ChromaDB.Client.ChromaWhereDocumentOperator.Contains(string! value) -> ChromaDB.Client.ChromaWhereDocumentOperator! +static ChromaDB.Client.ChromaWhereDocumentOperator.NotContains(char value) -> ChromaDB.Client.ChromaWhereDocumentOperator! +static ChromaDB.Client.ChromaWhereDocumentOperator.NotContains(string! value) -> ChromaDB.Client.ChromaWhereDocumentOperator! +static ChromaDB.Client.ChromaWhereDocumentOperator.NotRegex(string! pattern) -> ChromaDB.Client.ChromaWhereDocumentOperator! +static ChromaDB.Client.ChromaWhereDocumentOperator.Regex(string! pattern) -> ChromaDB.Client.ChromaWhereDocumentOperator! +static ChromaDB.Client.ChromaWhereDocumentOperator.operator &(ChromaDB.Client.ChromaWhereDocumentOperator! lhs, ChromaDB.Client.ChromaWhereDocumentOperator! rhs) -> ChromaDB.Client.ChromaWhereDocumentOperator! +static ChromaDB.Client.ChromaWhereDocumentOperator.operator false(ChromaDB.Client.ChromaWhereDocumentOperator! _) -> bool +static ChromaDB.Client.ChromaWhereDocumentOperator.operator true(ChromaDB.Client.ChromaWhereDocumentOperator! _) -> bool +static ChromaDB.Client.ChromaWhereDocumentOperator.operator |(ChromaDB.Client.ChromaWhereDocumentOperator! lhs, ChromaDB.Client.ChromaWhereDocumentOperator! rhs) -> ChromaDB.Client.ChromaWhereDocumentOperator! +static ChromaDB.Client.ChromaWhereOperator.Contains(string! key, object! value) -> ChromaDB.Client.ChromaWhereOperator! +static ChromaDB.Client.ChromaWhereOperator.Equal(string! key, object! value) -> ChromaDB.Client.ChromaWhereOperator! +static ChromaDB.Client.ChromaWhereOperator.GreaterThan(string! key, object! value) -> ChromaDB.Client.ChromaWhereOperator! +static ChromaDB.Client.ChromaWhereOperator.GreaterThanOrEqual(string! key, object! value) -> ChromaDB.Client.ChromaWhereOperator! +static ChromaDB.Client.ChromaWhereOperator.In(string! key, params object![]! values) -> ChromaDB.Client.ChromaWhereOperator! +static ChromaDB.Client.ChromaWhereOperator.LessThan(string! key, object! value) -> ChromaDB.Client.ChromaWhereOperator! +static ChromaDB.Client.ChromaWhereOperator.LessThanOrEqual(string! key, object! value) -> ChromaDB.Client.ChromaWhereOperator! +static ChromaDB.Client.ChromaWhereOperator.NotContains(string! key, object! value) -> ChromaDB.Client.ChromaWhereOperator! +static ChromaDB.Client.ChromaWhereOperator.NotEqual(string! key, object! value) -> ChromaDB.Client.ChromaWhereOperator! +static ChromaDB.Client.ChromaWhereOperator.NotIn(string! key, params object![]! values) -> ChromaDB.Client.ChromaWhereOperator! +static ChromaDB.Client.ChromaWhereOperator.operator &(ChromaDB.Client.ChromaWhereOperator! lhs, ChromaDB.Client.ChromaWhereOperator! rhs) -> ChromaDB.Client.ChromaWhereOperator! +static ChromaDB.Client.ChromaWhereOperator.operator false(ChromaDB.Client.ChromaWhereOperator! _) -> bool +static ChromaDB.Client.ChromaWhereOperator.operator true(ChromaDB.Client.ChromaWhereOperator! _) -> bool +static ChromaDB.Client.ChromaWhereOperator.operator |(ChromaDB.Client.ChromaWhereOperator! lhs, ChromaDB.Client.ChromaWhereOperator! rhs) -> ChromaDB.Client.ChromaWhereOperator! +static ChromaDB.Client.Models.ChromaEmbeddingFunctionReference.ChromaBm25(double k = 1.2, double b = 0.75, double avgDocLength = 256, int tokenMaxLength = 40, bool includeTokens = false) -> ChromaDB.Client.Models.ChromaEmbeddingFunctionReference! +static ChromaDB.Client.Models.ChromaEmbeddingFunctionReference.Known(string! name, System.Collections.Generic.IReadOnlyDictionary? config = null) -> ChromaDB.Client.Models.ChromaEmbeddingFunctionReference! +virtual ChromaDB.Client.ChromaClient.CollectionExistsAsync(string! name, string? tenant = null, string? database = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! +virtual ChromaDB.Client.ChromaClient.CountCollectionsAsync(string? tenant = null, string? database = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! +virtual ChromaDB.Client.ChromaClient.CreateCollectionAsync(ChromaDB.Client.Models.ChromaCollectionDefinition! definition, string? tenant = null, string? database = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! +virtual ChromaDB.Client.ChromaClient.CreateCollectionAsync(string! name, System.Collections.Generic.IReadOnlyDictionary? metadata = null, string? tenant = null, string? database = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! +virtual ChromaDB.Client.ChromaClient.CreateDatabaseAsync(string! name, string? tenant = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! +virtual ChromaDB.Client.ChromaClient.CreateTenantAsync(string! name, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! +virtual ChromaDB.Client.ChromaClient.DeleteCollectionAsync(string! name, string? tenant = null, string? database = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! +virtual ChromaDB.Client.ChromaClient.DeleteDatabaseAsync(string! name, string? tenant = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! +virtual ChromaDB.Client.ChromaClient.Dispose(bool disposing) -> void +virtual ChromaDB.Client.ChromaClient.GetCollectionAsync(string! name, string? tenant = null, string? database = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! +virtual ChromaDB.Client.ChromaClient.GetCollectionByCrnAsync(string! crn, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! +virtual ChromaDB.Client.ChromaClient.GetCollectionByIdAsync(System.Guid id, string? tenant = null, string? database = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! +virtual ChromaDB.Client.ChromaClient.GetCollectionClient(ChromaDB.Client.Models.ChromaCollection! collection) -> ChromaDB.Client.ChromaCollectionClient! +virtual ChromaDB.Client.ChromaClient.GetCollectionClient(System.Guid collectionId, string! collectionName) -> ChromaDB.Client.ChromaCollectionClient! +virtual ChromaDB.Client.ChromaClient.GetDatabaseAsync(string! name, string? tenant = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! +virtual ChromaDB.Client.ChromaClient.GetOrCreateCollectionAsync(ChromaDB.Client.Models.ChromaCollectionDefinition! definition, string? tenant = null, string? database = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! +virtual ChromaDB.Client.ChromaClient.GetOrCreateCollectionAsync(string! name, System.Collections.Generic.IReadOnlyDictionary? metadata = null, string? tenant = null, string? database = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! +virtual ChromaDB.Client.ChromaClient.GetPreFlightChecksAsync(System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! +virtual ChromaDB.Client.ChromaClient.GetTenantAsync(string! name, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! +virtual ChromaDB.Client.ChromaClient.GetUserIdentityAsync(System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! +virtual ChromaDB.Client.ChromaClient.GetVersionAsync(System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! +virtual ChromaDB.Client.ChromaClient.HealthcheckAsync(System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! +virtual ChromaDB.Client.ChromaClient.HeartbeatAsync(System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! +virtual ChromaDB.Client.ChromaClient.ListCollectionsAsync(int limit, int offset = 0, string? tenant = null, string? database = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task!>! +virtual ChromaDB.Client.ChromaClient.ListCollectionsAsync(string? tenant = null, string? database = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task!>! +virtual ChromaDB.Client.ChromaClient.ListDatabasesAsync(int limit, int offset = 0, string? tenant = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task!>! +virtual ChromaDB.Client.ChromaClient.ListDatabasesAsync(string? tenant = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task!>! +virtual ChromaDB.Client.ChromaClient.Options.get -> ChromaDB.Client.ChromaConfigurationOptions! +virtual ChromaDB.Client.ChromaClient.ResetAsync(System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! +virtual ChromaDB.Client.ChromaClient.UpdateTenantAsync(string! name, string! resourceName, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! +virtual ChromaDB.Client.ChromaClient.WithMetadataValues(ChromaDB.Client.ChromaMetadataValues metadataValues) -> ChromaDB.Client.ChromaClient! +virtual ChromaDB.Client.ChromaClient.WithTenantAndDatabaseFromIdentityAsync(System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! +virtual ChromaDB.Client.ChromaCollectionClient.AddAsync(ChromaDB.Client.Models.ChromaRecords! records, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! +virtual ChromaDB.Client.ChromaCollectionClient.AddAsync(System.Collections.Generic.IReadOnlyList! ids, System.Collections.Generic.IReadOnlyList>? embeddings = null, System.Collections.Generic.IReadOnlyList!>? metadatas = null, System.Collections.Generic.IReadOnlyList? documents = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! +virtual ChromaDB.Client.ChromaCollectionClient.AttachFunctionAsync(string! function, string! name, string! outputCollection, System.Collections.Generic.IReadOnlyDictionary? parameters = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task<(ChromaDB.Client.Models.ChromaAttachedFunction! AttachedFunction, bool Created)>! +virtual ChromaDB.Client.ChromaCollectionClient.Collection.get -> ChromaDB.Client.Models.ChromaCollection! +virtual ChromaDB.Client.ChromaCollectionClient.CountAsync(ChromaDB.Client.ChromaReadLevel readLevel, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! +virtual ChromaDB.Client.ChromaCollectionClient.CountAsync(System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! +virtual ChromaDB.Client.ChromaCollectionClient.DeleteAsync(ChromaDB.Client.Models.ChromaDelete! delete, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! +virtual ChromaDB.Client.ChromaCollectionClient.DeleteAsync(System.Collections.Generic.IReadOnlyList! ids, ChromaDB.Client.ChromaWhereOperator? where = null, ChromaDB.Client.ChromaWhereDocumentOperator? whereDocument = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! +virtual ChromaDB.Client.ChromaCollectionClient.DetachFunctionAsync(string! name, bool deleteOutputCollection = false, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! +virtual ChromaDB.Client.ChromaCollectionClient.ForkAsync(string! newName, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! +virtual ChromaDB.Client.ChromaCollectionClient.ForkCountAsync(System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! +virtual ChromaDB.Client.ChromaCollectionClient.GetAsync(System.Collections.Generic.IReadOnlyList? ids = null, ChromaDB.Client.ChromaWhereOperator? where = null, ChromaDB.Client.ChromaWhereDocumentOperator? whereDocument = null, int? limit = null, int? offset = null, ChromaDB.Client.ChromaGetInclude? include = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task!>! +virtual ChromaDB.Client.ChromaCollectionClient.GetAsync(string! id, ChromaDB.Client.ChromaWhereOperator? where = null, ChromaDB.Client.ChromaWhereDocumentOperator? whereDocument = null, ChromaDB.Client.ChromaGetInclude? include = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! +virtual ChromaDB.Client.ChromaCollectionClient.GetAttachedFunctionAsync(string! name, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! +virtual ChromaDB.Client.ChromaCollectionClient.GetIndexingStatusAsync(System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! +virtual ChromaDB.Client.ChromaCollectionClient.ModifyAsync(string? name = null, System.Collections.Generic.IReadOnlyDictionary? metadata = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! +virtual ChromaDB.Client.ChromaCollectionClient.ModifyConfigurationAsync(ChromaDB.Client.Models.ChromaCollectionConfigurationUpdate! configuration, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! +virtual ChromaDB.Client.ChromaCollectionClient.PeekAsync(int limit = 10, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task!>! +virtual ChromaDB.Client.ChromaCollectionClient.QueryAsync(ChromaDB.Client.Models.ChromaQuery! query, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task!>!>! +virtual ChromaDB.Client.ChromaCollectionClient.QueryAsync(System.Collections.Generic.IReadOnlyList>! queryEmbeddings, int nResults = 10, ChromaDB.Client.ChromaWhereOperator? where = null, ChromaDB.Client.ChromaWhereDocumentOperator? whereDocument = null, ChromaDB.Client.ChromaQueryInclude? include = null, System.Collections.Generic.IReadOnlyList? ids = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task!>!>! +virtual ChromaDB.Client.ChromaCollectionClient.QueryAsync(System.ReadOnlyMemory queryEmbeddings, int nResults = 10, ChromaDB.Client.ChromaWhereOperator? where = null, ChromaDB.Client.ChromaWhereDocumentOperator? whereDocument = null, ChromaDB.Client.ChromaQueryInclude? include = null, System.Collections.Generic.IReadOnlyList? ids = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task!>! +virtual ChromaDB.Client.ChromaCollectionClient.SearchAsync(ChromaDB.Client.Models.ChromaSearch! search, ChromaDB.Client.ChromaReadLevel? readLevel = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task!>! +virtual ChromaDB.Client.ChromaCollectionClient.SearchAsync(System.Collections.Generic.IReadOnlyList! searches, ChromaDB.Client.ChromaReadLevel? readLevel = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task!>!>! +virtual ChromaDB.Client.ChromaCollectionClient.UpdateAsync(ChromaDB.Client.Models.ChromaRecords! records, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! +virtual ChromaDB.Client.ChromaCollectionClient.UpdateAsync(System.Collections.Generic.IReadOnlyList! ids, System.Collections.Generic.IReadOnlyList>? embeddings = null, System.Collections.Generic.IReadOnlyList!>? metadatas = null, System.Collections.Generic.IReadOnlyList? documents = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! +virtual ChromaDB.Client.ChromaCollectionClient.UpsertAsync(ChromaDB.Client.Models.ChromaRecords! records, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! +virtual ChromaDB.Client.ChromaCollectionClient.UpsertAsync(System.Collections.Generic.IReadOnlyList! ids, System.Collections.Generic.IReadOnlyList>? embeddings = null, System.Collections.Generic.IReadOnlyList!>? metadatas = null, System.Collections.Generic.IReadOnlyList? documents = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! diff --git a/ChromaDB.Client/PublicAPI.Unshipped.txt b/ChromaDB.Client/PublicAPI.Unshipped.txt new file mode 100644 index 0000000..b570968 --- /dev/null +++ b/ChromaDB.Client/PublicAPI.Unshipped.txt @@ -0,0 +1,74 @@ +#nullable enable +virtual ChromaDB.Client.ChromaClient.DeleteCollectionAsync(string! name, bool deleteRecordsFirst, string? tenant = null, string? database = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! +virtual ChromaDB.Client.ChromaClient.DeleteCollectionIfExistsAsync(string! name, string? tenant = null, string? database = null, bool deleteRecordsFirst = false, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! +virtual ChromaDB.Client.ChromaClient.DeleteDatabaseAsync(string! name, bool deleteRecordsFirst, string? tenant = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! +ChromaDB.Client.ChromaCloudQuotas +ChromaDB.Client.ChromaConfigurationOptions.IsChromaCloud.get -> bool +static readonly ChromaDB.Client.ChromaCloudQuotas.MaxDocumentBytes -> int +static readonly ChromaDB.Client.ChromaCloudQuotas.MaxDocumentFilterBytes -> int +static readonly ChromaDB.Client.ChromaCloudQuotas.MaxEmbeddingDimensions -> int +static readonly ChromaDB.Client.ChromaCloudQuotas.MaxIdBytes -> int +static readonly ChromaDB.Client.ChromaCloudQuotas.MaxMetadataKeyBytes -> int +static readonly ChromaDB.Client.ChromaCloudQuotas.MaxMetadataKeys -> int +static readonly ChromaDB.Client.ChromaCloudQuotas.MaxMetadataValueBytes -> int +static readonly ChromaDB.Client.ChromaCloudQuotas.MaxRecordsPerRequest -> int +static readonly ChromaDB.Client.ChromaCloudQuotas.MaxUriBytes -> int +static readonly ChromaDB.Client.ChromaCloudQuotas.MaxWherePredicates -> int +ChromaDB.Client.Models.ChromaQuery.Offset.get -> int +ChromaDB.Client.Models.ChromaQuery.Offset.init -> void +ChromaDB.Client.Models.ChromaCollection.FindBm25Index(string! sourceKey) -> ChromaDB.Client.Models.ChromaSparseVectorIndex? +static ChromaDB.Client.ChromaRank.HybridRrf(System.ReadOnlyMemory embedding, string! text, string! sparseKey, int limit, double k = 60, string! embeddingKey = "#embedding") -> ChromaDB.Client.ChromaRank! +static ChromaDB.Client.ChromaWhereDocumentOperator.Not(ChromaDB.Client.ChromaWhereDocumentOperator! filter) -> ChromaDB.Client.ChromaWhereDocumentOperator! +static ChromaDB.Client.ChromaWhereOperator.All.get -> ChromaDB.Client.ChromaWhereOperator! +static ChromaDB.Client.ChromaWhereOperator.None.get -> ChromaDB.Client.ChromaWhereOperator! +static ChromaDB.Client.ChromaWhereOperator.Not(ChromaDB.Client.ChromaWhereOperator! filter) -> ChromaDB.Client.ChromaWhereOperator! +ChromaDB.Client.ChromaMetadataConvert +static ChromaDB.Client.ChromaMetadataConvert.FromMetadataValue(object? value, System.Type! type) -> object? +static ChromaDB.Client.ChromaMetadataConvert.ToMetadataValue(object? value) -> object? +ChromaDB.Client.Models.ChromaRecords.NullDocumentsDelete.get -> bool +ChromaDB.Client.Models.ChromaRecords.NullDocumentsDelete.init -> void +virtual ChromaDB.Client.ChromaClient.GetCollectionClient(string! name) -> ChromaDB.Client.ChromaCollectionClient! +virtual ChromaDB.Client.ChromaCollectionClient.GetCollectionAsync(System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! +virtual ChromaDB.Client.ChromaCollectionClient.WithDocumentCopyKey(string! documentCopyKey) -> ChromaDB.Client.ChromaCollectionClient! +virtual ChromaDB.Client.ChromaCollectionClient.WithDocumentDeletionKey(string! documentDeletionKey) -> ChromaDB.Client.ChromaCollectionClient! +virtual ChromaDB.Client.ChromaCollectionClient.WithEmptyDocumentDeletions() -> ChromaDB.Client.ChromaCollectionClient! +ChromaDB.Client.Models.ChromaQuery.ExpectedSpace.get -> ChromaDB.Client.ChromaSpace? +ChromaDB.Client.Models.ChromaQuery.ExpectedSpace.init -> void +static ChromaDB.Client.ChromaWhereOperator.Document(ChromaDB.Client.ChromaWhereDocumentOperator! filter) -> ChromaDB.Client.ChromaWhereOperator! +static ChromaDB.Client.ChromaWhereDocumentOperator.FromDictionary(System.Collections.Generic.IReadOnlyDictionary! whereDocument) -> ChromaDB.Client.ChromaWhereDocumentOperator! +static ChromaDB.Client.ChromaWhereOperator.FromDictionary(System.Collections.Generic.IReadOnlyDictionary! where) -> ChromaDB.Client.ChromaWhereOperator! +*REMOVED*ChromaDB.Client.Models.ChromaRecords.Documents.get -> System.Collections.Generic.IReadOnlyList? +*REMOVED*ChromaDB.Client.Models.ChromaRecords.Metadatas.get -> System.Collections.Generic.IReadOnlyList!>? +ChromaDB.Client.Models.ChromaRecords.Documents.get -> System.Collections.Generic.IReadOnlyList? +ChromaDB.Client.Models.ChromaRecords.Metadatas.get -> System.Collections.Generic.IReadOnlyList?>? +static ChromaDB.Client.ChromaMetadataConvert.ToMetadata(System.Collections.Generic.IEnumerable>! values) -> System.Collections.Generic.IReadOnlyDictionary? +virtual ChromaDB.Client.ChromaCollectionClient.WithMetadataValues(ChromaDB.Client.ChromaMetadataValues metadataValues) -> ChromaDB.Client.ChromaCollectionClient! +ChromaDB.Client.Models.ChromaCollectionSchema.WithBm25Index(string! sourceKey, bool ifSupported = false) -> ChromaDB.Client.Models.ChromaCollectionSchema! +virtual ChromaDB.Client.ChromaCollectionClient.FindBm25IndexAsync(string! sourceKey, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! +ChromaDB.Client.ChromaConfigurationOptions.WithChromaCloud(bool chromaCloud = true) -> ChromaDB.Client.ChromaConfigurationOptions! +ChromaDB.Client.ChromaUpsertStrategy +ChromaDB.Client.ChromaUpsertStrategy.Server = 0 -> ChromaDB.Client.ChromaUpsertStrategy +ChromaDB.Client.ChromaConfigurationOptions.UpsertStrategy.get -> ChromaDB.Client.ChromaUpsertStrategy +ChromaDB.Client.ChromaConfigurationOptions.UpsertStrategy.init -> void +ChromaDB.Client.ChromaConfigurationOptions.WithUpsertStrategy(ChromaDB.Client.ChromaUpsertStrategy strategy) -> ChromaDB.Client.ChromaConfigurationOptions! +ChromaDB.Client.ChromaConfigurationOptions.ReadsBeyondKeyQuota.get -> bool +ChromaDB.Client.ChromaConfigurationOptions.ReadsBeyondKeyQuota.init -> void +ChromaDB.Client.ChromaConfigurationOptions.WithReadsBeyondKeyQuota(bool readsBeyondKeyQuota = true) -> ChromaDB.Client.ChromaConfigurationOptions! +ChromaDB.Client.ChromaUpsertStrategy.SkipUnchangedEmbeddings = 1 -> ChromaDB.Client.ChromaUpsertStrategy +ChromaDB.Client.ChromaConfigurationOptions.ReadRetries.get -> ChromaDB.Client.ChromaReadRetries! +ChromaDB.Client.ChromaConfigurationOptions.ReadRetries.init -> void +ChromaDB.Client.ChromaConfigurationOptions.WithReadRetries(ChromaDB.Client.ChromaReadRetries! readRetries) -> ChromaDB.Client.ChromaConfigurationOptions! +ChromaDB.Client.ChromaReadRetries +ChromaDB.Client.ChromaReadRetries.BaseDelay.get -> System.TimeSpan +ChromaDB.Client.ChromaReadRetries.ChromaReadRetries(int maxAttempts, System.TimeSpan baseDelay, System.TimeSpan maxDelay) -> void +ChromaDB.Client.ChromaReadRetries.ChromaReadRetries(int maxAttempts, System.TimeSpan minDelay, System.TimeSpan baseDelay, System.TimeSpan maxDelay) -> void +ChromaDB.Client.ChromaReadRetries.MaxAttempts.get -> int +ChromaDB.Client.ChromaReadRetries.MaxDelay.get -> System.TimeSpan +ChromaDB.Client.ChromaReadRetries.MinDelay.get -> System.TimeSpan +static ChromaDB.Client.ChromaReadRetries.Default.get -> ChromaDB.Client.ChromaReadRetries! +static ChromaDB.Client.ChromaReadRetries.None.get -> ChromaDB.Client.ChromaReadRetries! +virtual ChromaDB.Client.ChromaCollectionClient.DeleteAllAsync(System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! +virtual ChromaDB.Client.ChromaClient.GetCollectionClient(ChromaDB.Client.Models.ChromaCollectionDefinition! definition, bool createIfMissing) -> ChromaDB.Client.ChromaCollectionClient! +virtual ChromaDB.Client.ChromaClient.GetCollectionClient(string! name, bool createIfMissing) -> ChromaDB.Client.ChromaCollectionClient! +virtual ChromaDB.Client.ChromaClient.GetOrCreateCollectionClientAsync(ChromaDB.Client.Models.ChromaCollectionDefinition! definition, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! +virtual ChromaDB.Client.ChromaClient.GetOrCreateCollectionClientAsync(string! name, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! diff --git a/ChromaDB.Client/PublicAPI/EmbeddingGenerators/PublicAPI.Shipped.txt b/ChromaDB.Client/PublicAPI/EmbeddingGenerators/PublicAPI.Shipped.txt new file mode 100644 index 0000000..7dc5c58 --- /dev/null +++ b/ChromaDB.Client/PublicAPI/EmbeddingGenerators/PublicAPI.Shipped.txt @@ -0,0 +1 @@ +#nullable enable diff --git a/ChromaDB.Client/PublicAPI/EmbeddingGenerators/PublicAPI.Unshipped.txt b/ChromaDB.Client/PublicAPI/EmbeddingGenerators/PublicAPI.Unshipped.txt new file mode 100644 index 0000000..cbaf78e --- /dev/null +++ b/ChromaDB.Client/PublicAPI/EmbeddingGenerators/PublicAPI.Unshipped.txt @@ -0,0 +1,7 @@ +#nullable enable +ChromaDB.Client.Models.ChromaCollectionDefinition.EmbeddingGenerator.get -> Microsoft.Extensions.AI.IEmbeddingGenerator!>? +ChromaDB.Client.Models.ChromaCollectionDefinition.EmbeddingGenerator.init -> void +virtual ChromaDB.Client.ChromaCollectionClient.EmbeddingGenerator.get -> Microsoft.Extensions.AI.IEmbeddingGenerator!>? +virtual ChromaDB.Client.ChromaCollectionClient.QueryAsync(string! queryText, int nResults = 10, ChromaDB.Client.ChromaWhereOperator? where = null, ChromaDB.Client.ChromaWhereDocumentOperator? whereDocument = null, ChromaDB.Client.ChromaQueryInclude? include = null, System.Collections.Generic.IReadOnlyList? ids = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task!>! +virtual ChromaDB.Client.ChromaCollectionClient.QueryTextsAsync(System.Collections.Generic.IReadOnlyList! queryTexts, int nResults = 10, ChromaDB.Client.ChromaWhereOperator? where = null, ChromaDB.Client.ChromaWhereDocumentOperator? whereDocument = null, ChromaDB.Client.ChromaQueryInclude? include = null, System.Collections.Generic.IReadOnlyList? ids = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task!>!>! +virtual ChromaDB.Client.ChromaCollectionClient.WithEmbeddingGenerator(Microsoft.Extensions.AI.IEmbeddingGenerator!>! embeddingGenerator) -> ChromaDB.Client.ChromaCollectionClient! diff --git a/LICENSE b/LICENSE index f3581a5..2b79be0 100644 --- a/LICENSE +++ b/LICENSE @@ -1,4 +1,5 @@ Copyright (c) 2024 Nedeljko Savic +Copyright (c) 2026 ChromaDotNet contributors Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/README.md b/README.md index 70f3205..d20bc25 100644 --- a/README.md +++ b/README.md @@ -1,27 +1,88 @@ +[![ChromaDotNet](https://raw.githubusercontent.com/ChromaDotNet/.github/main/assets/logo-64.png)](https://chromadotnet.org) + # ChromaDB.Client -_ChromaDB.Client_ is a .NET SDK that offers a seamless connection to the Chroma database. It allows creating and managing collections, performing CRUD operations, and executing nearest neighbor search and filtering. +[![NuGet](https://img.shields.io/nuget/v/ChromaDotNet.Client)](https://www.nuget.org/packages/ChromaDotNet.Client/) +[![CI](https://img.shields.io/github/actions/workflow/status/ChromaDotNet/ChromaDB.Client/ci.yml?branch=main)](https://github.com/ChromaDotNet/ChromaDB.Client/actions/workflows/ci.yml) +[![License](https://img.shields.io/github/license/ChromaDotNet/ChromaDB.Client)](https://github.com/ChromaDotNet/ChromaDB.Client/blob/main/LICENSE) +[![OpenSSF Scorecard](https://api.scorecard.dev/projects/github.com/ChromaDotNet/ChromaDB.Client/badge)](https://scorecard.dev/viewer/?uri=github.com/ChromaDotNet/ChromaDB.Client) +[![OpenSSF Best Practices](https://www.bestpractices.dev/projects/15264/badge)](https://www.bestpractices.dev/projects/15264) + +[![Open in GitHub Codespaces](https://github.com/codespaces/badge.svg)](https://codespaces.new/ChromaDotNet/ChromaDB.Client?quickstart=1) + +_ChromaDB.Client_, published on NuGet as `ChromaDotNet.Client`, is a .NET client for Chroma and Chroma Cloud. It covers: + +- collections, tenants and databases; +- writing and reading records, and nearest neighbor queries with filters; +- the Search API of Chroma Cloud, with hybrid BM25 search; +- OpenTelemetry traces and metrics. + +> This is a community project, the continuation of [ssone95/ChromaDB.Client](https://github.com/ssone95/ChromaDB.Client), kept up to date with current Chroma versions. It is not affiliated with or endorsed by Chroma. See [About this fork](https://github.com/ChromaDotNet/ChromaDB.Client/blob/main/docs/about-this-fork.md). + +Website: [chromadotnet.org](https://chromadotnet.org) + +## Used by + +- [CommunityToolkit.VectorData.Chroma](https://www.nuget.org/packages/CommunityToolkit.VectorData.Chroma): the Chroma provider for Microsoft.Extensions.VectorData, in the [AI Community Toolkit](https://github.com/CommunityToolkit/AI/blob/546aa1b8e306e7518b0225341ea62cd86c916297/MEVD/src/Chroma/Chroma.csproj#L18) of the .NET Foundation. It works with Semantic Kernel and Agent Framework. + - Used by: + - [Generative AI for Beginners .NET](https://github.com/microsoft/Generative-AI-for-beginners-dotnet/blob/359ffe5adf0cb74a397fd99af8786e14d0930ca3/samples/CoreSamples/RAGSimple-05MEAIVectorsChroma/RAGSimple-05MEAIVectorsChroma.csproj#L12): a RAG sample in the Microsoft course. + - [LangChain .NET](https://github.com/tryAGI/LangChain/blob/4d8062616101cced1c6737aac7bc76535f6efc86/examples/LangChain.Samples.Chroma/LangChain.Samples.Chroma.csproj#L16): a RAG sample with Chroma. + - [LangChain.Databases](https://github.com/tryAGI/LangChain.Databases/blob/17c118f863ef1437b4ecaa0222081eb045b38a87/src/IntegrationTests/LangChain.Databases.IntegrationTests.csproj#L19): the integration tests. + - Cited in: + - [Semantic Kernel](https://github.com/microsoft/semantic-kernel/blob/7e68276a4a85022dd4da255beae3263ab5cbf059/dotnet/src/VectorData/Chroma/README.md#L1): recommended for Chroma in .NET. + - [LangChain.Databases](https://github.com/tryAGI/LangChain.Databases/blob/17c118f863ef1437b4ecaa0222081eb045b38a87/README.md#L36): recommended for Chroma in its README. + - [Microsoft Agent Framework](https://github.com/microsoft/agent-framework/blob/deecbcb062198f926516f89b65e5b1676e4514c2/dotnet/samples/community-projects.md#L26): listed among the .NET community projects. +- CommunityToolkit.Aspire.Chroma: the Chroma client integration, in the [Aspire Community Toolkit](https://github.com/CommunityToolkit/Aspire/blob/523b84b27c5d019d242d95c2252046ba3619303c/src/CommunityToolkit.Aspire.Chroma/CommunityToolkit.Aspire.Chroma.csproj#L12) of the .NET Foundation. +- [BotSharp.Plugin.Chroma](https://github.com/SciSharp/BotSharp/blob/c8f632bf13bb22aed53c8ba63d41ee9e0e0fb75b/src/Plugins/BotSharp.Plugin.Chroma/BotSharp.Plugin.Chroma.csproj#L14): the Chroma vector database plugin of BotSharp, by SciSharp. +- [LlmTornado.VectorDatabases.ChromaDB](https://github.com/lofcz/LLMTornado/blob/d619b5d4c6558094cc2fcb663a5d6b55e6871233/src/LlmTornado.VectorDatabases.ChromaDB/LlmTornado.VectorDatabases.ChromaDB.csproj#L17): the Chroma connector of LLM Tornado. + +## Try it in your browser + +Click **Open in GitHub Codespaces** above. The Codespace has .NET 10 and Chroma on port 8000. When the terminal is ready, run: + +``` +dotnet run Samples/quickstart.cs +``` + +The [quickstart](https://github.com/ChromaDotNet/ChromaDB.Client/blob/main/Samples/quickstart.cs) takes `ChromaDotNet.Client` from NuGet, creates a collection, adds records with documents and metadata, queries them by similarity and by metadata, and deletes the collection. The embeddings are written by hand, so no model or key is needed. + +You need a GitHub account. The Codespace uses the free Codespaces hours of your account. + +## Installation + +``` +dotnet add package ChromaDotNet.Client +``` + +`ChromaDotNet.Client.DependencyInjection` for `Microsoft.Extensions.DependencyInjection`: [Dependency injection](https://github.com/ChromaDotNet/ChromaDB.Client/blob/main/docs/dependency-injection.md). + +To run Chroma on your machine without Docker: [ChromaDotNet.Local](https://www.nuget.org/packages/ChromaDotNet.Local). + +To compute the embeddings as the Python client does: [ChromaDotNet.Embeddings](https://www.nuget.org/packages/ChromaDotNet.Embeddings), see [Embeddings](https://github.com/ChromaDotNet/ChromaDB.Client/blob/main/docs/embeddings.md). ## Example +The models, like `ChromaRecords`, `ChromaQuery` and `ChromaCollectionDefinition`, are in `ChromaDB.Client.Models`. `AddChromaClient` is in `ChromaDB.Client.DependencyInjection`. + ```csharp using ChromaDB.Client; +using ChromaDB.Client.Models; -var configOptions = new ChromaConfigurationOptions(uri: "http://localhost:8000/api/v1/"); +var configOptions = new ChromaConfigurationOptions(uri: "http://localhost:8000/api/v2/"); using var httpClient = new HttpClient(); var client = new ChromaClient(configOptions, httpClient); -Console.WriteLine(await client.GetVersion()); +Console.WriteLine(await client.GetVersionAsync()); -var string5Collection = await client.GetOrCreateCollection("string5"); +var string5Collection = await client.GetOrCreateCollectionAsync("string5"); var string5Client = new ChromaCollectionClient(string5Collection, configOptions, httpClient); -await string5Client.Add(["340a36ad-c38a-406c-be38-250174aee5a4"], embeddings: [new([1f, 0.5f, 0f, -0.5f, -1f])]); +await string5Client.AddAsync(["340a36ad-c38a-406c-be38-250174aee5a4"], embeddings: [new([1f, 0.5f, 0f, -0.5f, -1f])]); -var getResult = await string5Client.Get("340a36ad-c38a-406c-be38-250174aee5a4", include: ChromaGetInclude.Metadatas | ChromaGetInclude.Documents | ChromaGetInclude.Embeddings); +var getResult = await string5Client.GetAsync("340a36ad-c38a-406c-be38-250174aee5a4", include: ChromaGetInclude.Metadatas | ChromaGetInclude.Documents | ChromaGetInclude.Embeddings); Console.WriteLine($"ID: {getResult!.Id}"); -var queryData = await string5Client.Query([new([1f, 0.5f, 0f, -0.5f, -1f]), new([1.5f, 0f, 2f, -1f, -1.5f])], include: ChromaQueryInclude.Metadatas | ChromaQueryInclude.Distances); +var queryData = await string5Client.QueryAsync([new([1f, 0.5f, 0f, -0.5f, -1f]), new([1.5f, 0f, 2f, -1f, -1.5f])], include: ChromaQueryInclude.Metadatas | ChromaQueryInclude.Distances); foreach (var item in queryData) { foreach (var entry in item) @@ -31,10 +92,42 @@ foreach (var item in queryData) } ``` -## Status +The URI, the `HttpClient` and the use from more threads: [Connecting](https://github.com/ChromaDotNet/ChromaDB.Client/blob/main/docs/connecting.md). + +## Compatibility + +[docs/COMPATIBILITY.md](https://github.com/ChromaDotNet/ChromaDB.Client/blob/main/docs/COMPATIBILITY.md) lists every tested release, the differences between them and the versions the CI runs. + +The [compatibility table](https://chromadotnet.org/compatibility/) shows every check of the latest release on Chroma 1.5.9 and on Chroma Cloud, on each .NET runtime and platform. + +## Documentation + +- [Connecting](https://github.com/ChromaDotNet/ChromaDB.Client/blob/main/docs/connecting.md): the URI, the `HttpClient`, threads, the API version, authentication and connection strings. +- [Dependency injection](https://github.com/ChromaDotNet/ChromaDB.Client/blob/main/docs/dependency-injection.md): `AddChromaClient` and `AddKeyedChromaClient`. +- [Collections](https://github.com/ChromaDotNet/ChromaDB.Client/blob/main/docs/collections.md): collections, tenants and databases, collections by id. +- [Records](https://github.com/ChromaDotNet/ChromaDB.Client/blob/main/docs/records.md): URIs, large writes, deletes and base64 embeddings. +- [Embeddings](https://github.com/ChromaDotNet/ChromaDB.Client/blob/main/docs/embeddings.md): embeddings from an `IEmbeddingGenerator`, query texts and the default embedding function of Chroma. +- [Metadata values](https://github.com/ChromaDotNet/ChromaDB.Client/blob/main/docs/metadata.md): values, lists, deletions in updates and upserts, document copies, conversions. +- [Queries and filters](https://github.com/ChromaDotNet/ChromaDB.Client/blob/main/docs/queries-and-filters.md): queries, `GetAsync` in pages, where and document filters. +- [Upsert strategies](https://github.com/ChromaDotNet/ChromaDB.Client/blob/main/docs/upsert-strategies.md): `Server` and `SkipUnchangedEmbeddings`. +- [Index settings](https://github.com/ChromaDotNet/ChromaDB.Client/blob/main/docs/index-settings.md): the distance, the HNSW and SPANN settings, `ModifyConfigurationAsync`. +- [Errors](https://github.com/ChromaDotNet/ChromaDB.Client/blob/main/docs/errors.md): `ChromaException` and `CollectionExistsAsync`. +- [Mocks in tests](https://github.com/ChromaDotNet/ChromaDB.Client/blob/main/docs/mocking.md): mocking `ChromaClient` and `ChromaCollectionClient`. +- [Health, traces and metrics](https://github.com/ChromaDotNet/ChromaDB.Client/blob/main/docs/observability.md): `HealthcheckAsync` and OpenTelemetry. +- [Chroma Cloud](https://github.com/ChromaDotNet/ChromaDB.Client/blob/main/docs/chroma-cloud.md): options, identity, batches, quotas and the operations of Chroma Cloud. +- [Search](https://github.com/ChromaDotNet/ChromaDB.Client/blob/main/docs/search.md): the Search API and hybrid search with BM25. +- [Sparse vectors and schema](https://github.com/ChromaDotNet/ChromaDB.Client/blob/main/docs/sparse-vectors-and-schema.md): sparse vectors, BM25 indexes and the indexes of a schema. +- [Platforms](https://github.com/ChromaDotNet/ChromaDB.Client/blob/main/docs/platforms.md): the builds, trimming and NativeAOT. +- [About this fork](https://github.com/ChromaDotNet/ChromaDB.Client/blob/main/docs/about-this-fork.md): the original project and what this fork does. + +## Migrating from ChromaDB.Client 1.x + +The [migration guide](https://github.com/ChromaDotNet/ChromaDB.Client/blob/main/v2-migration/MIGRATION_GUIDE_V2.md) has the details, and the [release notes](https://github.com/ChromaDotNet/ChromaDB.Client/releases) list the changes of every version. + +## Contributing + +[CONTRIBUTING.md](https://github.com/ChromaDotNet/ChromaDB.Client/blob/main/CONTRIBUTING.md) and [SECURITY.md](https://github.com/ChromaDotNet/ChromaDB.Client/blob/main/SECURITY.md). + +## License -[![NuGet Downloads](https://img.shields.io/nuget/dt/ChromaDB.Client)](https://www.nuget.org/packages/ChromaDB.Client/) -[![NuGet](https://img.shields.io/nuget/v/ChromaDB.Client)](https://www.nuget.org/packages/ChromaDB.Client/) -[![NuGet Prerelease](https://img.shields.io/nuget/vpre/ChromaDB.Client)](https://www.nuget.org/packages/ChromaDB.Client/) -[![License](https://img.shields.io/github/license/ssone95/ChromaDB.Client)](https://github.com/ssone95/ChromaDB.Client/LICENSE) -[![CI](https://img.shields.io/github/actions/workflow/status/ssone95/ChromaDB.Client/ci.yml)](https://github.com/ssone95/ChromaDB.Client/actions/workflows/ci.yml) +The project is released under the MIT License, keeping the original copyright notice. diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..1972c7b --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,47 @@ +# Security + +## Supported versions + +Security fixes go into the latest release of `ChromaDotNet.Client` and `ChromaDotNet.Client.DependencyInjection` on NuGet. Earlier versions do not get fixes: update to the latest one. + +## Reporting a vulnerability + +Please do not report a vulnerability in a public issue, discussion or pull request. + +Report it privately on GitHub instead: [Report a vulnerability](https://github.com/ChromaDotNet/ChromaDB.Client/security/advisories/new), in the **Security** tab of this repository. Only the maintainers see the report. + +Include the version of the package, the version of Chroma or Chroma Cloud, and the steps to reproduce the problem. + +## What happens next + +- We acknowledge the report within 7 days. +- We confirm or rule out the vulnerability within 14 days, and keep you informed of the progress. +- A confirmed vulnerability is fixed in a new release. A GitHub security advisory follows the release, with credit to you unless you prefer otherwise. +- Please keep the details private until the advisory is published, and for at most 90 days from the report. + +## How the packages are published + +- Only the CI publishes the packages, from the version tags of this repository, with [NuGet trusted publishing](https://learn.microsoft.com/nuget/nuget-org/trusted-publishing): no NuGet API key is kept by a person or in the repository. +- The organization requires two-factor authentication. +- The workflows pin their actions to commit hashes. Dependabot and CodeQL check the dependencies and the code. + +## Verifying a package + +From 2.10.2 on, each GitHub release has the packages that the CI built from the version tag, with: + +- their Sigstore signatures (`.sigstore.json`), made by the release workflow of this repository; +- their build provenance (`provenance-.intoto.jsonl`), attested by the CI. + +nuget.org serves the same packages with its repository signature (`.signature.p7s`) added: the release workflow checks that every other file is the same. Earlier releases have no build provenance. + +To verify a package downloaded from the release, with Cosign 3 or later: + +``` +cosign verify-blob ChromaDotNet.Client.2.11.0.nupkg --bundle ChromaDotNet.Client.2.11.0.nupkg.sigstore.json --certificate-identity-regexp '^https://github[.]com/ChromaDotNet/ChromaDB[.]Client/[.]github/workflows/release-assets[.]yml@' --certificate-oidc-issuer https://token.actions.githubusercontent.com +``` + +With the GitHub CLI: + +``` +gh attestation verify ChromaDotNet.Client.2.11.0.nupkg --bundle provenance-v2.11.0.intoto.jsonl --repo ChromaDotNet/ChromaDB.Client --signer-workflow ChromaDotNet/ChromaDB.Client/.github/workflows/ci.yml --source-ref refs/tags/v2.11.0 +``` diff --git a/Samples/ChromaDB.Client.Sample/Program.cs b/Samples/ChromaDB.Client.Sample/Program.cs index af935aa..761ed44 100644 --- a/Samples/ChromaDB.Client.Sample/Program.cs +++ b/Samples/ChromaDB.Client.Sample/Program.cs @@ -1,20 +1,20 @@ using ChromaDB.Client; -var configOptions = new ChromaConfigurationOptions(uri: "http://localhost:8000/api/v1/"); +var configOptions = new ChromaConfigurationOptions(uri: "http://localhost:8000/api/v2/"); using var httpClient = new HttpClient(); var client = new ChromaClient(configOptions, httpClient); -Console.WriteLine(await client.GetVersion()); +Console.WriteLine(await client.GetVersionAsync()); -var string5Collection = await client.GetOrCreateCollection("string5"); +var string5Collection = await client.GetOrCreateCollectionAsync("string5"); var string5Client = new ChromaCollectionClient(string5Collection, configOptions, httpClient); -await string5Client.Add(["340a36ad-c38a-406c-be38-250174aee5a4"], embeddings: [new([1f, 0.5f, 0f, -0.5f, -1f])]); +await string5Client.AddAsync(["340a36ad-c38a-406c-be38-250174aee5a4"], embeddings: [new([1f, 0.5f, 0f, -0.5f, -1f])]); -var getResult = await string5Client.Get("340a36ad-c38a-406c-be38-250174aee5a4", include: ChromaGetInclude.Metadatas | ChromaGetInclude.Documents | ChromaGetInclude.Embeddings); +var getResult = await string5Client.GetAsync("340a36ad-c38a-406c-be38-250174aee5a4", include: ChromaGetInclude.Metadatas | ChromaGetInclude.Documents | ChromaGetInclude.Embeddings); Console.WriteLine($"ID: {getResult!.Id}"); -var queryData = await string5Client.Query([new([1f, 0.5f, 0f, -0.5f, -1f]), new([1.5f, 0f, 2f, -1f, -1.5f])], include: ChromaQueryInclude.Metadatas | ChromaQueryInclude.Distances); +var queryData = await string5Client.QueryAsync([new([1f, 0.5f, 0f, -0.5f, -1f]), new([1.5f, 0f, 2f, -1f, -1.5f])], include: ChromaQueryInclude.Metadatas | ChromaQueryInclude.Distances); foreach (var item in queryData) { foreach (var entry in item) diff --git a/Samples/ChromaDB.Client.TrimmingTest/ChromaDB.Client.TrimmingTest.csproj b/Samples/ChromaDB.Client.TrimmingTest/ChromaDB.Client.TrimmingTest.csproj new file mode 100644 index 0000000..c1cad8e --- /dev/null +++ b/Samples/ChromaDB.Client.TrimmingTest/ChromaDB.Client.TrimmingTest.csproj @@ -0,0 +1,19 @@ + + + + Exe + net8.0 + enable + enable + false + true + false + true + + true + + + + + + diff --git a/Samples/ChromaDB.Client.TrimmingTest/Program.cs b/Samples/ChromaDB.Client.TrimmingTest/Program.cs new file mode 100644 index 0000000..94140f0 --- /dev/null +++ b/Samples/ChromaDB.Client.TrimmingTest/Program.cs @@ -0,0 +1,266 @@ +using ChromaDB.Client; +using ChromaDB.Client.DependencyInjection; +using ChromaDB.Client.Models; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.DependencyInjection; + +// Runs the main calls of the client against the server at args[0], like http://localhost:8000. +// Published with trimming, it fails if the client needs code that trimming removed. +// --lists: the server stores lists in metadata (Chroma 1.5.0 and later), so they must work. +// With CHROMA_HOST, like api.trychroma.com, it runs against Chroma Cloud: CHROMA_API_KEY, CHROMA_TENANT and CHROMA_DATABASE. +var options = Environment.GetEnvironmentVariable("CHROMA_HOST") is { Length: > 0 } host + ? CloudOptions(host) + : new ChromaConfigurationOptions(uri: args.Length > 0 && !args[0].StartsWith("--") ? args[0] : "http://localhost:8000"); +var listsStored = args.Contains("--lists"); +using var httpClient = new HttpClient(); +var client = new ChromaClient(options, httpClient); +var failures = 0; + +async Task Check(string step, Func> action) +{ + try + { + Console.WriteLine($"ok {step}: {await action()}"); + } + catch (Exception ex) + { + failures++; + Console.WriteLine($"FAIL {step}: {ex.GetType().Name}: {ex.Message}"); + } +} + +static string Expect(string value, bool condition, string expected) + => condition ? value : throw new Exception($"expected {expected}, got {value}"); + +var version = ""; +var name = $"trimming{Guid.NewGuid():N}"; +ChromaCollectionClient collectionClient = null!; +var embeddings = new List> { new([1f, 0f, 0f]), new([0f, 1f, 0f]) }; + +await Check("Heartbeat", async () => (await client.HeartbeatAsync()).NanosecondHeartbeat.ToString()); +await Check("GetVersion", async () => version = await client.GetVersionAsync()); +await Check("GetPreFlightChecks", async () => (await client.GetPreFlightChecksAsync()).MaxBatchSize.ToString()); +await Check("CreateCollection", async () => +{ + var collection = await client.CreateCollectionAsync(new ChromaCollectionDefinition(name) { Metadata = new Dictionary { ["owner"] = "trimming", ["level"] = 1 }, Configuration = new() { Space = ChromaSpace.Cosine } }); + collectionClient = client.GetCollectionClient(collection); + return Expect($"{collection.Name} {collection.Space}", collection.Space == ChromaSpace.Cosine, "Cosine"); +}); +await Check("GetOrCreateCollection", async () => (await client.GetOrCreateCollectionAsync(name)).Id.ToString()); +await Check("CollectionExists", async () => Expect((await client.CollectionExistsAsync(name)).ToString(), await client.CollectionExistsAsync(name), "True")); +await Check("ListCollections", async () => (await client.ListCollectionsAsync()).Count.ToString()); +await Check("Add", async () => +{ + await collectionClient.AddAsync(new ChromaRecords(["a", "b"]) + { + Embeddings = embeddings, + Metadatas = [new Dictionary { ["text"] = "x", ["int"] = 1, ["long"] = 2L, ["double"] = 1.5, ["float"] = 2.5f, ["bool"] = true }, new Dictionary { ["text"] = "y", ["int"] = 2 }], + Documents = ["first", "second"], + Uris = ["file://a", null], + }); + return "2 records"; +}); +await Check("Every type of single value", async () => +{ + var guid = Guid.NewGuid(); + var values = new Dictionary + { + ["string"] = "s", ["int"] = 1, ["long"] = 2L, ["short"] = (short)3, ["byte"] = (byte)4, ["sbyte"] = (sbyte)5, ["ushort"] = (ushort)6, + ["uint"] = 7u, ["ulong"] = 8ul, ["double"] = 1.5, ["float"] = 2.5f, ["decimal"] = 3.5m, ["bool"] = true, + ["dateTime"] = new DateTime(2026, 10, 4, 12, 0, 0, DateTimeKind.Utc), ["dateTimeOffset"] = new DateTimeOffset(2026, 10, 4, 12, 0, 0, TimeSpan.FromHours(2)), + ["guid"] = guid, + }; + await collectionClient.AddAsync(new ChromaRecords(["v"]) { Embeddings = [new([0f, 0f, 0.5f])], Metadatas = [values] }); + var where = ChromaWhereOperator.Equal("short", (short)3) & ChromaWhereOperator.Equal("ulong", 8ul) & ChromaWhereOperator.Equal("decimal", 3.5m) + & ChromaWhereOperator.In("guid", guid.ToString()) & ChromaWhereOperator.Equal("sbyte", (sbyte)5); + var exact = client.WithMetadataValues(ChromaMetadataValues.Exact).GetCollectionClient(collectionClient.Collection); + var result = await exact.GetAsync(where: where, include: ChromaGetInclude.Metadatas); + var metadata = result.FirstOrDefault()?.Metadata; + await collectionClient.DeleteAsync(["v"]); + return Expect($"{result.Count} record, {metadata?.Count} values, guid {metadata?["guid"]}", result.Count == 1 && metadata?.Count == values.Count && Equals(metadata["guid"], guid.ToString()), $"1 record, {values.Count} values"); +}); +await Check("Count", async () => Expect((await collectionClient.CountAsync()).ToString(), await collectionClient.CountAsync() == 2, "2")); +await Check("Get with filters", async () => +{ + var where = ChromaWhereOperator.Equal("text", "x") & (ChromaWhereOperator.In("int", 1, 3) | ChromaWhereOperator.GreaterThan("double", 1.0)); + var result = await collectionClient.GetAsync(where: where, whereDocument: ChromaWhereDocumentOperator.Contains("fir"), include: ChromaGetInclude.Metadatas | ChromaGetInclude.Documents | ChromaGetInclude.Embeddings); + return Expect($"{string.Join(",", result.Select(x => x.Id))} {result.FirstOrDefault()?.Metadata?["bool"]}", result.Count == 1 && result[0].Id == "a", "a"); +}); +await Check("Query", async () => +{ + var result = await collectionClient.QueryAsync(new ChromaQuery([new([1f, 0.1f, 0f])]) { NResults = 2, Include = ChromaQueryInclude.Distances | ChromaQueryInclude.Documents }); + return Expect($"{result[0][0].Id} {result[0][0].Distance}", result[0][0].Id == "a", "a first"); +}); +await Check("Embedding generator", async () => +{ + var generator = new LetterGenerator(); + var collection = await client.CreateCollectionAsync(new ChromaCollectionDefinition(name + "g") { EmbeddingGenerator = generator }); + try + { + var records = client.GetCollectionClient(collection).WithEmbeddingGenerator(generator); + await records.AddAsync(["a", "b"], documents: ["apple", "banana"]); + var nearest = await records.QueryAsync("avocado", nResults: 1); + var declared = collection.ConfigurationJson is { } configuration && configuration.TryGetProperty("embedding_function", out var function) ? function.GetProperty("name").GetString() : null; + return Expect($"{nearest[0].Id} {declared}", nearest[0].Id == "a" && declared == "default", "a default"); + } + finally + { + await client.DeleteCollectionAsync(collection.Name); + } +}); +await Check("Exact metadata", async () => +{ + var exact = client.WithMetadataValues(ChromaMetadataValues.Exact).GetCollectionClient(collectionClient.Collection); + await exact.UpsertAsync(new ChromaRecords(["c"]) { Embeddings = [new([0f, 0f, 1f])], Metadatas = [new Dictionary { ["date"] = "2026-10-04" }] }); + var value = (await exact.GetAsync("c", include: ChromaGetInclude.Metadatas))?.Metadata?["date"]; + return Expect($"{value?.GetType().Name} {value}", value is string, "a string"); +}); +await Check("Lists in metadata", async () => +{ + // A list of each type the client knows without reflection. + var lists = new Dictionary + { + ["tags"] = new List { "red", "blue" }, ["strings"] = new[] { "x" }, + ["ints"] = new[] { 1, 2 }, ["intList"] = new List { 1 }, ["longs"] = new[] { 1L }, ["longList"] = new List { 1L }, + ["shorts"] = new short[] { 1 }, ["shortList"] = new List { 1 }, ["sbytes"] = new sbyte[] { 1 }, ["sbyteList"] = new List { 1 }, + ["ushorts"] = new ushort[] { 1 }, ["ushortList"] = new List { 1 }, ["uints"] = new uint[] { 1 }, ["uintList"] = new List { 1 }, + ["ulongs"] = new ulong[] { 1 }, ["ulongList"] = new List { 1 }, ["byteList"] = new List { 1 }, + ["doubles"] = new[] { 1.5 }, ["doubleList"] = new List { 1.5 }, ["floats"] = new[] { 2.5f }, ["floatList"] = new List { 2.5f }, + ["decimals"] = new[] { 3.5m }, ["decimalList"] = new List { 3.5m }, + ["bools"] = new[] { true }, ["boolList"] = new List { false }, + ["objects"] = new List { "a", "b" }, ["objectArray"] = new object[] { 1, 2 }, + }; + var records = new ChromaRecords(["l"]) { Embeddings = [new([0.5f, 0.5f, 0f])], Metadatas = [lists] }; + if (!listsStored) + { + try + { + await collectionClient.AddAsync(records); + return "stored"; + } + catch (ChromaException ex) + { + return $"rejected on Chroma {version}: {ex.Message}"; + } + } + await collectionClient.AddAsync(records); + var result = await client.WithMetadataValues(ChromaMetadataValues.Exact).GetCollectionClient(collectionClient.Collection).GetAsync(where: ChromaWhereOperator.Contains("tags", "red"), include: ChromaGetInclude.Metadatas); + var metadata = result.FirstOrDefault()?.Metadata; + return Expect($"{result.Count} records, {metadata?.Count} lists", result.Count == 1 && metadata?.Count == lists.Count && metadata.Values.All(x => x is List), $"1 record, {lists.Count} lists"); +}); +await Check("Update and Delete", async () => +{ + await collectionClient.UpdateAsync(["a"], documents: ["first updated"]); + await collectionClient.DeleteAsync(["b"]); + return (await collectionClient.PeekAsync()).Count.ToString(); +}); +await Check("Modify", async () => +{ + await collectionClient.ModifyAsync(metadata: new Dictionary { ["owner"] = "trimming2" }); + return (await client.GetCollectionAsync(name)).Metadata?["owner"]?.ToString() ?? "null"; +}); +await Check("Filters as JSON", async () => +{ + await Task.CompletedTask; + return Expect(ChromaWhereOperator.NotEqual("k", 1).ToString() + ChromaWhereDocumentOperator.NotContains("x").ToString(), true, ""); +}); +await Check("Missing collection", async () => +{ + try + { + await client.GetCollectionAsync(name + "missing"); + throw new Exception("no exception"); + } + catch (ChromaException ex) + { + return $"{ex.StatusCode}: {ex.Message}"; + } +}); +await Check("Tenants and databases", async () => +{ + // Chroma 0.4.14 and earlier have no tenants and databases. + if (Version.TryParse(version, out var parsed) && parsed < new Version(0, 4, 15)) + return "skipped"; + var tenant = $"tenant{Guid.NewGuid():N}"; + try + { + await client.CreateTenantAsync(tenant); + } + catch (ChromaException ex) when (ex.StatusCode == System.Net.HttpStatusCode.Forbidden) + { + // Chroma Cloud does not let a key create tenants. + return $"skipped: {ex.Message}"; + } + await client.CreateDatabaseAsync("database", tenant: tenant); + return $"{(await client.GetTenantAsync(tenant)).Name}/{(await client.GetDatabaseAsync("database", tenant: tenant)).Name}"; +}); +await Check("DependencyInjection", async () => +{ + var services = new ServiceCollection(); + services.AddChromaClient(_ => options); + services.AddKeyedChromaClient("k", _ => options); + services.AddKeyedChromaClientFromServices("s", _ => options); + using var provider = services.BuildServiceProvider(); + await provider.GetRequiredKeyedService("k").HeartbeatAsync(); + await provider.GetRequiredKeyedService("s").HeartbeatAsync(); + return (await provider.GetRequiredService().HeartbeatAsync()).NanosecondHeartbeat.ToString(); +}); +await Check("Collection created when missing", async () => +{ + var missing = $"{name}m"; + var created = client.GetCollectionClient(missing, createIfMissing: true); + await created.AddAsync(["a"], [new([1f, 0f, 0f])]); + var count = await (await client.GetOrCreateCollectionClientAsync(missing)).CountAsync(); + await client.DeleteCollectionAsync(missing); + return Expect(count.ToString(), count == 1, "1"); +}); +// chroma:document takes a text only (KD-58). Without reflection, a list derived from List, which the JSON context does not list, +// would go as an array: it must be rejected before the request. +await Check("chroma:document", async () => +{ + try + { + await collectionClient.UpdateAsync(new ChromaRecords(["a"]) { Metadatas = [new Dictionary { ["chroma:document"] = new DerivedInts { 1 } }] }); + } + catch (ArgumentException) + { + return "rejected before the request"; + } + throw new Exception("a list under chroma:document went to the server"); +}); +await Check("DeleteCollection", async () => +{ + await client.DeleteCollectionAsync(name); + return Expect((await client.CollectionExistsAsync(name)).ToString(), !await client.CollectionExistsAsync(name), "False"); +}); + +Console.WriteLine(failures == 0 ? "ALL OK" : $"{failures} FAILED"); +return failures; + +static ChromaConfigurationOptions CloudOptions(string host) +{ + var options = new ChromaConfigurationOptions(uri: host.Contains("://") ? host : $"https://{host}"); + options = Environment.GetEnvironmentVariable("CHROMA_API_KEY") is { Length: > 0 } key ? options.WithChromaToken(key) : options; + options = Environment.GetEnvironmentVariable("CHROMA_TENANT") is { Length: > 0 } tenant ? options.WithTenant(tenant) : options; + return Environment.GetEnvironmentVariable("CHROMA_DATABASE") is { Length: > 0 } database ? options.WithDatabase(database) : options; +} + +sealed class DerivedInts : List { } + +// One dimension for each letter from a to e, by the first letter of the text, and one for the rest. Its metadata is that of the generator +// of the default embedding function of Chroma, so that a new collection declares "default". +sealed class LetterGenerator : IEmbeddingGenerator> +{ + public Task>> GenerateAsync(IEnumerable values, EmbeddingGenerationOptions? options = null, CancellationToken cancellationToken = default) + => Task.FromResult(new GeneratedEmbeddings>(values.Select(text => + { + var embedding = new float[6]; + embedding[text.Length > 0 && text[0] is >= 'a' and <= 'e' ? text[0] - 'a' : 5] = 1f; + return new Embedding(embedding); + }))); + + public object? GetService(Type serviceType, object? serviceKey = null) + => serviceType == typeof(EmbeddingGeneratorMetadata) ? new EmbeddingGeneratorMetadata("chroma", null, "all-MiniLM-L6-v2", 6) : null; + + public void Dispose() { } +} diff --git a/Samples/quickstart.cs b/Samples/quickstart.cs new file mode 100644 index 0000000..0440ac7 --- /dev/null +++ b/Samples/quickstart.cs @@ -0,0 +1,59 @@ +#:package ChromaDotNet.Client@2.14.0 + +// A first look at ChromaDB.Client, with ChromaDotNet.Client from NuGet: a collection, records with documents and metadata, a query by +// similarity, a filter on the metadata, a get and a count. The embeddings are written by hand, three numbers each, so no model is needed. +// Run it with Chroma on http://localhost:8000, or set CHROMA_URL: +// dotnet run Samples/quickstart.cs +using ChromaDB.Client; + +var uri = Environment.GetEnvironmentVariable("CHROMA_URL") ?? "http://localhost:8000"; +var client = new ChromaClient(new ChromaConfigurationOptions(uri)); +await client.HeartbeatAsync(); +Console.WriteLine($"Connected to Chroma at {uri}"); + +var name = $"quickstart_{Guid.NewGuid():N}"; +var collection = client.GetCollectionClient(await client.CreateCollectionAsync(name)); +Console.WriteLine($"Created the collection {name}"); + +try +{ + // Three numbers per record: how much it is about fruit, vehicles and colors. + await collection.AddAsync( + ["apple", "banana", "car", "bicycle", "red"], + embeddings: [new([0.9f, 0.1f, 0.3f]), new([0.9f, 0.1f, 0.1f]), new([0.1f, 0.9f, 0.1f]), new([0.2f, 0.8f, 0f]), new([0.2f, 0.1f, 0.9f])], + metadatas: + [ + new Dictionary { ["kind"] = "fruit", ["price"] = 2 }, + new Dictionary { ["kind"] = "fruit", ["price"] = 1 }, + new Dictionary { ["kind"] = "vehicle", ["price"] = 20000 }, + new Dictionary { ["kind"] = "vehicle", ["price"] = 300 }, + new Dictionary { ["kind"] = "color", ["price"] = 0 }, + ], + documents: ["A red apple", "A yellow banana", "A fast car", "A city bicycle", "The color red"]); + Console.WriteLine($"Added {await collection.CountAsync()} records"); + + Console.WriteLine(); + Console.WriteLine("Nearest to something fruity:"); + var nearest = await collection.QueryAsync([new([1f, 0f, 0.3f])], nResults: 3, include: ChromaQueryInclude.Documents | ChromaQueryInclude.Distances); + foreach (var entry in nearest[0]) + { + Console.WriteLine($" {entry.Id,-8} {entry.Distance,6:F3} {entry.Document}"); + } + + Console.WriteLine(); + Console.WriteLine("Vehicles under 1000, by metadata:"); + var cheap = await collection.GetAsync(where: ChromaWhereOperator.Equal("kind", "vehicle") & ChromaWhereOperator.LessThan("price", 1000), include: ChromaGetInclude.Documents | ChromaGetInclude.Metadatas); + foreach (var entry in cheap) + { + Console.WriteLine($" {entry.Id,-8} price {entry.Metadata!["price"],-6} {entry.Document}"); + } + + Console.WriteLine(); + var apple = (await collection.GetAsync(["apple"], include: ChromaGetInclude.Documents | ChromaGetInclude.Embeddings)).Single(); + Console.WriteLine($"Got apple: \"{apple.Document}\", embedding [{string.Join(", ", apple.Embedding!.Value.ToArray())}]"); +} +finally +{ + await client.DeleteCollectionAsync(name); + Console.WriteLine($"Deleted the collection {name}"); +} diff --git a/THIRD-PARTY-NOTICES.md b/THIRD-PARTY-NOTICES.md new file mode 100644 index 0000000..e93dcc8 --- /dev/null +++ b/THIRD-PARTY-NOTICES.md @@ -0,0 +1,35 @@ +# Third-party notices + +`ChromaDotNet.Client` contains a port of the Snowball English stemmer of the Python package snowballstemmer 3.1.1 (`ChromaDB.Client/Common/SnowballEnglishStemmer.cs` and `SnowballProgram.cs`), which `ChromaBm25` uses. Its license: + +``` +Copyright (c) 2001, Dr Martin Porter +Copyright (c) 2004,2005, Richard Boulton +Copyright (c) 2013, Yoshiki Shibukawa +Copyright (c) 2006-2025, Olly Betts +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions +are met: + + 1. Redistributions of source code must retain the above copyright notice, + this list of conditions and the following disclaimer. + 2. Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + 3. Neither the name of the Snowball project nor the names of its contributors + may be used to endorse or promote products derived from this software + without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR +ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON +ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. +``` diff --git a/assets/chromadotnet-icon.png b/assets/chromadotnet-icon.png new file mode 100644 index 0000000..d0bb66f Binary files /dev/null and b/assets/chromadotnet-icon.png differ diff --git a/docs/COMPATIBILITY.md b/docs/COMPATIBILITY.md new file mode 100644 index 0000000..989d402 --- /dev/null +++ b/docs/COMPATIBILITY.md @@ -0,0 +1,249 @@ +# Compatibility with Chroma + +On 3 October 2026 the whole test suite of the client ran against each Chroma release listed on this page, using the `chromadb/chroma` Docker image published by Chroma. The page lists only those runs: a version that is not here was not tested. + +The suite covers collections (create, get, list, count, modify, delete), records (add, update, upsert, delete, get, query, count, peek, filters), tenants and databases, error messages, cancellation, and the built-in authentication of the server where noted. + +## v2 API (the default) + +All the tests pass on: + +0.5.16 – 0.5.18, 0.5.20 – 0.5.21, 0.5.23, 0.6.0 – 0.6.3, 1.0.0, 1.0.2 – 1.0.10, 1.0.12 – 1.0.13, 1.0.15 – 1.0.21, 1.1.0 – 1.1.1, 1.2.0 – 1.2.2, 1.3.0, 1.3.2 – 1.3.3, 1.3.5 – 1.3.7, 1.4.0 – 1.4.1, 1.5.0 – 1.5.9 + +Differences between these servers, seen in the tests: + +| Behavior | Versions | +|---|---| +| Rejects embeddings of different dimensions in the same request | 0.5.20 – 1.5.9; 0.5.16 – 0.5.18 accept them | +| Requires embeddings in `AddAsync` and `UpsertAsync` | 1.0.16 – 1.5.9; the earlier versions accept records without embeddings | +| Ignores an add of an id that exists, without an error: the record keeps its document | 1.5.9 and Chroma Cloud, with this client and with the Python client 1.5.9; the other versions not verified yet | +| Keeps the stored document when an update or an upsert sends a null document | 1.0.0 – 1.5.9 and Chroma Cloud; every 0.x server deletes it, from 0.4.10 to 0.6.3 on the v1 and the v2 API, and the client sends the null as it is; on 0.5.20 and 0.6.3 also the upsert of the Python client 0.5.20 and 0.6.3, whose update rejects a null document before the request | +| Deletes a key of the metadata when an update or an upsert gives it a null value | 1.5.9 and Chroma Cloud, with this client and with the Python client 1.5.9; also 0.4.24 (v1 API), 0.6.3 and 1.0.0, with this client, as the Python client of those versions rejects a null before the request; a null for a key the record does not have is accepted on all of them and on 1.5.9, with this client and in plain HTTP; the other versions not verified yet | +| Rejects a null value in the metadata of an add, with `422` "Failed to deserialize the JSON body into the target type: metadatas[0].x: data did not match any variant of untagged enum MetadataValue" | 1.5.2, 1.5.9 and Chroma Cloud, with the Python client of the same version; 0.5.20 and 0.6.3 store the record without the key, without an error, in plain HTTP, as the Python client of those versions rejects the null before the request; this client throws an `ArgumentException` before the request; the other versions not verified yet | +| Rejects a list of values of different types in the metadata, with `422` "Failed to deserialize the JSON body into the target type: metadatas[0].l: data did not match any variant of untagged enum MetadataValue" | 1.5.0, 1.5.9 and Chroma Cloud, in plain HTTP: this client and the Python client 1.5.9 throw before the request; the other versions not verified yet | +| Rejects metadata keys that start with `#` or `$`, like `#id`, `#document` or `$eq`, with `400` "Validation error: metadatas: Invalid metadata at index 0" | 1.1.0, 1.3.7, 1.5.9 and Chroma Cloud: on 1.5.9 and Chroma Cloud with this client and with the Python client 1.5.9, on 1.1.0 and 1.3.7 with the Python client 1.1.0 and 1.3.6; 1.0.0, 1.0.21 and 0.6.3 store them, with the Python client of the same version; the other versions not verified yet | +| Reads `#id` in the `where` of a get as a metadata key, which finds nothing, and rejects `#document` there with `400` "Cannot filter using metadata key '#document' with type 'Str' because indexing is disabled" | 1.5.9, with the Python client 1.5.9 and in plain HTTP; Chroma Cloud reads `#id` there as the id of the record, and rejects `#document` in the same way, with the Python client 1.5.9; this client sends `#id` as a filter on the ids, and gets the same `400` for `#document`; the other versions not verified yet | +| Sends null metadata for a record whose metadata has no keys left | 1.5.9 and Chroma Cloud: the Python client 1.5.9 reads `None`, and this client an empty `Metadata`; 0.4.24 (v1 API), 0.6.3 and 1.0.0 too, with this client; the other versions not verified yet | +| Keeps the type of a number in the metadata: `2.0` reads back as a float and `2` as an integer | 1.5.9 and Chroma Cloud, with this client and with the Python client 1.5.9; the other versions not verified yet | +| Stores an integer beyond the range of a 64-bit integer as a float: 2^63 reads back as `9.223372036854776e18` | 1.5.9 and Chroma Cloud, with the Python client 1.5.9 and in plain HTTP; this client throws an `ArgumentOutOfRangeException` for a `ulong` above `long.MaxValue`; the other versions not verified yet | +| Matches the records without the key with `$not_contains` on a list, as with `$ne` and `$nin` | 1.5.9 and Chroma Cloud, with this client and with the Python client 1.5.9; the other versions not verified yet; for `$ne` and `$nin` on the other versions see below | +| Matches the type in `$contains` on a list: `1` finds the lists of integers and `1.0` those of floats; `$eq` on a single value finds both types on 1.5.9, and only its own on Chroma Cloud (KD-54) | 1.5.9 and Chroma Cloud, with this client and with the Python client 1.5.9; the other versions not verified yet | +| Compares only numbers with `$gt`, and has no `$not`: a string in `$gt`, or `$not`, gets `400` "Invalid where clause" | 1.5.9: the string with this client and in plain HTTP, `$not` in plain HTTP; the Python client 1.5.9 rejects both before the request; the other versions not verified yet | +| Built-in authentication: token in `X-Chroma-Token` or `Authorization: Bearer`, and basic | 0.5.16 – 0.6.3; Chroma 1.0.0 – 1.5.9 accept requests without credentials, also with the token authentication of 0.x configured | +| Lists and deletes databases (`ListDatabasesAsync`, `DeleteDatabaseAsync`) | 0.6.3 – 1.5.9; 0.5.16 – 0.6.2 answer `405 Method Not Allowed` | +| Gets a collection by its id (`GetCollectionByIdAsync`) | 1.5.7 – 1.5.9; 0.5.16 – 1.5.6 answer `404 Not Found` | +| Returns every collection to a list without a limit | 1.5.9: 1,100 collections of 1,100, with this client and with the Python client 1.5.9; the other versions not verified yet | +| Stores lists in metadata, and filters them with `ChromaWhereOperator.Contains` and `NotContains` | 1.5.0 – 1.5.9; 1.0.0 – 1.4.1 reject the lists with `422`, with this client and with the Python client 1.5.9 on 1.3.7, while 1.5.9 keeps them; 0.5.16 – 0.6.3 drop the lists without an error, so the client throws a `ChromaException` before sending them, and reject `$contains` | +| Reports the space of a collection created without one (`ChromaCollection.Space`) | 1.0.6 – 1.5.9 report `L2`; 0.5.16 – 1.0.5 send `hnsw_configuration.space`, always "l2": `Space` is null on 1.0.0 – 1.0.5, and `L2` on 0.5.16 – 0.6.3, where the client asks the version of the server; Chroma Cloud sends it in the SPANN configuration (`spann.space`, `l2`), and 1.5.9 in the HNSW one (`hnsw.space`), with this client, which reports `L2` on both, and with the Python client 1.5.9 | +| Rejects sparse vectors in metadata (only Chroma Cloud stores them) | 1.0.0 – 1.5.9: 1.5.2 and 1.5.9 answer `501` "Unimplemented: Sparse vector is not supported for local chroma", with this client and with the Python client of the same version; 0.5.20 and 0.6.3 store the record without the sparse vector, without an error, in plain HTTP, as this client and the Python client of those versions throw before the request; 1.0.21 – 1.1.1 then fail the next write on the server, also to another collection, with `Error sending message to compactor`, so the tests leave that write out there | +| Searches only the records with the ids of `ChromaQuery.Ids` | 1.0.0 – 1.5.9, which answer `500` with `Error finding id` when one of the ids does not exist and the query has no filter: `QueryAsync` then asks again with the ids that exist; 0.5.16 – 0.6.3 ignore the ids: `QueryAsync` throws a `ChromaException` when a result falls outside them | +| Ignores an `offset` in a query, which has none: a query with `offset` 2 returns the first records | 1.5.9 and Chroma Cloud, in plain HTTP: the Python client 1.5.9 has no offset in `query`, and `QueryAsync` asks for the skipped records too and leaves them out; the other versions not verified yet | +| Keeps the lists in the metadata of the records of a deleted collection or database, and gives them to the next records it stores, in any collection and database | 1.5.0 – 1.5.9: with `deleteRecordsFirst: true`, `DeleteCollectionAsync`, `DeleteCollectionIfExistsAsync` and `DeleteDatabaseAsync` delete the records first on Chroma 1.x, which leaves no lists | +| Has the healthcheck (`HealthcheckAsync`) | 1.0.0 – 1.5.9; 0.5.16 – 0.6.3 answer `404 Not Found` | +| Applies a new configuration of the index (`ModifyConfigurationAsync`) | 1.0.6 – 1.5.9; the earlier versions answer without applying it, so the client throws a `ChromaException` | +| Filters documents with `$regex` and `$not_regex` (`ChromaWhereDocumentOperator.Regex` and `NotRegex`) | 1.0.12 – 1.5.9; the earlier versions fail with a `ChromaException`: 1.0.0 – 1.0.6 reject them, 1.0.10 closes the connection | +| Applies the space of a collection created with a schema | 1.3.2 – 1.5.9; 1.3.0 applies it only when the schema also turns an index on or off, and with the space alone keeps `l2`, with this client, in plain HTTP and with the Python client 1.3.0; 1.0.0 – 1.2.2 create the collection without the schema | +| Applies the limit of a delete (`ChromaDelete.Limit`) and answers a count, which for a delete by ids is the number of ids given, also those without a record (KD-53) | 1.5.3 – 1.5.9; on the earlier versions the client throws a `ChromaException` before a delete with a limit, and `DeleteAsync` returns null; 0.5.20, 0.6.3 and 1.5.2 ignore the limit and delete every matching record, in plain HTTP, as the Python client of those versions has no limit, and on 1.5.2 also with the Python client 1.5.9 | +| Reports the HNSW settings of a new collection (`ChromaCollectionConfiguration.Hnsw`), sent as the `hnsw:` metadata | 1.0.6 – 1.5.9; the earlier versions take the metadata without reporting it; Chroma Cloud creates a SPANN index and reports no HNSW settings, with this client, which then deletes the collection and throws a `ChromaException`, and with the Python client 1.5.9, which keeps the collection | +| Rejects a float in the `hnsw:` metadata of a new collection, like `hnsw:M` 100.0, with `InvalidArgumentError` "Failed to parse hnsw parameters from segment metadata" | 1.5.9, with the Python client 1.5.9; this client sends 100.0 as the integer 100; 0.6.3 too, with the Python client 0.6.3: "Invalid value for HNSW parameter: hnsw:M = 100.0"; the other versions not verified yet | +| Applies the SPANN settings of a new collection (`ChromaCollectionConfiguration.Spann`) | Chroma Cloud only; 1.0.9 – 1.5.9 ignore them and report an HNSW index, so the client deletes the collection and throws a `ChromaException`; 1.0.6 – 1.0.8 reject them with `400` "SPANN is still in development. Not allowed to created spann indexes", with this client, with the Python client 1.0.6 and in plain HTTP; 1.0.0 – 1.0.5 report no configuration; 0.5.16 – 0.6.3 fail on the configuration, so the client throws before sending it | +| Rejects HNSW and SPANN settings together, with `400` `InvalidArgumentError` "Multiple vector index configurations provided" | 1.5.9 and Chroma Cloud, in plain HTTP: this client and the Python client 1.5.9 throw before the request; the other versions not verified yet | +| Takes `nreplica_count` of the SPANN index only in a schema: in the configuration it gets `422` "unknown field `nreplica_count`" | Chroma Cloud: the configuration with the Python client 1.5.9 and in plain HTTP; in a schema `nreplica_count` 4 applies, with this client, which sends it there, and in plain HTTP | +| Ignores the `hnsw:space` metadata next to SPANN settings: a collection created with `cosine` there has the space `l2` | Chroma Cloud, with the Python client 1.5.9; this client throws an `ArgumentException` before the request; the other versions not verified yet | +| Applies the embedding function of a new collection (`ChromaCollectionConfiguration.EmbeddingFunction`) and reports it | 1.0.6 – 1.5.9; 1.0.0 – 1.0.5 take it without reporting it; 0.5.16 – 0.6.3 fail on the configuration, so the client throws before sending it | +| Applies the embedding function of `ModifyConfigurationAsync` | 1.0.6 – 1.5.9, as the other settings of the index | +| Applies the SPANN settings of `ModifyConfigurationAsync` | Chroma Cloud, with this client and with the Python client 1.5.9; 1.5.9 answers `200` and keeps the HNSW index, in plain HTTP: this client and the Python client 1.5.9 throw before the request; the other versions not verified yet | +| Applies the indexes of a schema turned on or off (`ChromaCollectionSchema.WithIndex` and `WithoutIndex`), and rejects a filter on a key without its index ("indexing is disabled") | 1.3.0 – 1.5.9, but the full-text search index of the documents only from 1.5.1: 1.3.0 – 1.5.0 keep it on; 1.0.0 – 1.2.2 create the collection without the schema, and so do 0.5.20 and 0.6.3, with this client, which then deletes the collection and throws a `ChromaException`, and in plain HTTP, as the Python client of those versions has no schema | +| Takes a sparse vector index in the schema (`ChromaCollectionSchema.WithSparseVectorIndex`) | Chroma Cloud, with this client and with the Python client 1.5.9; 1.5.9 answers `400` `InvalidArgumentError` "Failed to reconcile schema: Invalid schema input: Sparse vector indexing is not enabled in local", with the two clients; 1.3.0 answers `500` `InternalError` "Failed to reconcile schema: Schema reconciliation failed: Sparse vector indexing is not enabled in local", with the two clients; the other versions not verified yet | +| Rejects a sparse vector whose indices are not in ascending order, with `400` "Validation error: metadatas: Invalid metadata at index 0" | Chroma Cloud, in plain HTTP: this client and the Python client 1.5.9 throw before the request | +| Takes two sparse vector indexes in one collection | Chroma Cloud, with this client; the Python client 1.5.9 throws before the request: "Only one sparse vector index is allowed per collection" | +| Rejects a sparse vector index with a source key and no embedding function, with `400` `InvalidArgumentError` "Validation error: schema: If source_key is provided then embedding_function must also be provided since there is no default embedding function." | Chroma Cloud, in plain HTTP: this client and the Python client 1.5.9 throw before the request; 1.5.9 rejects every sparse vector index; the other versions not verified yet | +| Rejects a configuration with HNSW settings next to a schema, with `InvalidArgumentError` "Failed to reconcile schema: Cannot set both collection config and schema simultaneously" | 1.5.9 and Chroma Cloud, with the Python client 1.5.9, which sends `hnsw.space` and the embedding function in the configuration; on 1.5.9 in plain HTTP `hnsw.space` cosine and `ef_search` get `400`, while `hnsw.space` `l2` or the embedding function alone go and the schema applies; 1.3.0 and 1.3.2 answer `500` `InternalError` with the same message; this client sends `Configuration.Space` in the schema, and the collection is created with that space; the other versions not verified yet | +| Takes an HNSW index with fewer than 2 neighbors (`max_neighbors`, `hnsw:M`) | 1.5.9 crashes on the first write with 0 and misses the nearest records with 1, also after `ModifyConfigurationAsync`: the client throws an `ArgumentException` before the request | +| Sends the dimension and the version of a collection (`ChromaCollection.Dimension` and `Version`) | 0.5.16 – 1.5.9, and on the v1 API from 0.5.1; the log position (`LogPosition`) from 0.5.9 | +| Forks a collection and counts its forks (`ForkAsync`, `ForkCountAsync`), and reports the indexing status (`GetIndexingStatusAsync`) | Chroma Cloud, with this client and with the Python client 1.5.9; 1.5.9 answers `501` "Collection forking is unsupported for local chroma" and "Count forks is unsupported for local chroma", and `500` to the indexing status (KD-37), with the two clients; the other versions not verified yet | +| Has function operations and the get of a collection by its CRN (`AttachFunctionAsync`, `GetCollectionByCrnAsync`) | not on a single server: 1.5.9 answers `501` "Attached Function operations not yet implemented in SqliteSysDb" to a function, with the two clients, and closes the connection without an answer to a get by CRN, with this client and in plain HTTP, as the Python client 1.5.9 has no such call; for Chroma Cloud see KD-26 and KD-27; the other versions not verified yet | +| Answers the identity of the key (`GetUserIdentityAsync`): with a key of a database, the tenant and that database; with a key of the whole tenant, the tenant and no database | Chroma Cloud, with this client and with the Python client 1.5.9; 1.5.9 answers `default_tenant` and `default_database`, with the two clients; the other versions not verified yet | +| Accepts an empty tenant in the path, `tenants//databases/default_database/...`, as the default tenant | 1.5.9, in plain HTTP; 0.5.20 and 0.6.3 answer `404`; this client and the Python client send the default tenant; the other versions not verified yet | +| Names the kind of error of a missing collection (`ChromaException.ErrorType`) | `NotFoundError` on 1.5.2, 1.5.9 and Chroma Cloud, `InvalidCollection` on 0.5.20 and 0.6.3, and `ValueError` on the v1 API of 0.4.24, with this client and with the Python client of the same version (0.4.24.dev0 for 0.4.24); the other versions not verified yet | +| Answers the delete of a missing collection with an error | `404` `NotFoundError` on 1.0.0 and 1.5.9, `400` `InvalidArgumentError` on 0.5.18 and 0.6.3, `500` `ValueError` on 0.5.16 and on the v1 API of 0.4.24, with this client and with the Python client of the same version (0.4.24.dev0 for 0.4.24); the other versions not verified yet | +| Names the field that fails validation: a query with `n_results` `"x"` gets `422` with a `detail` list and the `loc` `["body", "n_results"]` on 0.4.24 and 0.5.20, `400` `InvalidArgumentError` "1 validation error for QueryEmbedding" on 0.6.3, and `422` "Failed to deserialize the JSON body into the target type: n_results: invalid type: string \"x\", expected u32" on 1.5.2 and 1.5.9 | in plain HTTP, as the two clients send no such request; the other versions not verified yet | + +## v1 API (`ChromaApiVersion.V1`) + +All the tests pass on, including the built-in authentication: + +0.5.1 – 0.5.7, 0.5.9 – 0.5.18, 0.5.20 – 0.5.21, 0.5.23 + +On these older servers collections and records work; some features are missing on the server, and the client reports them with a `ChromaException`: + +| Versions | Missing on the server | +|---|---| +| 0.4.23, 0.4.24, 0.5.0 | nothing among the features above; the built-in authentication was not tested | +| 0.4.15 | `CountCollectionsAsync`; the `$not_contains` document filter; the tenant and database of the collections it returns; records in the collections of a tenant or database other than the default: the server answers that the collection does not exist; the `limit` and `offset` of `ListCollectionsAsync`: the server returns all the collections; the URIs of the records: the server rejects `uris` in `include` | +| 0.4.10, 0.4.12 – 0.4.14 | tenants and databases; `CountCollectionsAsync`; the `$not_contains` document filter; the `limit` and `offset` of `ListCollectionsAsync`: the server returns all the collections; the URIs of the records: the server rejects `uris` in `include` | + +Chroma 0.4.10 has no `pre-flight-checks` either, so `GetPreFlightChecksAsync` answers `404 Not Found` there. + +A missing endpoint gives a message that names the request, like `Not Found: POST /api/v1/tenants`. The v1 API has no `auth/identity`, so `GetUserIdentityAsync` needs the v2 API: Chroma 0.5.15, 0.5.16 and 0.6.3 answer it with `404 Not Found` in v1. The v1 API does not list or delete databases either: `ListDatabasesAsync` and `DeleteDatabaseAsync` answer `405 Method Not Allowed` on the servers above from 0.4.15. `GetCollectionByIdAsync` answers `404 Not Found` on all the servers above. They all ignore `ChromaQuery.Ids`, as Chroma 0.6.3 does with the v2 API, drop lists in metadata, so the client throws a `ChromaException` before sending them, and reject `$contains`. + +A collection created with `ChromaCollectionConfiguration.Space` uses that space on all the servers on this page, and `ChromaCollection.Space` reads it back: the client sends it as the `hnsw:space` metadata. The `configuration` field of the request is not used: Chroma 0.4.10 – 0.5.3 ignore it and keep `l2`, and 0.5.4 – 0.6.3 answer it with `500`. + +`pre-flight-checks` declares `supports_base64_encoding` from Chroma 1.0.13: there `AddAsync`, `UpdateAsync` and `UpsertAsync` take the embeddings as base64 strings and store the same float32 values, and the client sends them so; queries take only numbers. Chroma 1.0.12 and earlier do not declare it, reject base64 embeddings with `422`, and get numbers. + +Chroma 0.4.23 writes the space of a `get_or_create` into the metadata of a collection that exists, whose index keeps its space: the collection then reports a space it does not use. On the 0.x servers `GetOrCreateCollectionAsync` reads the collection first and throws before the request when it has another space than the definition; on the others the answer tells it. + +`CollectionExistsAsync` recognizes a missing collection on all the servers on this page: Chroma 1.x answers `404`, Chroma 0.5.6 – 0.6.3 `400`, and Chroma 0.4.10 – 0.5.5 `500`, always with "does not exist" in the message. + +The `max_batch_size` of `pre-flight-checks` is 41666 on Chroma 0.4.12 – 0.6.3 and 5461 on 1.x. A single request beyond it fails up to Chroma 1.0.13 (`400` or `500`); Chroma 1.0.15 – 1.5.9 accept it. With `WithBatchSplitting` the client sends batches within the limit, and writes beyond it work on all the servers that have `pre-flight-checks`: Chroma 0.4.10 has none, so its records go in one request. + +All the servers on this page reject `$in` and `$nin` without values (`400` or `500`), so `ChromaWhereOperator.In` without values is `None`, which sends no request, and `NotIn` without values is `All`, which sends no `where`. `$ne` and `$nin` match the records without the key on 0.5.15, and on the v2 API of 0.6.3, 1.0.0 and 1.5.9; 0.4.10 to 0.4.23 leave them out. `$lte` leaves them out on all of them, as `ChromaWhereOperator.Not` describes. A request on the id of a deleted collection gets `500` "coroutine raised StopIteration" from Chroma 0.4.23, without "does not exist", so a collection client made by name compares the ids to find a collection created again. + +The v1 API of Chroma 0.6.3 fails on most requests, and Chroma 1.0.0 – 1.5.9 answer it with `410 Gone`: use the v2 API there. On 1.5.9 the creation of a collection on the v1 API gets `405 Method Not Allowed` instead, and the reads `410`, with this client and in plain HTTP (checked on 10 October 2026). + +## Known defects of the servers + +Each defect has a number, KD-n, to refer to it. Reproduced with plain HTTP, without the client, from 3 to 10 October 2026; KD-51 with the client, as plain HTTP with a single upsert did not show it. From 10 October 2026 they are verified with this client and with the Python client too, and the column of the versions says where. The first ones, up to KD-11, the client cannot fully work around: KD-4 says how far its split of long lists goes. The column on the right says what it does for the others. + +| KD | Defect | Versions | The client | +|---|---|---|---| +| KD-1 | `$contains` and `$not_contains` on documents read `_` and `%` as SQL wildcards: `ChromaWhereDocumentOperator.Contains("a_b")` also finds `xacby` and `a b`, `Contains("50%")` also finds `sale 500 off`, and `NotContains("a_b")` leaves them out. On 1.0.0 – 1.0.12 a text with `%` between spaces, like `" 50% "`, finds nothing. The server puts the text in an SQLite `LIKE` without an escape character, so the client cannot escape them | all the tested versions from 0.4.10 to 1.0.12; right from 1.0.13; verified on 10 October 2026 on 0.4.10, 0.6.3, 1.0.12, 1.0.13 and 1.5.9, with this client and with the Python client of the same version | — | +| KD-3 | While 24 threads each add 13 records of their own in batches of 5, a query with `n_results` 3 and a `where` filter on a thread's records returns no record, on a server just started | 0.6.3, in 1 run out of 10; verified on 10 October 2026 with the Python client 0.6.3, in 1 run of 10; not seen with this client in 10 runs | — | +| KD-4 | A list of `$and` or `$or` becomes an SQLite expression as deep as the list: from 988 filters in one list Chroma 1.5.9 answers `500`, and from about 4,400 it crashes. The client splits long lists, as [queries-and-filters.md](queries-and-filters.md#very-long-filters) says; beyond 8,167 filters Chroma 1.5.9 answers `500` with `too many SQL variables` | single servers 1.0.0 – 1.5.9, with this client and with the Python client of the same version on 1.0.0 and 1.5.9; 0.6.3 answers `500` from about 490 filters however they go: from 490, and from 489 with `$ne`, in one list and split, with the two clients, while an `In` of 60,000 values goes | — | +| KD-5 | `$contains` and `$not_contains` on documents do not see the text after a NUL character (`\u0000`): `Contains("after")` does not find `"before\u0000after"`, which comes back whole | all the tested versions from 0.4.24 to 1.5.9; verified on 10 October 2026 on 0.4.24, 0.6.3, 1.0.0 and 1.5.9, with this client and with the Python client of the same version | — | +| KD-6 | A float compared with int metadata is truncated: `GreaterThanOrEqual("a", 2.25)` also finds `a = 2`, and `GreaterThan("a", -1.5)` leaves out `a = -1`. A `$in` that mixes integers and floats goes or gets `400` "Invalid where clause" depending on the order: `[2.25, -2]` goes, and on a single server also finds `b = 2`, while `[-2, 2.25]` and `[0, 2.25]` get `400` | 1.0.0 – 1.5.9; right on 0.4.24 – 0.6.3; the order of a `$in` on 1.0.0, 1.0.21, 1.3.7 and 1.5.9 in plain HTTP, and on 1.5.9 and Chroma Cloud with this client, as the Python client rejects a list of mixed types before the request | — | +| KD-7 | Some doubles are read with an error in the last digit: `-95.41757424465169`, the shortest text of the double, comes back `-95.41757424465168`, and `1e-28` comes back `9.999999999999999e-29`; `Equal("x", 1e-30)` does not find the record stored with `1e-30`. The client sends the shortest text that reads back as the same double | 1.0.0 – 1.5.9; right on 0.4.24 – 0.6.3; verified on 10 October 2026 on 0.4.24, 0.6.3, 1.0.0 and 1.5.9, with this client and with the Python client of the same version | — | +| KD-8 | The embeddings of a collection with the `cosine` space come back different in the last bit: `0.91782147` as `0.9178214`, `-0.4` as `-0.39999998` | 1.0.0 – 1.5.9; identical on 0.4.24 – 0.6.3, and with the other spaces; verified on 10 October 2026 on 0.4.24, 0.6.3, 1.0.0 and 1.5.9, with this client and with the Python client of the same version, with `[0.31594023, 0.19201598, 0.91782147, -0.4]` | — | +| KD-9, KD-10 | In a collection with the `cosine` space, an embedding beyond the range of a float, like `[float.MaxValue, float.MaxValue]`, comes back as `null`, which the client reads as `NaN`, and one near zero, like `[1e-30, 1e-30]`, comes back as `[0, 0]`, at a distance of `-0.4` from `[3, 4]`. In an `l2` or `ip` collection the distance to the first comes back as `null`, which the client reads as `NaN` | 1.0.0 and 1.5.9, on 1.5.9 also with this client and with the Python client 1.5.9; Chroma Cloud, with the two clients, answers `null` to the distance to the first in `l2` and `ip` too, while in `cosine` both embeddings come back as written, and the second is at a distance of `-139` from `[3, 4]`; 0.6.3 sends the values as they are, and the `l2` and `ip` distances as numbers beyond the range of a float, like `2.3e77` and `-2.38e39`, which the client reads as infinities | — | +| KD-11 | `-0.0` in metadata comes back as `0.0`; in embeddings it keeps its sign | 0.6.3, 1.0.0 and 1.5.9; verified on 10 October 2026 on 0.6.3, 1.0.0 and 1.5.9, with this client and with the Python client of the same version | — | +| KD-12 | The lists in the metadata of the records of a deleted collection or database go to the records of the collections created after it: 6 records of 60 without the key came back with the lists of the deleted ones | 1.5.0 – 1.5.9; a deleted database measured on 1.5.0 and 1.5.9; the deleted collection verified on 10 October 2026 on 1.5.0 and 1.5.9, with this client and with the Python client of the same version: 4 records of 60; the deleted database too, with the two clients: 4 records of 60 | with `deleteRecordsFirst: true` the deletions delete the records first, in batches | +| KD-13 | A get with `limit: 0` returns every record, from the offset | 0.6.3; 1.0.21 and 1.5.9 return none; verified on 10 October 2026 on 0.6.3, 1.0.21 and 1.5.9 with the Python client of the same version | sends no get for a limit of 0; a client made by name reads its collection first, and throws the error of the other reads when it does not exist | +| KD-14 | `/api/v2/version` answers `1.0.0`: the value is fixed in the server, and so is `info.version` in `openapi.json` | every 1.x and Chroma Cloud; Chroma Cloud with this client and with the Python client 1.5.9; `info.version` on 1.5.9 and Chroma Cloud in plain HTTP; 1.5.9 with this client and with the Python client 1.5.9, and 1.5.2 with the Python client 1.5.2 | tells 0.x from 1.x by the version, and the features of 1.x by `pre-flight-checks` and `openapi.json` | +| KD-18 | A list in the metadata of a record is accepted, with `201`, and dropped: the record comes back with null metadata | 0.6.3; 1.0.0 rejects it with `422`, 1.5.9 keeps it; verified on 10 October 2026: 0.6.3 in plain HTTP, as this client and the Python client 0.6.3 reject the list before the request; 1.0.0 with this client and in plain HTTP; 1.5.9 with this client and with the Python client 1.5.9 | throws a `ChromaException` before sending a list to 0.5.16 – 0.6.3 | +| KD-19 | A tenant or a database with `/` in the name is created, and then a get of it, or of a collection in it, answers `404` | 0.6.3; verified on 10 October 2026 on 0.6.3, with this client, with the Python client 0.6.3 and in plain HTTP | sends `%2F` in the path | +| KD-20 | A list in the metadata of a collection makes the server panic and close the connection, without an answer | 1.5.9; 0.6.3 answers `400`, 1.0.0 and 1.3.7 `422`, Chroma Cloud `500`; verified on 10 October 2026 with the Python client of the same version and in plain HTTP on 0.6.3, 1.0.0, 1.3.7 (Python client 1.3.6) and 1.5.9, and on Chroma Cloud with the Python client 1.5.9, which answers `500` "collection metadata value type not supported" | throws an `ArgumentException` before the request | +| KD-21 | `pre-flight-checks` declares a `max_batch_size` of 1000, but a write of 301 records gets `422` "Quota exceeded" | Chroma Cloud; verified on 10 October 2026 with this client and with the Python client 1.5.9 | batches of 300 by default, and of the quota the error names | +| KD-22 | An empty list in the metadata of a record is accepted and dropped without an error | 1.5.9; Chroma Cloud keeps it; verified on 10 October 2026 in plain HTTP, as this client and the Python client 1.5.9 reject an empty list before the request | `AddAsync` throws an `ArgumentException`; in an update or an upsert an empty list deletes the key | +| KD-23 | In a `cosine` collection of 1000 nearly parallel vectors, a query with `n_results: 1000` returns 998 records in 4 runs of 5, and pages of 10 repeat some records and miss others; `ef_search` 1000 or 2000 does not help, `l2` returns all | 1.5.9; verified on 10 October 2026 with this client and with the Python client 1.5.9: 997 to 1000 records in 10 runs, `l2` all; `ef_search` not verified again | — | +| KD-24 | A query whose ids include one without a record answers `500` "Error finding id" | 1.0.0 and 1.5.9; Chroma Cloud leaves the id out; verified on 10 October 2026: 1.0.0 in plain HTTP, as the query of the Python client 1.0.0 has no ids; 1.5.9 and Chroma Cloud with the Python client 1.5.9 | asks again with the ids that have a record | +| KD-25 | A change of the configuration with `hnsw` settings answers `500` "failed to merge config into schema" | Chroma Cloud, whose index is SPANN; verified on 10 October 2026 with the Python client 1.5.9 and in plain HTTP: "failed to merge config into schema: trying to update hnsw config but schema has spann" | throws a `ChromaException` before sending `Hnsw` settings to Chroma Cloud | +| KD-26 | `GET /api/v2/collections/{crn}` answers `403` "Permission denied." with a key of the database and with one of the whole tenant, also for a malformed CRN | Chroma Cloud, with this client and in plain HTTP, as the Python client 1.5.9 has no such call (checked on 10 October 2026) | reports the error of the server | +| KD-27 | Attaching a function the tenant has not enabled answers `429` "Too many requests", also on the first call; detaching too | Chroma Cloud; verified on 10 October 2026 with this client and with the Python client 1.5.9 | reports the error of the server, without retrying | +| KD-28 | In the image `chromadb/chroma:1.5.9`, `chroma --version` answers `chroma 1.4.4` | the image of 1.5.9, the same as `latest`; verified on 10 October 2026 | — | +| KD-29 | The v1 API fails with "cannot unpack non-iterable coroutine object" | 0.6.x; verified on 10 October 2026 on 0.6.3 in plain HTTP, where the list of the collections gets `400` with that message | use the v2 API there | +| KD-30 | With `CHROMA_ALLOW_RESET=TRUE` the server stops at the start, "expected a boolean"; `ALLOW_RESET=TRUE` is ignored. Only `CHROMA_ALLOW_RESET=true` enables the reset | 1.5.9; verified on 10 October 2026 with this client and with the Python client 1.5.9 | — | +| KD-33 | Embeddings of different dimensions in the same request are accepted | 0.5.16 – 0.5.18; rejected from 0.5.20; verified on 10 October 2026 on 0.5.16, 0.5.18 and 0.5.20, with this client and with the Python client of the same version | — | +| KD-34 | The Docker images do not start: they install NumPy 2.2.6, and the server exits with "np.float_ was removed in the NumPy 2.0 release" | images 0.4.16 – 0.4.22; verified on 10 October 2026 on the images 0.4.16 and 0.4.22 | — | +| KD-35 | The Docker image does not start: "Path 'log_config.yml' does not exist" | image 0.4.11; verified on 10 October 2026 | — | +| KD-36 | A query ignores its ids, without an error, and searches the whole collection | 0.5.16 – 0.6.3 on the v2 API, and every version on the v1 API; 1.0.0 – 1.5.9 apply them; verified on 10 October 2026: 0.5.16 and 0.6.3 with this client and in plain HTTP, as the query of the Python client of those versions has no ids, and the v1 API of 0.4.24 with this client; 1.0.0, 1.0.5 and 1.5.9 apply them, also in plain HTTP and with the Python client 1.5.9 | throws a `ChromaException` when a result falls outside the ids | +| KD-37 | `indexing_status` answers `500` "Method scout_logs is not implemented", while the other operations of Chroma Cloud only answer `501` | 1.5.9 single server, with this client and with the Python client 1.5.9 | reports the error of the server | +| KD-38 | An add to a collection of a tenant or database other than the default ones answers that the collection does not exist | 0.4.15 on the v1 API; right from 0.4.23; verified on 10 October 2026 on 0.4.15 with this client and in plain HTTP, and right on 0.4.23 with this client | — | +| KD-39 | Collections come back without tenant and database | 0.4.15 on the v1 API; right from 0.4.23; verified on 10 October 2026 on 0.4.15 with this client and in plain HTTP, and right on 0.4.23 with this client | — | +| KD-40 | The list of the collections ignores `limit` and `offset` and returns every collection | 0.4.10 and 0.4.12 – 0.4.15 on the v1 API; right from 0.4.23; verified on 10 October 2026 on 0.4.10 and 0.4.15 with this client and in plain HTTP, as the Python client of those versions has no limit, and right on 0.4.23 with this client | — | +| KD-41 | A path that does not exist answers `404` with an empty body | 1.0.0 and 1.5.9; the 0.x servers answer with a message; verified on 10 October 2026 in plain HTTP, and on 1.0.0 with this client; 0.6.3 answers `{"detail":"Not Found"}` | writes the request in the message of the error | +| KD-42 | A missing collection answers "Collection [missing] does not exists" | 1.0.0; verified on 10 October 2026 with this client and with the Python client 1.0.0 | looks for "does not exist", which the text holds | +| KD-43 | A missing collection answers `404` on 1.x, `400` on 0.5.6 – 0.6.3 and `500` on 0.4.10 – 0.5.5 | 0.4.10 – 1.5.9; with this client and with the Python client of the same version on 0.4.24, 0.5.20, 0.6.3, 1.5.2 and 1.5.9 | `CollectionExistsAsync` recognizes the three, by "does not exist" in the message | +| KD-44 | `configuration.hnsw.space` at the creation is ignored on 0.4.10 – 0.5.3, answers `500` on 0.5.4 – 0.6.3, and is reported as `l2` on 1.0.0 – 1.0.5 | 0.4.10 – 1.0.5; verified on 10 October 2026 in plain HTTP on 0.4.10, 0.5.3, 0.5.5, 0.5.16, 0.6.3, 1.0.0 and 1.0.5, and on 1.0.0 and 1.0.5 with the Python client of the same version; the Python clients of the 0.x versions do not send it | sends the space as the `hnsw:space` metadata, which every version applies | +| KD-45 | The `max_batch_size` that `pre-flight-checks` declares is not enforced: a write of 5462 records goes | 1.0.15 – 1.5.9; up to 1.0.13 it fails; verified on 10 October 2026 on 1.0.13, 1.0.15 and 1.5.9 with this client without batch splitting and in plain HTTP, as the Python client rejects the batch before the request | sends batches within the declared limit | +| KD-46 | `$in` and `$nin` without values answer `500` | 0.4.10 – 0.5.16; `400` from 0.5.17; verified on 10 October 2026 in plain HTTP on 0.4.10 and 0.5.16 (`500`) and on 0.5.18, 0.6.3 and 1.5.9 (`400`), as this client and the Python client send no such request | sends no request for `In` without values, and no `where` for `NotIn` without values | +| KD-47 | In the OpenAPI description the parameter of `GET` and `DELETE .../collections/{collection_id}` is named as an id, but the server reads a name: with an id it answers `404` on 1.x, while 0.5.16 and 0.6.3 answer `400` to a `GET` and `400` (0.6.3) or `500` (0.5.16) to a `DELETE` (chroma-core/chroma#4456) | 0.5.16 – 1.5.9; verified on 10 October 2026 in plain HTTP on 0.5.16, 0.6.3, 1.0.0 and 1.5.9, where a name answers `200` and this client sends the name | sends the name | +| KD-48 | After the records of a collection are deleted and written again, with the same ids or with new ones, by an add or an upsert, a query returns wrong neighbors: in a `cosine` collection with three records deleted and added again, `n_results: 1` on the vector of one of them returns the farthest of the three, and `n_results: 2` misses one of the two nearest, and which one changes from run to run; `n_results: 3` returns all three in the right order | 1.0.0 – 1.0.5, also with this client and with the Python client 1.0.0 on 1.0.0 and 1.0.5, with new ids too; right from 1.0.6 | — | +| KD-49 | After an upsert or an update with embeddings of records that exist, also with the embeddings they had, a query can miss a record for good, while `get` and `count` find it: of 60 records with random vectors, after an upsert of 20 of them with new vectors, `n_results: 60` returns 59 in 9 runs of 400 on 1.5.9 with `cosine`, and a query with the vector of the missing record does not find it. The same case with this client and with the Python client 1.5.9 lost a record on 1.5.9 in 11 and in 9 runs of 300, and on Chroma Cloud in none of 300 with either client. Upstream: chroma-core/chroma#7758 | 1.5.9, with this client and with the Python client; 1.0.21, 1.3.7 and 1.5.0 with plain HTTP, with `l2`, `cosine` and `ip`; none in 100 runs on 0.6.3; not seen on Chroma Cloud; the other versions not verified yet | on the server, the environment variable `RAYON_NUM_THREADS=1` made it lose no record in our measurements on 1.5.9; in the client, `WithUpsertStrategy(ChromaUpsertStrategy.SkipUnchangedEmbeddings)` updates without the embedding a record whose embedding does not change, and a record whose embedding changes stays exposed. See [Records lost after an update of their embeddings](#records-lost-after-an-update-of-their-embeddings) | +| KD-50 | A collection created without a space and one created with `configuration.hnsw.space` come back the same: no metadata, and `hnsw_configuration.space` `l2`. The first uses `l2`, the second its space: a query of `[0, 0.1]` on `[3, 4]` gives the distance 24.21 in the first and 0.2 in a `cosine` one | 1.0.0 – 1.0.5, also with this client and with the Python client 1.0.0 on 1.0.0 and 1.0.5; right from 1.0.6 | `Space` is null for a collection without the `hnsw:space` metadata, and `ExpectedSpace` lets it pass, without an exception: use Chroma 1.0.6 or later. The collections the client creates have the metadata, so their space is known | +| KD-51 | After some upserts with the same embeddings, a query with `n_results` equal to the count of the records returns one less, while `get` and `count` find it; after the next write it comes back. On 0.6.3, in a `cosine` collection of 60 records, records were missing in 28 queries of 300 and in 25 of 150, two series of runs; a single upsert, 30 runs with plain HTTP, missed none | 0.5.15, 0.5.16, 0.5.20 and 0.6.3, on the v2 and the v1 API; not on 0.4.10, 0.4.15 and 0.4.23; verified on 10 October 2026 on 0.5.20 and 0.6.3, with this client and with the Python client of the same version | — the same with every upsert strategy | +| KD-52 | A query while a record is deleted and added again can return that record with no metadata, which the client reads as an empty `Metadata`; an upsert of the record instead does not do it. In a `cosine` collection of 8 records, one task deleted a record and added it again, with the same metadata, for 15 seconds, while four tasks queried all 8. On 1.5.9: with this client, 14 results in 442 queries, 6 in 278 and 3 in 264; with the Python client 1.5.9, 5 in 337 and 6 in 735; with upserts, none with either client. On Chroma Cloud, none: 450 queries with this client and 387 with the Python client | 1.5.9, with this client and with the Python client; not seen on Chroma Cloud; the other versions not verified yet | — | +| KD-53 | A delete by ids answers as deleted the number of ids it was given, also the ids without a record: in a collection with `a` and `b`, a delete of `zz` answers `{"deleted": 1}` and the count stays 2; a delete of `a` and `zz` answers 2 and the count goes to 1; a delete of three ids without a record answers 3. `DeleteAsync(ChromaDelete)` of this client and `delete` of the Python client 1.5.9 return that number | 1.5.9 and Chroma Cloud, with this client and with the Python client; the other versions not verified yet | returns the number the server answers | +| KD-54 | A filter that compares a number with metadata of the other numeric type gives wrong results. With `n` 2 and 3 as integers and 3.0 and 2.5 as floats: `n >= 2.25` finds 2.5 and 3.0 but not 3, `n >= 2.0` leaves out 2 and 3, and `n == 3.0` finds only 3.0, as a float in a filter does not match integer metadata; `n == 3` finds only 3, and `n >= 3` finds 2.5 too. `n >= 2` finds all four | Chroma Cloud, with this client and with the Python client 1.5.9; on 1.5.9 the same filters are right, except `n >= 2.25`, which also finds 2 (KD-6); the other versions not verified yet | writes `2.0` as a float and `2` as an integer, so each number keeps its type | +| KD-55 | A `$contains` filter on the documents longer than 130 characters gets `422` "Quota exceeded: 'Length of where document value' exceeded quota limit for action 'Get': current usage of 131 exceeds limit of 130", while the Quotas & Limits page of Chroma Cloud gives 256 for the size of a full-text or regex search. 130 characters find the document | Chroma Cloud, a tenant with the default quotas, with this client and with the Python client 1.5.9; 1.5.9 takes 131 and 149 characters; the other versions not verified yet | reports the error of the server | +| KD-56 | A `$regex` filter on the documents whose pattern takes an empty text, like `^$`, `^` or `^.*$`, finds every record: also one whose document does not match, like `x` for `^$`, and one without a document. `$not_regex` with such a pattern finds no record. A pattern with a character, like `^x$`, is right | Chroma Cloud, with this client, with the Python client 1.5.9 and in plain HTTP; 1.5.9 finds only the records that match; the other versions not verified yet | throws an `ArgumentException` on Chroma Cloud for such a pattern, and for one it cannot read, which Chroma rejects, before the request, in every read and delete, where the filter would delete every record. It reads a pattern as Chroma does, with the syntax of the Rust crate `regex`: anchors and word boundaries take no character, and a repetition the minimum of its count. `Regex("(?s).")` finds the records with a document that is not empty, and `NotRegex("(?s).")` the records with an empty document or none | +| KD-57 | A `$contains` filter on the documents with a text of fewer than 3 characters, like `ab`, `a` or `""`, finds no record once Chroma Cloud has indexed the records, and `$not_contains` with such a text finds every record, also one without a document. Before the indexing, right after the writes, both are right. A text of 3 characters is right. The characters are the Unicode code points: `αβ` and `😀` are wrong, `αβγ` is right | Chroma Cloud, with this client, with the Python client 1.5.9 and in plain HTTP; 1.5.9 finds the records that contain the text; the other versions not verified yet | throws an `ArgumentException` on Chroma Cloud for such a text, before the request, in every read and delete, where `$not_contains` would delete every record. `Regex` and `NotRegex` with the text as the pattern, its special characters escaped, are right, like `Regex("ab")` | +| KD-58 | The metadata key `chroma:document`, where Chroma keeps the document of a record, with a value that is not a text, like `1.0`, `false`, `3`, a list or, in an update or an upsert, `null`: the write gets `500` "Error sending message to compactor", and from then on every add and get of the server, on every collection, gets `500` until the server restarts. Creating a collection and the heartbeat still answer. A text sets the document, as `documents` does | 1.0.0 – 1.5.9, in plain HTTP on 20 and 21 October 2026: numbers and Booleans in an add on 1.0.0, 1.0.5, 1.0.20, 1.1.0, 1.2.2, 1.3.7, 1.4.1, 1.5.0, 1.5.5 and 1.5.9; updates, upserts, `null` and lists on 1.0.0 and 1.5.9, where 1.0.0 rejects a list with `422`. 0.4.10, 0.5.20 and 0.6.3 keep working. The Python client 1.5.9 rejects the key with any value | throws an `ArgumentException` before the request for a value that is not a text, in `AddAsync`, `UpdateAsync` and `UpsertAsync`, also for the null of a deleted document or the mark that `WithDocumentCopyKey` or `WithDocumentDeletionKey` would write under that key; a text goes as it is | + +The query of KD-3 returned 3 records in every run on Chroma 0.5.20, 1.0.0 and 1.5.9, 10 runs each on a server just started. + +## Records lost after an update of their embeddings + +Chroma 1.0.21 to 1.5.9, installed on your own servers, may lose a record from the vector index after an `UpdateAsync` or an `UpsertAsync` with embeddings of records that exist, also with the embeddings they had. A query no longer finds it, not even with its own embedding, while `GetAsync` and `CountAsync` do. The record lost is often not one of those written. Chroma applies the records of a write to the vector index in parallel, and the update of a record in place can leave another one without links. Chroma Cloud has not shown it, and Chroma 0.x has not the defect. It is KD-49 in [docs/COMPATIBILITY.md](#known-defects-of-the-servers). + +### On the server: `RAYON_NUM_THREADS=1` + +With the environment variable `RAYON_NUM_THREADS=1` on the server, the parallel work of Chroma runs on one thread, and no record was lost in our measurements, whatever the client does. It costs time on upserts of records that exist: + +```bash +docker run -e RAYON_NUM_THREADS=1 -p 8000:8000 chromadb/chroma:1.5.9 +``` + +Measured on Chroma 1.5.9, in a `cosine` collection, two containers started the same way, with and without the variable: + +| Case | Without | With `RAYON_NUM_THREADS=1` | +|---|---|---| +| 120 records, 20 written again with new vectors | 23 runs of 200 miss a record | 0 of 200, and 0 of 600 more | +| 60 records, 20 written again with the same vectors and new metadata | 27 runs of 300 | 0 of 300 | +| add of 5,000 records of 384 dimensions | 0.70 to 1.35 s | 1.29 to 1.69 s | +| upsert of the same 5,000 records with new vectors | 1.34 to 2.06 s | 7.32 to 7.79 s | +| 100 queries of 10 results | 0.23 to 0.35 s | 0.23 to 0.35 s | + +The times are of 6 runs each. One thread makes the defect much rarer, but it may not remove it all: with the code of Chroma changed to apply the records in sequence, another measurement lost a record in 1 run of 600, with 60 records. Not measured yet: a long run of many updates, and the versions from 1.0.21 to 1.5.0. Writing the records one per request from the client does not help: the runs that miss a record stay the same. + +The strategies of the client: [upsert-strategies.md](upsert-strategies.md). + +## Other behaviors of the servers + +**Very long filters.** A single Chroma server turns a list of filters into an SQLite expression as deep as the list, and SQLite stops at 1000. Chroma 1.5.9 takes 987 to 994 filters in one list, depending on the operator. Split this way, Chroma 1.5.9 takes up to 8,167 filters, and Chroma 1.0.0 about 4,090. Beyond that, SQLite answers "too many SQL variables", the client throws a `ChromaException`, and the server stays up. That limit counts the values of the query, not the filters: on Chroma 1.5.9 an `In` or a `NotIn` takes about 16,000 values. Checked on 10 October 2026 with the Python client of the same version, which sends a list as it is: in one list Chroma 1.5.9 takes 987 metadata filters with `$ne`, 988 with the other comparisons, and 993 or 994 document filters, and Chroma 1.0.0 takes 991 to 993 metadata filters. Split, Chroma 1.5.9 takes 8,167 filters in an `$and` or an `$or` with this client, and Chroma 1.0.0 4,087 in an `$and` and 3,619 in an `$or`. An `In` or a `NotIn` takes 16,380 values on 1.5.9 and 16,378 on 1.0.0, with the two clients. + +A single Chroma server from 1.0.17 accepts `UpdateTenantAsync` but does not keep the name. On 1.5.9 the update answers `200`, and the tenant comes back with a null `resource_name`, with this client and in plain HTTP, as the Python client 1.5.9 has no such call (checked on 10 October 2026). + +Two single servers 1.5.9 on the same data start without an error, and each reads what the other writes: in Docker, two containers on one volume, with this client and with the Python client 1.5.9 (checked on 10 October 2026). + +## Chroma Cloud + +Checked on 4 October 2026 against `api.trychroma.com`: the key goes in `X-Chroma-Token`, the default of `WithChromaToken`, since `Authorization: Bearer` gets `401`. `pre-flight-checks` declares a `max_batch_size` of 1000 and `supports_base64_encoding`, but a write of more than 300 records gets `422` with `Quota exceeded`, the default quota; `WithBatchSplitting(maxBatchSize: 300)` writes and deletes 301 records in two batches. Embeddings sent in base64 read back identical. A missing collection gets `404` with `NotFoundError`. + +A metadata value has at most 8,182 bytes, and a document 16,384: a value of 8,183 bytes gets `422` with `Quota exceeded`, and so does every write of a metadata key beyond 36 bytes, also when a collection whose schema names that key was created without an error (checked on 6 October 2026). + +The metadata of a record has at most 32 keys, and a null value counts as a key: an add with 33 keys, and an upsert with 32 values and a null, get `422` "Quota exceeded: 'Number of metadata dictionary keys' exceeded quota limit for action 'Upsert': current usage of 33 exceeds limit of 32", with this client and with the Python client 1.5.9 (checked on 10 October 2026). + +A filter has at most 8 predicates, the default quota of a tenant: a `where` with 9, nested ones included, gets `422` with `Quota exceeded: 'Number of where clause predicates'`, and a link to ask for more; the values of an `In` do not count (checked on 5 October 2026). + +A `$regex` or `$not_regex` pattern has at most 256 bytes in UTF-8, the default quota of a tenant, which can be raised, apart from the 130 of `$contains` (KD-55): one of 257 bytes, like 257 ASCII characters, 129 `é` or 65 emoji, gets `422` "Quota exceeded: 'Length of regex pattern' exceeded quota limit for action 'Get': current usage of 257 exceeds limit of 256", in get, query, delete and search, with a link to ask for more (checked with this client and with the Python client 1.5.9 on 10 October 2026, and in plain HTTP with ASCII, `é` and emoji on 19 October 2026). A single server has no limit: Chroma 1.5.9 takes a pattern of 10,000 characters. The client sends the pattern, as a tenant can have a higher quota, and on that answer throws a `ChromaException` that names the pattern, its bytes and the limit of the answer. + +Right after a write, a count that reads only the index (`CountAsync(ChromaReadLevel.IndexOnly)`) can be lower than the number of the records: right after an add of 6 records it answered 0, and `CountAsync()` 6, with this client and with the Python client 1.5.9 (checked on 10 October 2026). A single Chroma server 1.5.9 answered 6 to both. + +| Chroma server | API | Tested | +|---|---|---| +| Chroma Cloud | v2 | the tests pass, except the operations an API key cannot run, like `CreateTenantAsync` and `ResetAsync`, which get `403` "Permission denied.", also with the Python client 1.5.9 (checked on 10 October 2026); see [Chroma Cloud](chroma-cloud.md) | + +Chroma Cloud declares 1000, but takes 300 records per write and answers at most 300 records per read, unless the quota is raised: a get without a limit returns 300 of 350 records, without an error, a get with a limit of 350 gets `422` "Quota exceeded: 'Limit value'", and an add of 301 records gets `422` and writes none of them, with this client without batch splitting and with the Python client 1.5.9 (checked on 10 October 2026). With batch splitting, `GetAsync` reads the 350 records in pages. + +Chroma Cloud keeps the other SPANN settings fixed: the RNG factors at 1, `initial_lambda` at 100, and the quantization, which users cannot set. The schema of a new collection reports `search_rng_factor` and `write_rng_factor` at 1 and `initial_lambda` at 100, with this client and with the Python client 1.5.9, which have no setting for them; in plain HTTP another value gets `400` "Invalid SPANN configuration: write_rng_factor: Validation error: range", with a minimum and a maximum of 1, and of 100 for `initial_lambda` (checked on 10 October 2026). The quantization is not verified yet. + +`QueryAsync` with more than 300 results, `Offset` included, gets the quota error of Chroma Cloud, "'Number of results' exceeded quota limit", unless the quota is raised. `GetAsync` reads in pages, and `SearchAsync` returns more than 300 results. Checked on 10 October 2026 with this client and with the Python client 1.5.9: a query of 300 results answers, one of 301 gets `422`, and so does `QueryAsync` of 299 results with an `Offset` of 2; a search with a limit of 350, also in its `Knn` ranking, returns 350 records. + +`GetCollectionByCrnAsync` is there as in the JavaScript client of Chroma, but the operation is hidden in the OpenAPI description of Chroma and missing from its documentation. + +Only Chroma Cloud serves the Search API of Chroma; a single Chroma server answers `501`: 1.5.9 answers "Error executing plan: Not implemented: Search operation is not implemented for local executor", with this client and with the Python client 1.5.9 (checked on 10 October 2026); the other versions not verified yet. + +A BM25 search, `SparseKnn` of a text on an index with `chroma_bm25`, ranks the records without the terms of the text too, among its `limit`, with the score 1: in a collection of `apple pie`, `banana bread`, `cherry tart` and `apple cider`, a search of `apple` returns `banana bread` and `cherry tart` with 1, with this client and with the Python client 1.5.9 (checked on 10 October 2026). + +An embedding has at most 4,096 dimensions: one of 4,097 gets `422` "Quota exceeded: 'Embedding dimension' exceeded quota limit for action 'Add': current usage of 4097 exceeds limit of 4096", with this client and with the Python client 1.5.9 (checked on 10 October 2026). + +An id and the name of a collection have at most 128 bytes: 129 get `422` with "'ID size (bytes)'" and "'Name size (bytes)'", with this client and with the Python client 1.5.9 (checked on 10 October 2026). The name of a database is not verified yet. + +Queries at once on one collection can get `429` "Too many requests; backoff and try again": 9 of 30 with the Python client 1.5.9, 2 of 60 and 3 of 100 with this client, which does not retry; 30 adds at once all went (checked on 10 October 2026). + +The metadata of a collection has at most 16 keys and 256 bytes for a value: 17 keys and a value of 257 bytes get `422` when the collection is created, with this client and with the Python client 1.5.9 (checked on 10 October 2026). + +A tenant or a database that does not exist gets `403` "Permission denied.", not `404`, with this client and with the Python client 1.5.9 (checked on 10 October 2026). + +The resource name of a tenant that has one cannot be set again: `UpdateTenantAsync` gets `409` "Failed to set resource name", also with the same name, with this client and in plain HTTP, as the Python client 1.5.9 has no such call (checked on 10 October 2026). + +The filter of a search is the where clause itself, as the Python client sends it: `{"where_clause": ...}`, as the OpenAPI description has it, gets `422` "Failed to deserialize the JSON body into the target type: searches[0].filter: Invalid where clause", in plain HTTP (checked on 10 October 2026). + +## Versions tested in the CI + +Every change runs the whole suite against: + +- v2 API: 0.5.16, 0.5.20, 0.6.3, 1.0.0, 1.5.9, and `latest` without blocking; +- v1 API: 0.4.10, 0.4.15, 0.4.23, 0.5.15. + +On the older servers the tests of the features they miss are skipped, with the reason. diff --git a/docs/about-this-fork.md b/docs/about-this-fork.md new file mode 100644 index 0000000..b2c44a2 --- /dev/null +++ b/docs/about-this-fork.md @@ -0,0 +1,11 @@ +# About this fork + +The original project was created by [ssone95](https://github.com/ssone95) and largely written by [cincuranet](https://github.com/cincuranet). Its last change dates from February 2025, and it still targets the Chroma v1 API, which Chroma 1.x no longer serves. + +This fork: + +- integrates the pending upstream pull requests [80](https://github.com/ssone95/ChromaDB.Client/pull/80), by [inlineHamed](https://github.com/inlineHamed), and [82](https://github.com/ssone95/ChromaDB.Client/pull/82), by [richlander](https://github.com/richlander), which move the client to the v2 API; +- tests the client against several Chroma versions, with its .NET 8 and .NET Standard 2.0 builds; +- works through the issues reported upstream, one pull request each. + +The original commits of the upstream pull requests, which were squash-merged, are kept in the [`upstream-history`](https://github.com/ChromaDotNet/ChromaDB.Client/tree/upstream-history) branch. diff --git a/docs/chroma-cloud.md b/docs/chroma-cloud.md new file mode 100644 index 0000000..5920415 --- /dev/null +++ b/docs/chroma-cloud.md @@ -0,0 +1,62 @@ +# Chroma Cloud + +[COMPATIBILITY.md](COMPATIBILITY.md#chroma-cloud) has the quotas and the behaviors of Chroma Cloud. + +```csharp +var options = new ChromaConfigurationOptions(uri: "https://api.trychroma.com").WithChromaToken(apiKey); +var client = await new ChromaClient(options, httpClient).WithTenantAndDatabaseFromIdentityAsync(); +``` + +`WithTenantAndDatabaseFromIdentityAsync` takes the tenant and the database from the credentials, as the `CloudClient` of the Python client does: + +- an API key for one database gives both; +- an API key for a whole tenant gives only the tenant, so the database goes in the options; +- a tenant or database set in the options, other than the default ones, must match the one the key gives, when it gives one; +- a single Chroma server always answers with `default_tenant` and `default_database`. + +These operations exist only on Chroma Cloud; a single Chroma server answers them with an error. + +```csharp +var copy = await collectionClient.ForkAsync("my_collection_copy"); +var forks = await collectionClient.ForkCountAsync(); +var status = await collectionClient.GetIndexingStatusAsync(); +``` + +`ForkAsync` copies a collection with its records under a new name. `GetIndexingStatusAsync` tells how many writes are indexed. + +On every server, `CountAsync(ChromaReadLevel.IndexOnly)` counts only the records already indexed. Chroma Cloud indexes them later, so right after a write the count can be lower, even 0, while `CountAsync()` already sees them. A single server indexes them at once and gives the same count. + +```csharp +await collectionClient.AddAsync(ids, embeddings: embeddings); // 6 records +var all = await collectionClient.CountAsync(); // 6 +var indexed = await collectionClient.CountAsync(ChromaReadLevel.IndexOnly); // on Chroma Cloud, from 0 to 6 until they are indexed +``` + +```csharp +var (attached, created) = await collectionClient.AttachFunctionAsync(ChromaFunctions.Statistics, "my_stats", "my_stats_output"); +var function = await collectionClient.GetAttachedFunctionAsync("my_stats"); +await collectionClient.DetachFunctionAsync("my_stats", deleteOutputCollection: true); +``` + +The functions of Chroma Cloud, `ChromaFunctions.Statistics` and `ChromaFunctions.RecordCounter`, run on the records of a collection and write their results to an output collection. + +```csharp +await client.UpdateTenantAsync("my_tenant", resourceName: "my_org"); +var collection = await client.GetCollectionByCrnAsync("my_org:my_database:my_collection"); +``` + +`UpdateTenantAsync` sets the resource name of a tenant, which `GetTenantAsync` returns as `ResourceName`. `GetCollectionByCrnAsync` gets a collection by its Chroma Resource Name. + +`GetCollectionByCrnAsync` on Chroma Cloud: KD-26 in [COMPATIBILITY.md](COMPATIBILITY.md#known-defects-of-the-servers). + +## Batches on Chroma Cloud + +So on Chroma Cloud, at `*.trychroma.com`, the client uses 300 when no limit is given. Behind a proxy or another address, `WithChromaCloud()` on the options says the server is Chroma Cloud, for this and the other rules of Chroma Cloud. A server may reject a batch over its quota of records before writing any of it, as Chroma Cloud does with "current usage of 301 exceeds limit of 300". The client then sends that batch, the rest and the next writes in batches of the quota. A raised quota needs its limit: + +```csharp +var options = new ChromaConfigurationOptions(uri: "https://api.trychroma.com").WithChromaToken(apiKey) + .WithTenant(tenant).WithDatabase(database) + .WithBatchSplitting(maxBatchSize: 1000); // a quota raised to 1000 records +``` + +`ChromaCloudQuotas` holds the default quotas of a Chroma Cloud tenant, as its documentation lists them: 300 records per request, 8,182 bytes per metadata value, 16,384 per document, 32 metadata keys of at most 36 bytes, 8 predicates per filter. A single Chroma server has none of them. Chroma Cloud creates a collection whose schema names a key beyond 36 bytes, and then rejects every write with that key, so on Chroma Cloud `CreateCollectionAsync` and `GetOrCreateCollectionAsync` throw an `ArgumentException` before the request. Chroma Cloud counts the nulls of an update or an upsert against the 32 keys too: a write with nulls that would take a record beyond them throws an `ArgumentException` before it goes, unless `WithReadsBeyondKeyQuota()`, which reads the record first ([metadata.md](metadata.md)). A record with more than 32 values and no null goes, and Chroma Cloud rejects it with `422`. diff --git a/docs/collections.md b/docs/collections.md new file mode 100644 index 0000000..7b35ebc --- /dev/null +++ b/docs/collections.md @@ -0,0 +1,84 @@ +# Collections + +## Collections and records + +```csharp +var collections = await client.ListCollectionsAsync(); +var page = await client.ListCollectionsAsync(limit: 10, offset: 20); +var count = await client.CountCollectionsAsync(); +await collectionClient.ModifyAsync(name: "renamed", metadata: new Dictionary { ["owner"] = "me" }); +var first = await collectionClient.PeekAsync(5); +await client.DeleteCollectionAsync("renamed"); +var deleted = await client.DeleteCollectionIfExistsAsync("renamed"); // false: it no longer exists +var heartbeat = await client.HeartbeatAsync(); +var checks = await client.GetPreFlightChecksAsync(); // MaxBatchSize, SupportsBase64Encoding +var identity = await client.GetUserIdentityAsync(); // UserId, Tenant, Databases +var database = await client.GetDatabaseAsync("my_database"); +``` + +`ModifyAsync` changes the name or the metadata of a collection. `PeekAsync` returns its first records. `DeleteCollectionIfExistsAsync` takes a missing collection as deleted already, which the client recognizes as `CollectionExistsAsync` does (KD-43 in [docs/COMPATIBILITY.md](COMPATIBILITY.md)). + +`DeleteCollectionAsync`, `DeleteCollectionIfExistsAsync` and `DeleteDatabaseAsync` send one request, as Chroma does. If your records have lists in their metadata, pass `deleteRecordsFirst: true`: on Chroma 1.x, except Chroma Cloud, the client then deletes the records first, in batches. That takes about two requests for every 5,461 records, about 370 for a million. If it stops halfway, the collection keeps part of its records or none of them: delete it again. If the server returns records after their delete, the client throws a `ChromaException` and keeps the collection, instead of deleting it with them. + +The lists of the records of a deleted collection: KD-12 in [COMPATIBILITY.md](COMPATIBILITY.md#known-defects-of-the-servers). + +## Tenants and databases + +```csharp +await client.CreateTenantAsync("my_tenant"); +await client.CreateDatabaseAsync("my_database", tenant: "my_tenant"); + +var options = new ChromaConfigurationOptions(uri: "http://localhost:8000", tenant: "my_tenant", database: "my_database"); +``` + +The collections created with these options belong to that tenant and database. + +```csharp +var databases = await client.ListDatabasesAsync(tenant: "my_tenant"); +var page = await client.ListDatabasesAsync(limit: 10, offset: 20, tenant: "my_tenant"); +await client.DeleteDatabaseAsync("my_database", tenant: "my_tenant"); +``` + +[docs/COMPATIBILITY.md](COMPATIBILITY.md) lists the servers that have `ListDatabasesAsync` and `DeleteDatabaseAsync`. + +## Collections by id + +```csharp +var collection = await client.GetCollectionByIdAsync(id); +``` + +`GetCollectionByIdAsync` looks for the id in the tenant and database of the options, or in the ones you pass. [docs/COMPATIBILITY.md](COMPATIBILITY.md) lists the servers that have it. + +A `ChromaClient` hands out the clients for the records of its collections, with its options and `HttpClient`, without sending a request. The id and the name are enough: + +```csharp +var collectionClient = client.GetCollectionClient(collection); +var sameCollection = client.GetCollectionClient(collectionId, "my_collection"); +var byName = client.GetCollectionClient("my_collection"); // whichever collection has the name + +// or without a ChromaClient; the tenant and database come from the options +var standalone = new ChromaCollectionClient(collectionId, "my_collection", options, httpClient); +``` + +A collection client made by name reads the collection before its first request, which `GetCollectionAsync` returns. When a request fails on that id, it reads the collection again: if the name has another id, as when the collection was deleted and created again elsewhere, the operation runs again, once, on that collection, and otherwise the failure stands. The servers tell a collection gone in their own ways ([docs/COMPATIBILITY.md](COMPATIBILITY.md)), so the client compares the ids. + +## Collections created when missing + +Two ways give a client for the records of a collection that is created when the name has none: + +```csharp +// When the first operation goes: no request now +var lazy = client.GetCollectionClient("my_collection", createIfMissing: true); + +// Now: the request goes in this call +var now = await client.GetOrCreateCollectionClientAsync("my_collection"); + +// With the metadata, the configuration or the schema of a new collection +var defined = client.GetCollectionClient(new ChromaCollectionDefinition("my_collection") { Metadata = metadata }, createIfMissing: true); +``` + +- `GetCollectionClient(name, createIfMissing: true)` sends no request. Its first operation gets or creates the collection, with one request that the operations which start before it ends share; each of them keeps its own cancellation. An error of that request comes from the first operation, and the next one tries again. +- `GetOrCreateCollectionClientAsync(name)` sends the request now, so its error comes from that call; then the client works as the other one. +- Both keep the collection. When a request fails because the collection was deleted elsewhere, they get or create it again, empty, with the same definition, and run the operation again, once: never a write that went in part, which throws with the number of records that went. +- With `createIfMissing: false`, the client is the one of `GetCollectionClient(name)`, which never creates the collection. +- The client does not take the embedding generator of a definition: give it with `WithEmbeddingGenerator`. diff --git a/docs/connecting.md b/docs/connecting.md new file mode 100644 index 0000000..4e531eb --- /dev/null +++ b/docs/connecting.md @@ -0,0 +1,49 @@ +# Connecting + +## The URI and the HttpClient + +The URI can be just the address of the server, like `http://localhost:8000`, and the client adds `/api/v2/`. A URI with a path, like `http://localhost:8000/api/v2`, is used as it is, with or without the trailing slash. + +Without an `HttpClient`, `new ChromaClient("http://localhost:8000")` or `new ChromaClient(options)` creates its own, and `Dispose` closes it: `using var client = new ChromaClient("http://localhost:8000");`. The clients it returns, like the collection clients, share that `HttpClient`. An `HttpClient` you pass to the constructor stays open. + +## Sharing a client between threads + +`ChromaClient` and the collection clients can be shared between threads, as singletons: a call keeps its state to itself, what the client learns about the server is read once under a lock, and a collection client made by name may read its collection more than once when calls start together. Two clients that write the same records at the same time get what Chroma does with them. + +## API version + +The client uses the v2 API. For the servers that have only the v1 API, like Chroma 0.5.15, choose it once in the options: + +```csharp +var options = new ChromaConfigurationOptions(uri: "http://localhost:8000").WithApiVersion(ChromaApiVersion.V1); + +// or, with dependency injection +services.AddChromaClient(options => options!.WithUri("http://localhost:8000").WithApiVersion(ChromaApiVersion.V1)); +``` + +## Authentication + +```csharp +var options = new ChromaConfigurationOptions(uri: "http://localhost:8000"); + +// X-Chroma-Token: , the header of Chroma Cloud. +var tokenOptions = options.WithChromaToken("token"); + +// Authorization: Bearer , the default of the token authentication of the Chroma 0.x servers. +var bearerOptions = options.WithChromaToken("token", ChromaTokenTransportHeader.Authorization); + +// Authorization: Basic, for the Chroma 0.x servers with basic authentication. +var basicOptions = options.WithBasicAuth("admin", "password"); +``` + +The client adds the credentials to each of its requests, without changing the `HttpClient` you pass. + +## Connection strings + +From a connection string, as the settings of an application keep it: + +```csharp +var options = ChromaConfigurationOptions.FromConnectionString("Endpoint=https://api.trychroma.com;Token=ck-...;Tenant=...;Database=..."); +``` + +`Endpoint` is the http or https URI of the server, and a connection string that is just a URI is the endpoint alone; another key, no endpoint, or an endpoint without http or https throws an `ArgumentException` that says which. `Token` goes in the `X-Chroma-Token` header. `Tenant` and `Database` are the ones of the requests. diff --git a/docs/dependency-injection.md b/docs/dependency-injection.md new file mode 100644 index 0000000..1647797 --- /dev/null +++ b/docs/dependency-injection.md @@ -0,0 +1,17 @@ +# Dependency injection + +`ChromaDotNet.Client.DependencyInjection` adds the registration for `Microsoft.Extensions.DependencyInjection`: + +- `AddChromaClient` registers the `ChromaClient` as a singleton, so singletons can take it too. `AddKeyedChromaClient` registers more than one, under different keys, for other servers, tenants or databases. Each key gets its own `HttpClient`, also keys with the same text like `1` and `"1"`; a null key throws an `ArgumentNullException`. +- The client's `HttpClient` sends each request through the current handler of `IHttpClientFactory`. The factory renews the handler after its lifetime, two minutes by default, so a DNS change of the server address is picked up on every target framework. After two minutes the client also asks again what it learned about the server, like its version. +- An overload configures that `HttpClient`, for example with a resilience handler, a proxy or a timeout: `services.AddChromaClient(_ => options, builder => builder.AddStandardResilienceHandler())`. +- `serviceProvider.CreateChromaClient(options, httpClientName)` creates a client the same way when it is kept elsewhere, like in an integration with an `HttpClient` name of its own. +- `AddChromaClientFromServices` and `AddKeyedChromaClientFromServices` make the options from the services, when the client or its options are first resolved: so the address can come from `IConfiguration` or `IOptions` of the application, also registered after the call. The function runs once, and a null it returns throws an `InvalidOperationException`. + +```csharp +services.AddChromaClientFromServices(serviceProvider => + new ChromaConfigurationOptions(serviceProvider.GetRequiredService()["Chroma:Uri"]!)); +services.AddKeyedChromaClientFromServices("archive", serviceProvider => + new ChromaConfigurationOptions(serviceProvider.GetRequiredService>().Value.ChromaUri), + builder => builder.AddStandardResilienceHandler()); +``` diff --git a/docs/embeddings.md b/docs/embeddings.md new file mode 100644 index 0000000..5c970f9 --- /dev/null +++ b/docs/embeddings.md @@ -0,0 +1,62 @@ +# Embeddings + +## The embeddings of the Python client + +[ChromaDotNet.Embeddings](https://www.nuget.org/packages/ChromaDotNet.Embeddings) computes the vectors of the default embedding function of Chroma, the one the Python client uses when a collection has no other: the model all-MiniLM-L6-v2, with 384 dimensions. So .NET and Python read the same collection with the same embeddings. + +``` +dotnet add package ChromaDotNet.Embeddings --prerelease +``` + +```csharp +using ChromaDB.Client; +using ChromaDB.Client.Models; +using ChromaDotNet.Embeddings; + +using var embeddings = await ChromaDefaultEmbeddingGenerator.CreateAsync(); +var collection = await client.GetOrCreateCollectionAsync(new ChromaCollectionDefinition("fruits") { EmbeddingGenerator = embeddings }); +var fruits = client.GetCollectionClient(collection).WithEmbeddingGenerator(embeddings); + +await fruits.AddAsync(["apple", "lemon"], documents: ["A crunchy apple", "A sour lemon"]); +var nearest = await fruits.QueryAsync("something sour", nResults: 1); +``` + +The first `CreateAsync` downloads the model into the cache of the Python client, or uses the one Python downloaded. The definition declares the function `default`, as the Python client does. The package is a preview, and runs in 64-bit processes only, not on Intel Macs: see its [repository](https://github.com/ChromaDotNet/ChromaDB.Embeddings). + +The [SearchByText](https://github.com/ChromaDotNet/ChromaDB.Local/tree/main/samples/SearchByText) sample of ChromaDotNet.Local stores and searches texts this way, on a Chroma started without Docker. [Open it in a Codespace](https://codespaces.new/ChromaDotNet/ChromaDB.Local?quickstart=1). + +## Embeddings from a generator + +```csharp +using Microsoft.Extensions.AI; +using OpenAI; + +// From Microsoft.Extensions.AI.OpenAI; any other IEmbeddingGenerator> works the same way. +IEmbeddingGenerator> generator = new OpenAIClient(apiKey).GetEmbeddingClient("text-embedding-3-small").AsIEmbeddingGenerator(); +var collectionClient = client.GetCollectionClient(collection).WithEmbeddingGenerator(generator); + +await collectionClient.AddAsync(["a", "b"], documents: ["apples are red", "kiwis are green"]); +var nearest = await collectionClient.QueryAsync("a red fruit", nResults: 1); +``` + +`WithEmbeddingGenerator` takes any `IEmbeddingGenerator>` of Microsoft.Extensions.AI. The client of the collection then computes the embeddings, as an embedding function does in the Python client of Chroma: + +- `AddAsync`, `UpdateAsync` and `UpsertAsync` compute the embeddings of the records given with documents and without embeddings. Every record then needs a document: a null document throws an `ArgumentException` before any request. +- Records given with embeddings keep them, and records without documents go as they are. +- `QueryAsync(queryText)` and `QueryTextsAsync(queryTexts)` compute the embeddings of the query texts, as `query_texts` in the Python client. Without a generator they throw an `InvalidOperationException`. +- The other `With` methods of the collection client keep the generator. The client does not dispose it. + +The builds for .NET 8 and .NET Framework have these methods. The build for .NET Standard 2.0, which applications on .NET Core 3.1 to 7 use, does not. + +## The default embedding function of Chroma + +The Python client of Chroma computes the embeddings with `all-MiniLM-L6-v2` when a collection has no other function, and stores the function as `default` in the configuration of the collection. With the generator of that function, a new collection declares it too: + +```csharp +var collection = await client.GetOrCreateCollectionAsync(new ChromaCollectionDefinition("fruits") { EmbeddingGenerator = generator }); +``` + +- The client recognizes the generator by its metadata: the provider `chroma` and the model `all-MiniLM-L6-v2`. Another generator declares nothing, and the collection is created as without it. +- `Configuration.EmbeddingFunction`, when set, wins over the function of the generator. +- Chroma 0.x takes no embedding function, so there the collection is created without it. Chroma 1.0.0 to 1.0.5 take it without reporting it ([docs/COMPATIBILITY.md](COMPATIBILITY.md)). +- The definition only declares the function: the client of the collection gets the generator with `WithEmbeddingGenerator`. diff --git a/docs/errors.md b/docs/errors.md new file mode 100644 index 0000000..b12ec1b --- /dev/null +++ b/docs/errors.md @@ -0,0 +1,31 @@ +# Errors + +A failed request throws a `ChromaException`, with the original exception as `InnerException`: an error answer from the server, a network error, an answer that is not the expected JSON, or a timeout. Other exceptions, like an assembly that does not load or a disposed `HttpClient`, are not wrapped. + +- `StatusCode` is the HTTP status of the answer, or null when there was no answer, like on a timeout. +- `ErrorType` is the kind of error the server names: `NotFoundError` or `InvalidArgumentError` from Chroma 1.x, `InvalidCollection` from Chroma 0.5 and 0.6, `ValueError` from the v1 API of Chroma 0.4, or null when it names none. + +```csharp +if (!await client.CollectionExistsAsync("my_collection")) +{ + await client.CreateCollectionAsync("my_collection"); +} +``` + +When a 0.x server rejects a request with validation errors, the message lists them, like `body.n_results: Input should be a valid integer`. + +`CollectionExistsAsync` tells a missing collection apart from other errors on every tested server (KD-43 in [docs/COMPATIBILITY.md](COMPATIBILITY.md)). Any other error, like a bare `404` from a wrong address, throws. + +## Reads that go again + +A read changes nothing, so after an error that can pass it goes again: each request of `GetAsync`, `PeekAsync`, `QueryAsync`, `SearchAsync` and `CountAsync`, and the reads that a write makes first, as the get of `ChromaUpsertStrategy.SkipUnchangedEmbeddings`, the get of `ReadsBeyondKeyQuota` on Chroma Cloud and the pages of ids that `DeleteCollectionAsync` reads with `deleteRecordsFirst`. The errors that can pass are an answer `429`, `500`, `502`, `503` or `504`, other than a missing collection, a request that fails on the network, and an answer whose lists do not have one item for every record. Chroma 1.5.9 answers `500` "Error finding id" or "Error creating hnsw segment reader" to a get with embeddings while other clients write and delete the same records, or answers it without the embedding of a record, and the same get right after goes. After the last attempt the error stands; for an answer whose lists do not have one item for every record, it is a `ChromaException` that says it, like "The answer of the server to a get has 1 id and 0 embeddings". A timeout of the `HttpClient` does not go again: the `ChromaException` with its `TaskCanceledException` stands. A write never goes again: one that failed may have gone. + +Before each new attempt the client waits with an exponential backoff with jitter: a random time between the smallest delay and the smaller of the largest delay and the base delay times 2 to the attempts that went again before. The smallest delay keeps a read from going again at once. Without it, it is 0.8 times the base delay, the lower bound of the jitter of Azure.Core; up to 2.17.0 it was zero. `ChromaReadRetries.Default` makes 4 attempts at most, with a smallest delay of 40 ms, a base delay of 50 ms and a largest delay of 1 second: it waits 40 to 50 ms, then 40 to 100 ms, then 40 to 200 ms. `WithReadRetries` sets others, with or without the smallest delay, and `ChromaReadRetries.None` turns them off: + +```csharp +var options = new ChromaConfigurationOptions(uri: "http://localhost:8000") + .WithReadRetries(new ChromaReadRetries(maxAttempts: 6, baseDelay: TimeSpan.FromMilliseconds(100), maxDelay: TimeSpan.FromSeconds(2))); +var withSmallestDelay = new ChromaReadRetries(maxAttempts: 6, minDelay: TimeSpan.FromMilliseconds(20), baseDelay: TimeSpan.FromMilliseconds(100), maxDelay: TimeSpan.FromSeconds(2)); +``` + +The smallest delay goes from zero to the base delay, and the largest delay from the base delay to `Int32.MaxValue` milliseconds; other values throw an `ArgumentOutOfRangeException` that names the parameter. diff --git a/docs/index-settings.md b/docs/index-settings.md new file mode 100644 index 0000000..6102343 --- /dev/null +++ b/docs/index-settings.md @@ -0,0 +1,65 @@ +# Index settings + +## Distance of a collection + +```csharp +var collection = await client.CreateCollectionAsync(new ChromaCollectionDefinition("my_collection") +{ + Configuration = new() { Space = ChromaSpace.Cosine }, +}); +Console.WriteLine(collection.Space); +``` + +`ChromaSpace` is `L2` (Chroma's default), `Cosine` or `InnerProduct`. The client sends it as the `hnsw:space` metadata (KD-44 in [docs/COMPATIBILITY.md](COMPATIBILITY.md)). `GetOrCreateCollectionAsync` takes a `ChromaCollectionDefinition` too. A collection that exists keeps its space, so `GetOrCreateCollectionAsync` throws a `ChromaException` when it has another space than the definition asks for; [docs/COMPATIBILITY.md](COMPATIBILITY.md) says how on the 0.x servers. + +`ChromaCollection.Space` reads the space back from that metadata, or from the configuration that Chroma 1.0.6 and later and Chroma Cloud send. On Chroma 0.x, which keeps the space only in that metadata, a collection without it uses `l2`, the default of Chroma, and `Space` is `L2`: the client asks the version of the server for that. It is null for a collection created without the `hnsw:space` metadata on Chroma 1.0.0 to 1.0.5, which report `l2` also for the ones that use another space (KD-50 in [docs/COMPATIBILITY.md](COMPATIBILITY.md)): there the space checks let it pass, so use Chroma 1.0.6 or later. `ChromaCollection.ConfigurationJson` holds the configuration as the server sends it. + +The configuration of a new collection also takes the settings of its vector index and the embedding function it declares: + +```csharp +var local = await client.CreateCollectionAsync(new ChromaCollectionDefinition("articles") +{ + Configuration = new() { Space = ChromaSpace.Cosine, Hnsw = new() { EfConstruction = 200, MaxNeighbors = 32 } }, +}); +var cloud = await cloudClient.CreateCollectionAsync(new ChromaCollectionDefinition("articles") +{ + Configuration = new() { Space = ChromaSpace.Cosine, Spann = new() { SearchNprobe = 32, WriteNprobe = 16 }, EmbeddingFunction = ChromaEmbeddingFunctionReference.Known("openai", new Dictionary { ["model_name"] = "text-embedding-3-small" }) }, +}); +``` + +- **`Hnsw`**, the index of a single Chroma server: `EfConstruction`, `EfSearch`, `MaxNeighbors`, `ResizeFactor`, `SyncThreshold`, `BatchSize` and `NumThreads`. They go as `hnsw:` metadata, like the space, which every tested Chroma applies. + - `MaxNeighbors` must be at least 2, here, in the `hnsw:M` metadata and in `ModifyConfigurationAsync`: the client throws an `ArgumentException` for 0 and 1 ([docs/COMPATIBILITY.md](COMPATIBILITY.md)). + - In the metadata, `hnsw:M`, `hnsw:construction_ef`, `hnsw:search_ef`, `hnsw:num_threads`, `hnsw:batch_size` and `hnsw:sync_threshold` are integers. Chroma rejects `100.0` for them, so the client sends a whole `double`, `float` or `decimal` as an integer and throws an `ArgumentException` for any other number. +- **`Spann`**, the index of Chroma Cloud: + - `SearchNprobe`, `WriteNprobe`, `EfConstruction`, `EfSearch`, `MaxNeighbors`, `SplitThreshold`, `MergeThreshold` and `ReassignNeighborCount` go in the `configuration` of the request, with the space, because Chroma ignores the `hnsw:space` metadata next to SPANN settings. + - `SearchRngEpsilon`, `WriteRngEpsilon`, `NreplicaCount`, `NumSamplesKmeans`, `NumCentersToMergeTo` and `CenterDriftThreshold` go in a schema, the only place Chroma takes them. +- **`EmbeddingFunction`** goes in the `configuration` of the request; [docs/COMPATIBILITY.md](COMPATIBILITY.md) lists the versions that take it and report it. +- **What the client checks:** + - A collection has one vector index. `Hnsw` and `Spann` together throw an `ArgumentException`, as Chroma rejects them. + - Chroma Cloud ignores `Hnsw`, and a single server ignores `Spann`. Then `CreateCollectionAsync` deletes the collection and throws a `ChromaException`, and `GetOrCreateCollectionAsync` throws and keeps it. Chroma 1.0.0 to 1.0.5 report no configuration, so there the client cannot tell. + - With `Spann` or `EmbeddingFunction`, the client throws a `ChromaException` before sending the `configuration` to the servers that fail on it or ignore it ([docs/COMPATIBILITY.md](COMPATIBILITY.md)). +- **With a schema**, every setting goes in the schema, on the vector index of `#embedding`, as `create_index(VectorIndexConfig(...))` of the Python client writes them. + +`ChromaCollection.Dimension` is the number of dimensions of the embeddings, set by the first write. `Version` and `LogPosition` are the version of the collection and its position in the log, as the server reports them; [docs/COMPATIBILITY.md](COMPATIBILITY.md) lists the versions that send them. + +## Settings of the index + +```csharp +await collectionClient.ModifyConfigurationAsync(new() { Hnsw = new() { EfSearch = 200 } }); +``` + +`ModifyConfigurationAsync` changes the index settings that Chroma allows after the creation: + +- the HNSW settings, like `EfSearch`; +- the `EfSearch` and `SearchNprobe` of the SPANN index of Chroma Cloud; +- the embedding function the collection declares, `EmbeddingFunction`. + +The versions that apply them are in [COMPATIBILITY.md](COMPATIBILITY.md). + +The client recognizes those versions from the configuration they send with the collection, and throws a `ChromaException` without sending the request. + +The settings must be those of the index of the collection: `Hnsw` on a single Chroma server, `Spann` on Chroma Cloud. Chroma Cloud answers `500` to `Hnsw` settings, and a single server accepts `Spann` settings without applying them. So for the settings of the other index, the client throws a `ChromaException` without sending them. On Chroma Cloud: + +```csharp +await collectionClient.ModifyConfigurationAsync(new() { Spann = new() { SearchNprobe = 32 } }); +``` diff --git a/docs/metadata.md b/docs/metadata.md new file mode 100644 index 0000000..c6a70d3 --- /dev/null +++ b/docs/metadata.md @@ -0,0 +1,45 @@ +# Metadata values + +By default, with `ChromaMetadataValues.Exact`: + +- strings stay strings; +- lists come back as `List` of `string`, `long`, `double` and `bool`, like single values; +- a `double`, `float` or `decimal` comes back as a `double`, also when it is whole. The client writes `2.0`, as the Python client does, and Chroma keeps it a float. + +Values that earlier versions of this client wrote as `2` stay integers in Chroma, so `LessThan("d", 2.2)` does not find them until they are written again. + +With `ChromaMetadataValues.Inferred`, as in earlier versions, a string that looks like a date comes back as a `DateTime`, and a list as a `JsonElement`: + +```csharp +var options = new ChromaConfigurationOptions(uri: "http://localhost:8000").WithMetadataValues(ChromaMetadataValues.Inferred); +``` + +A list in metadata, written and filtered: + +```csharp +await collectionClient.AddAsync(new ChromaRecords(["a"]) { Embeddings = [new([1f, 0.5f, 0f])], Metadatas = [new Dictionary { ["tags"] = new[] { "red", "blue" } }] }); +var tagged = await collectionClient.GetAsync(where: ChromaWhereOperator.Contains("tags", "red")); +``` + +In `UpdateAsync` and `UpsertAsync`, a null value or an empty list deletes the key on every tested Chroma, with the sparse vectors the client computes from its text. The type does not allow null, so write `null!`. A null goes to the record whether it has the key or not: every tested Chroma takes it, also in an upsert that creates the record. So two writes of the same record at the same time leave the record of the one that writes last: when that one deletes a key, the key goes, also if the other one added it. The keys the metadata does not have stay, as in Chroma. A null document keeps the stored one, as in Chroma 1.x and Chroma Cloud, while the 0.x servers delete it ([COMPATIBILITY.md](COMPATIBILITY.md)); with `NullDocumentsDelete = true` in `ChromaRecords` it deletes it, which Chroma 1.x cannot do, so the client writes an empty document to every record whose document goes, without a read. That needs one of three ways to delete documents, on the collection client: `WithDocumentCopyKey(key)` and `WithDocumentDeletionKey(key)` tell the empty document from an empty text and read it back as null, and `WithEmptyDocumentDeletions()` reads it back as an empty string. Without one of them, a null document with `NullDocumentsDelete` throws an `ArgumentException` before any request. A collection client chooses once: the same call again changes nothing, while another of the three methods, or the same one with another key, throws an `InvalidOperationException` that says the way the client has. On Chroma Cloud, which counts a null against its quota of 32 keys, a write with nulls that would take a record beyond the quota throws an `ArgumentException` before it goes, without a read. The count has the keys that the client adds too: the copy of the document, the mark of a deleted document and the BM25 sparse vectors. A record with more than 32 values and no null goes, and Chroma Cloud rejects it with `422` "Quota exceeded". With `WithReadsBeyondKeyQuota()` in the options of the client, the client reads that record first instead, and sends the nulls only to the keys it has, with two risks: a write of another client between the read and the write can keep what it wrote, and when an id comes more than once in the same write, a key that a later occurrence deletes can stay if an earlier one wrote it. `AddAsync` throws an `ArgumentException` for a null value or an empty list: Chroma 0.x would drop the key, and 1.x rejects the request. A record without metadata keys comes back with an empty `Metadata`, which Chroma sends as null; `Metadata` is null when the read does not include the metadatas. An empty metadata is sent as no metadata (null) in `AddAsync`, `UpdateAsync` and `UpsertAsync`, also in batches with other metadatas: every tested Chroma, 0.4.10 to 1.5.9 and Chroma Cloud, treats the two the same, so a new record has no metadata and a stored one keeps its own. The Python client rejects it instead. A list that holds values of more than one type, like a string and a number, throws an `ArgumentException`, as in the Python client: Chroma rejects it with an error that does not say what is wrong. The key `chroma:document`, where Chroma keeps the document of a record, takes a text only, which sets the document, also a `Guid`, a date or a `byte[]`, which the client writes as text: another value throws an `ArgumentException` before the request, as on Chroma 1.x it makes every write and read of the server fail until it restarts ([COMPATIBILITY.md](COMPATIBILITY.md), KD-58). The same holds when it is the key of `WithDocumentCopyKey` or `WithDocumentDeletionKey`: a deleted document or a mark there throws. In `ChromaRecords` a record can have null metadata or a null document, and a list of metadata that are all null goes as no metadata. + +`WithDocumentCopyKey(key)` of a collection client returns a client of the same collection that copies each document into the metadata key, so that a `where` filter can compare the whole text, which `where_document` cannot. A document deleted with `NullDocumentsDelete` loses its copy, so the client reads an empty document without its copy as null: a document comes back as it was written, empty or null. For that it reads the metadata with the documents, and leaves it out of the results that do not ask for it. The client deletes a document without reading the record first: it writes the empty document and deletes the copy of every record whose document goes, also of one without a document, which comes back as null all the same. A `where_document` filter that matches an empty text sees that record as one with an empty document: on Chroma 1.5.9 `$contains ""` and `$regex "^$"` find it and `$not_regex "^$"` does not, and on Chroma Cloud `$contains ""` finds it. On Chroma Cloud, which takes a metadata value of at most 8,182 bytes, a longer document goes without its copy, and an update or an upsert deletes the copy it had; on a single server every document has its copy. Up to 2.12.0 a second call with another key replaced the key; now it throws an `InvalidOperationException`, while the same key again works as before. + +`WithDocumentDeletionKey(key)` of a collection client returns a client of the same collection that marks a document deleted with `NullDocumentsDelete` under the metadata key, instead of copying every document. It writes the empty document with `true` under the key, and any other document that an update or an upsert writes deletes the mark with a null, so no write reads the records first. It reads an empty document with the mark as null, and an empty one without it as an empty string; the mark stays in `Metadata` like any key. For that it reads the metadata with the documents, and leaves it out of the results that do not ask for it. As with the copy, a deletion writes the empty document also to a record without a document, which a `where_document` filter that matches an empty text then sees as one with an empty document. On Chroma Cloud the mark and its null count against the quota of 32 keys. + +`WithEmptyDocumentDeletions()` of a collection client returns a client of the same collection that deletes a document with `NullDocumentsDelete` by writing an empty one, without a copy or a mark and without a read: it reads back as an empty string, as the Python client of Chroma reads it. As with the copy, a deletion writes the empty document also to a record without a document, which a `where_document` filter that matches an empty text then sees as one with an empty document. + +`ChromaMetadataConvert` converts .NET values to metadata values and back, always in the same form: `ToMetadataValue` writes a `DateTimeOffset` as round-trip text in UTC, so that equal instants are equal text, a `DateTime` as round-trip text with its `Kind`, a `DateOnly` as `yyyy-MM-dd`, and a sequence as a list; null and an empty sequence give null, no value. `FromMetadataValue(value, type)` reads a value of `ChromaMetadataValues.Exact` as the type, also arrays and lists, and throws an `InvalidCastException` for a value that does not convert. A filter with a converted value finds the values converted the same way. `ToMetadata(values)` builds the metadata of a record from keys and .NET values, each converted with `ToMetadataValue`; a value that converts to null stays as null, which deletes the key in `UpdateAsync` and `UpsertAsync`. The client does not convert the values of a metadata dictionary by itself. + +[docs/COMPATIBILITY.md](COMPATIBILITY.md) lists the versions that store lists in metadata, and what the client does on the others. The client asks the server for its version once, and only when a record has a list. + +Chroma 1.5.0 to 1.5.9 keep the lists of the records of a deleted collection or database, and give them to the next records they store, in any collection (KD-12 in [COMPATIBILITY.md](COMPATIBILITY.md#known-defects-of-the-servers)): delete them with `deleteRecordsFirst: true`, as [Collections and records](collections.md#collections-and-records) says. Every Chroma 1.x reports the same version, so the records go first on all of them. + +An existing `ChromaClient`, for example one from dependency injection, gives a client that reads values the other way. That client shares the `HttpClient`, the options and what was learned about the server. `Options` returns the options of a client: + +```csharp +var inferred = client.WithMetadataValues(ChromaMetadataValues.Inferred); +Console.WriteLine(inferred.Options.MetadataValues); // Inferred +``` + +`WithMetadataValues` of a collection client does the same for one collection. diff --git a/docs/mocking.md b/docs/mocking.md new file mode 100644 index 0000000..ce83b52 --- /dev/null +++ b/docs/mocking.md @@ -0,0 +1,11 @@ +# Mocks in tests + +`ChromaClient` and `ChromaCollectionClient` can be mocked, as in the Azure SDKs. Their members are virtual, and a protected constructor creates a client without a server, for a subclass or a mocking library. Only the members it overrides work. + +```csharp +sealed class FakeClient : ChromaClient +{ + public override Task GetCollectionAsync(string name, string? tenant = null, string? database = null, CancellationToken cancellationToken = default) + => Task.FromResult(new ChromaCollection(name)); +} +``` diff --git a/docs/observability.md b/docs/observability.md new file mode 100644 index 0000000..01437e3 --- /dev/null +++ b/docs/observability.md @@ -0,0 +1,33 @@ +# Health, traces and metrics + +## Health of the server + +```csharp +var health = await client.HealthcheckAsync(); +Console.WriteLine(health.IsExecutorReady); +``` + +[docs/COMPATIBILITY.md](COMPATIBILITY.md) lists the servers that have `HealthcheckAsync`. A server that is not ready answers `503`, and the client throws a `ChromaException`. + +## Traces and metrics + +```csharp +builder.Services.AddOpenTelemetry() + .WithTracing(tracing => tracing.AddSource(ChromaTelemetry.ActivitySourceName)) + .WithMetrics(metrics => metrics.AddMeter(ChromaTelemetry.MeterName)); +``` + +- **Spans:** each operation, like `query` or `add`, is a client span named after the operation and the collection, like `query articles`. Its requests, the batches of a large write too, are inside it. +- **Attributes**, from the OpenTelemetry semantic conventions for database clients: + - `db.system.name` `chroma`, `db.operation.name` and `db.collection.name`; + - `db.namespace`, the tenant and the database as `tenant|database`; + - `server.address` and `server.port`; + - on a failure, `error.type` and `db.response.status_code`, the HTTP status. +- **Metrics:** the duration of each operation in the `db.client.operation.duration` histogram, in seconds, with the same attributes. +- **Buckets of the histogram:** the boundaries the semantic conventions advise, 0.001, 0.005, 0.01, 0.05, 0.1, 0.5, 1, 5 and 10 seconds. OpenTelemetry 1.10 and later apply them. Without them, OpenTelemetry would use its default boundaries, made for milliseconds, and every operation under 5 seconds would fall in the same bucket. The netstandard2.0 build, used by applications on .NET Core 3.1 to 7, cannot advise them, so there the application adds a view with them: + + ```csharp + metrics.AddView("db.client.operation.duration", new ExplicitBucketHistogramConfiguration { Boundaries = [0.001, 0.005, 0.01, 0.05, 0.1, 0.5, 1, 5, 10] }); + ``` + +- **Cost:** without a listener, nothing is measured. A missing collection in `CollectionExistsAsync` is an answer, not an error. diff --git a/docs/platforms.md b/docs/platforms.md new file mode 100644 index 0000000..5abf0d5 --- /dev/null +++ b/docs/platforms.md @@ -0,0 +1,13 @@ +# Platforms + +## Builds + +The package has three builds: .NET 8, .NET Framework 4.6.2 and .NET Standard 2.0. The tests run the .NET 8 build on every tested Chroma version, and the .NET Standard 2.0 build on Chroma 1.5.9. The .NET Framework 4.6.2 build has the code of the .NET Standard 2.0 one, and also advises the buckets of the histogram, as the .NET 8 build does. It is built against the assemblies that .NET Framework applications ship, so it runs next to OpenTelemetry without binding redirects. + +## Trimming and NativeAOT + +The client serializes with metadata generated at build time, so it works in applications published with trimming or NativeAOT, where serialization by reflection is off. The packages are marked `IsAotCompatible`. + +The values of metadata and filters can be `string`, the numeric types, `bool`, `DateTime`, `DateTimeOffset`, `Guid`, `JsonElement`, arrays and lists of strings, numbers and booleans, `List` and `object[]`. Other types work only where reflection is on, as without trimming. + +The CI publishes `Samples/ChromaDB.Client.TrimmingTest` with trimming and with NativeAOT, with every warning as an error, and runs it against Chroma 1.5.9. With `CHROMA_HOST`, `CHROMA_API_KEY`, `CHROMA_TENANT` and `CHROMA_DATABASE`, it runs against Chroma Cloud. diff --git a/docs/queries-and-filters.md b/docs/queries-and-filters.md new file mode 100644 index 0000000..e2eef88 --- /dev/null +++ b/docs/queries-and-filters.md @@ -0,0 +1,45 @@ +# Queries and filters + +## Querying some records only + +```csharp +var results = await collectionClient.QueryAsync(new ReadOnlyMemory([1f, 0.5f, 0f]), nResults: 1, ids: ["a", "c"]); +var same = await collectionClient.QueryAsync(new ChromaQuery([new([1f, 0.5f, 0f])]) { Ids = ["a", "c"], NResults = 1 }); +``` + +`ChromaQuery` holds the query embeddings, the number of results, the filters, what to include and the ids to search among, and the offset: Chroma has no offset in queries, so the client asks for the skipped records too and leaves them out. An id without a record is left out: `QueryAsync` asks again with the ids that have one (KD-24 in [docs/COMPATIBILITY.md](COMPATIBILITY.md)). When a result falls outside the ids, `QueryAsync` throws a `ChromaException` instead of returning it (KD-36). With `ExpectedSpace`, `QueryAsync` throws an `InvalidOperationException` before the query when the collection has another space, whose distances would not be the expected ones. + +## Reading records with GetAsync + +`GetAsync` throws an `ArgumentOutOfRangeException` for a negative limit or offset, and returns no record for a limit of 0, without a get: Chroma 0.6.3 would take 0 as no limit. A client made by name still reads its collection first, so a collection that does not exist throws, as in the other reads. It reads more records than the batch size in pages: pages of the batch size from the offset, until the limit or the last record. Ids beyond the batch size go in batches, each id once, with the limit and the offset applied to all of them together. The pages are separate requests, so records written in between can be read twice or missed. + +## Filters + +`ToString()` of a `ChromaWhereOperator` or a `ChromaWhereDocumentOperator` returns the JSON the client sends: + +```csharp +var where = ChromaWhereOperator.Equal("year", 2026) & ChromaWhereOperator.In("lang", "en", "it"); +Console.WriteLine(where); // {"$and":[{"year":{"$eq":2026}},{"lang":{"$in":["en","it"]}}]} +``` + +- `ChromaWhereOperator` has `Equal`, `NotEqual`, `GreaterThan`, `GreaterThanOrEqual`, `LessThan`, `LessThanOrEqual`, `In`, `NotIn`, `Contains` and `NotContains`, combined with `&` and `|`. +- `ChromaWhereDocumentOperator` has `Contains`, `NotContains`, `Regex` and `NotRegex`. +- On Chroma Cloud a pattern of `Regex` or `NotRegex` has at most 256 bytes in UTF-8 by default, a quota that the tenant can raise: the client sends the pattern, and beyond the quota of the tenant throws a `ChromaException` that says the pattern, its bytes and the limit ([COMPATIBILITY.md](COMPATIBILITY.md)). A single server has no limit. +- `ChromaWhereDocumentOperator.FromDictionary` takes a filter on the documents written as the dictionary of the `where_document` of the Chroma API, like `{"$contains": "text"}`: get, query and delete send it as it is, and the Search API gets it on `#document`, as the client writes the other filters. For an operator other than `$contains`, `$not_contains`, `$regex`, `$not_regex`, `$and` and `$or` the search throws a `NotSupportedException` without a request. `Not` does not negate it. +- `ChromaWhereOperator.FromDictionary` takes a filter already written as the dictionary of the `where` of the Chroma API, as the Python client takes it, and sends it as it is: `FromDictionary(new Dictionary { ["year"] = new Dictionary { ["$gte"] = 2020 } })`. `Not` does not negate it. +- A chain of the same operator, like `a & b & c` or one built in a loop, goes as one list, `{"$and":[a,b,c]}`, as the Python client writes it. +- `Not` negates a filter. Chroma has no `$not`, so the negation goes into the operators: `$eq` becomes `$ne`, `$gt` becomes `$lte`, `$in` becomes `$nin`, `$contains` becomes `$not_contains`, `$regex` becomes `$not_regex`, and `$and` becomes `$or` of the negations. `$ne`, `$nin` and `$not_contains` match the records without the key, `$ne` and `$nin` from Chroma 0.5.15, and a comparison like `$lte` does not: `Not(GreaterThan("k", 5))` leaves out the records without `k`, as `GreaterThan("k", 5)` does. +- `ChromaWhereOperator` also filters the ids, with `Equal` and `In` on `ChromaSearchKeys.Id`, and the documents, with `Document(filter)`, as the where clause of the Search API does. Get, query and delete take neither in their `where`: the client sends them as the ids and the `where_document` of the request, which takes them only joined to the other conditions with `&`, and throws a `NotSupportedException` otherwise, like for an id inside an `|`. +- `ChromaWhereOperator.All` matches every record and `None` no record, which Chroma has no filter for: the client sends no `where` for `All`, and no request for `None`, so a read with it returns nothing and a delete deletes nothing. `&` and `|` simplify with them, and each is a single instance, which `Not` also returns. `In` without values is `None`, and `NotIn` without values `All` (KD-46 in [docs/COMPATIBILITY.md](COMPATIBILITY.md)). + +### Very long filters + +A single Chroma server limits how deep a list of filters can go: [Other behaviors of the servers](COMPATIBILITY.md#other-behaviors-of-the-servers) in COMPATIBILITY.md, and KD-4. + +So the client counts that depth, the length of the lists along the deepest path of the filter. Beyond 900, it splits each list of n filters into ⌈√n⌉ lists with the same meaning. + +`ChromaWhereDocumentOperator.Regex` and `NotRegex` filter the documents with a regular expression; [docs/COMPATIBILITY.md](COMPATIBILITY.md) lists the versions. On Chroma Cloud two filters on the documents give the wrong records, also in a delete: a pattern that takes an empty text, like `^$`, `^` or `^.*$`, finds every record with `Regex` and none with `NotRegex` (KD-56), and, once Chroma Cloud has indexed the records, a text of fewer than 3 characters, like `ab`, finds no record with `Contains` and every record with `NotContains` (KD-57). So there the client throws an `ArgumentException` before the request, in every read and delete. It reads a pattern as Chroma does, with the syntax of the Rust crate `regex`, also a nested or POSIX class such as `[[:alpha:]]`, a word boundary such as `\b{start-half}` and the verbose mode `(?x)`, and it throws too for a pattern it cannot read, which Chroma rejects. `Regex("(?s).")` finds the records with a document that is not empty, `NotRegex("(?s).")` the records with an empty document or none, and `Regex` and `NotRegex` with a short text as the pattern, its special characters escaped, like `Regex("ab")`, the same records as `Contains` and `NotContains`. + +```csharp +var apples = await collectionClient.GetAsync(whereDocument: ChromaWhereDocumentOperator.Regex("^apple")); +``` diff --git a/docs/records.md b/docs/records.md new file mode 100644 index 0000000..cffac13 --- /dev/null +++ b/docs/records.md @@ -0,0 +1,44 @@ +# Records + +## Records with URIs + +```csharp +await collectionClient.AddAsync(new ChromaRecords(["a"]) { Embeddings = [new([1f, 0.5f, 0f])], Uris = ["s3://bucket/a.png"] }); + +var entries = await collectionClient.GetAsync(["a"], include: ChromaGetInclude.Uris); +Console.WriteLine(entries[0].Uri); +``` + +`ChromaRecords` holds the ids, embeddings, metadata, documents and URIs that `AddAsync`, `UpdateAsync` and `UpsertAsync` write. + +Pass the embeddings yourself, or let the client compute them from the documents with an embedding generator: [Embeddings](embeddings.md). + +## Large writes + +By default, `AddAsync`, `UpdateAsync`, `UpsertAsync` and `DeleteAsync` send their records in batches of the server's `max_batch_size`, one request after the other. The client asks `pre-flight-checks` once. Before the first batch, the client checks that the embeddings, metadatas, documents and URIs are as many as the ids, and that the embeddings hold finite numbers, and throws an `ArgumentException` otherwise. If a batch fails, the earlier ones stay written, and the `ChromaException` says how many records went. A collection client made by name then does not run the write again on a collection created again under the name. A server without `pre-flight-checks` ([docs/COMPATIBILITY.md](COMPATIBILITY.md)) gets the records in one request, and so does a server whose answer the client cannot read. `WithBatchSplitting(false)` sends them in one request, as earlier versions did. + +```csharp +var options = new ChromaConfigurationOptions(uri: "http://localhost:8000").WithBatchSplitting(false); +``` + +`WithBatchSplitting(maxBatchSize)` uses the smaller of that limit and the server's, or that limit alone where the server declares none. + +On Chroma Cloud: [Batches on Chroma Cloud](chroma-cloud.md#batches-on-chroma-cloud). + +## Deleting records + +```csharp +var deleted = await collectionClient.DeleteAsync(new ChromaDelete { WhereDocument = ChromaWhereDocumentOperator.Contains("draft"), Limit = 100 }); +``` + +`ChromaDelete` holds the ids, the filters and the limit of a delete. + +- Without ids it deletes by the filters only. Without ids and filters it throws an `ArgumentException`, since it would select every record. +- `DeleteAllAsync()` deletes every record of the collection, which stays, and returns how many it deleted: a page of ids at a time, as many as the batch size of the server and of Chroma Cloud, read from the start. +- As in Chroma and its Python client, the ids cannot be an empty list, and the limit needs a `where` or `where_document` filter and cannot be negative. +- Chroma 1.5.3 and later apply `Limit` and answer a count, which `DeleteAsync` returns: for a delete by ids it is the number of ids given, also those without a record. On earlier servers it returns null. +- Earlier servers ignore the limit and would delete every matching record. So before a delete with a limit, the client reads the OpenAPI description of the server once, and throws a `ChromaException` without sending the delete if the limit is not declared there. + +## Embeddings in base64 + +Where `pre-flight-checks` declares `supports_base64_encoding`, `AddAsync`, `UpdateAsync` and `UpsertAsync` send the embeddings as base64 strings of their float32 values, about half the size of the numbers. Queries always send numbers. Elsewhere, or when `pre-flight-checks` does not answer, the embeddings go as numbers. [docs/COMPATIBILITY.md](COMPATIBILITY.md) lists the versions. diff --git a/docs/search.md b/docs/search.md new file mode 100644 index 0000000..7c0dce0 --- /dev/null +++ b/docs/search.md @@ -0,0 +1,64 @@ +# Search + +The servers that serve the Search API: [COMPATIBILITY.md](COMPATIBILITY.md#chroma-cloud). + +```csharp +var results = await collectionClient.SearchAsync(new ChromaSearch +{ + Where = ChromaWhereOperator.GreaterThanOrEqual("year", 2021), + WhereDocument = ChromaWhereDocumentOperator.Contains("apple"), + Rank = ChromaRank.Knn(new([0.1f, 0.2f, 0.3f])), + Limit = 10, + Select = [ChromaSearchKeys.Document, ChromaSearchKeys.Score, "title"], +}); +foreach (var result in results) +{ + Console.WriteLine($"{result.Id} {result.Score} {result.Document} {result.Metadata?["title"]}"); +} +``` + +`ChromaSearch` holds the filters, which combine with `$and`, the ids, the ranking, the page and the fields to return. Without `Select`, a search returns only the ids. The records with the lowest score come first, but with `ChromaRank.HybridRrf` as the whole rank, whose results come with the fused score, positive, highest first. Inside an expression, like `HybridRrf(...) * 0.5`, the score comes as Chroma sends it, lowest first. + +`ChromaRank` builds the ranking: + +- `Knn` ranks by distance from a vector; +- `Value`, `Sum`, `Multiply`, `Max`, `Min`, `Abs`, `Exp`, `Log` and the operators `+ - * /` combine rankings; +- `Rrf` fuses several rankings, built as the Python client builds it. + +```csharp +var fused = ChromaRank.Rrf([ChromaRank.Knn(vector1, returnRank: true), ChromaRank.Knn(vector2, returnRank: true)]); +var perCategory = new ChromaSearchGroupBy(ChromaSearchAggregate.MinK(3, ChromaSearchKeys.Score), "category"); +var many = await collectionClient.SearchAsync([new ChromaSearch { Rank = fused, Limit = 5 }, new ChromaSearch { GroupBy = perCategory, Rank = fused }]); +``` + +`ChromaSearchGroupBy` keeps, for each value of the metadata keys, the records the aggregate chooses. Several searches go in one request, and their results come back in order. `ToString()` of a `ChromaRank` returns its JSON, where a text query of `SparseKnn` appears as text; `SearchAsync` sends its sparse vector. + +`SearchAsync(search, ChromaReadLevel.IndexOnly)` leaves out the records not indexed yet. `ChromaSearchAggregate.MinK` keeps the records with the lowest values of the keys, the best ranked with `ChromaSearchKeys.Score`, and `MaxK` those with the highest. + +## Hybrid search with BM25 + +```csharp +var collection = await client.CreateCollectionAsync(new ChromaCollectionDefinition("articles") +{ + Schema = new ChromaCollectionSchema().WithSparseVectorIndex("doc_bm25", ChromaSearchKeys.Document, bm25: true, new ChromaBm25().Reference), +}); +var collectionClient = client.GetCollectionClient(collection); +await collectionClient.AddAsync(new ChromaRecords(ids) { Embeddings = embeddings, Documents = documents }); +var results = await collectionClient.SearchAsync(new ChromaSearch +{ + Rank = ChromaRank.Rrf([ChromaRank.Knn(queryEmbedding, returnRank: true), ChromaRank.SparseKnn(queryText, "doc_bm25", returnRank: true)]), + Limit = 10, + Select = [ChromaSearchKeys.Document, ChromaSearchKeys.Score], +}); +``` + +- **What `ChromaBm25` computes:** the BM25 vectors as the Python client computes them, `chroma_bm25` with the Snowball English stemmer of snowballstemmer 3.1.1. The same text gives the same indices and values in .NET and in Python, so a collection written by one is searched by the other. +- **How it was tested:** against the Python client on more than 5,000 texts, with the characters of every Unicode script that Python 3.13 knows, on .NET 8 and on .NET Framework, outside the CI. On every change, `ChromaBm25Tests` compares the vectors of a few texts with those of Python. It follows the Unicode rules of Python from its own tables, not those of the runtime. +- **What the client computes, as the Python client does:** + - In `AddAsync`, `UpdateAsync` and `UpsertAsync`, the vectors of each sparse vector index of the schema with a source key and `chroma_bm25`, from the document or from the text in the metadata key, with the settings of the schema. An empty text gets an empty vector, which replaces the vector of the old text, as with the Python client. A record whose metadata already has the key keeps its vector. The records and the metadata you pass do not change. + - In `SearchAsync`, the vector of the text of `SparseKnn(queryText, key)`, with the function of the index of the key. + - The schema comes with the collection, from `CreateCollectionAsync` or `GetCollectionAsync`. A collection client created from an id alone has no schema, so a text query throws a `ChromaException`. So does a function other than `chroma_bm25`, unless the metadata has the vectors. +- **By hand:** `new ChromaBm25()` with the same settings, or `Bm25Function` of the index, gives the vectors to put in the metadata or in `SparseKnn`. `Embed(text)` returns the vector of a text, and `Reference` declares the function in the schema. +- **Other functions:** `ChromaEmbeddingFunctionReference.Known(name, config)` declares another function that the clients of Chroma know. The client only declares it. +- **Records without the terms of the query:** Chroma Cloud ranks them too, among the `limit` of `SparseKnn`, with the score 1, one minus the dot product. With `returnRank` they take the next positions, so in `Rrf` they get points from the sparse part as well. `ChromaRank.HybridRrf(embedding, text, key, limit)` fuses the dense search and the BM25 search of the text so that only the records with a term of the text get points from it; the others keep the order of the dense search. +- **License:** the license of the stemmer is in [THIRD-PARTY-NOTICES.md](https://github.com/ChromaDotNet/ChromaDB.Client/blob/main/THIRD-PARTY-NOTICES.md). diff --git a/docs/sparse-vectors-and-schema.md b/docs/sparse-vectors-and-schema.md new file mode 100644 index 0000000..040064d --- /dev/null +++ b/docs/sparse-vectors-and-schema.md @@ -0,0 +1,51 @@ +# Sparse vectors and schema + +Chroma Cloud keeps sparse vectors, like the BM25 vectors of the documents, in a metadata key with a sparse vector index, which the schema of the collection declares: + +```csharp +var collection = await client.CreateCollectionAsync(new ChromaCollectionDefinition("articles") +{ + Schema = new ChromaCollectionSchema().WithSparseVectorIndex("doc_bm25", ChromaSearchKeys.Document, bm25: true, ChromaEmbeddingFunctionReference.ChromaBm25()), +}); +var collectionClient = client.GetCollectionClient(collection); +await collectionClient.AddAsync(new ChromaRecords(["a"]) +{ + Embeddings = [embedding], + Documents = ["apple pie"], + Metadatas = [new Dictionary { ["doc_bm25"] = new ChromaSparseVector([17, 4242], [0.8f, 1.1f]) }], +}); +var results = await collectionClient.SearchAsync(new ChromaSearch { Rank = ChromaRank.SparseKnn(queryVector, "doc_bm25"), Limit = 10 }); +``` + +- `ChromaSparseVector` holds the indices, in strictly ascending order, their values, and optionally the tokens. It is a metadata value, written as the Python client writes it: `{"#type": "sparse_vector", "indices": [...], "values": [...]}`. +- **Reading it back:** a sparse vector comes back as a `ChromaSparseVector`, or as a `JsonElement` with `ChromaMetadataValues.Inferred`. +- **Where it works:** + - the servers that store them: [COMPATIBILITY.md](COMPATIBILITY.md); + - Chroma 0.x would drop them without an error, so the client throws a `ChromaException` before sending them. +- **`ChromaCollectionSchema`** declares the indexes of a new collection: + - With `bm25`, the server applies the inverse document frequency of BM25. A source key needs an embedding function, because Chroma Cloud rejects one without the other. + - `ChromaEmbeddingFunctionReference.ChromaBm25()` declares the BM25 function of Chroma with the settings of its Python client, so the clients that know it compute the vectors. + - `ChromaCollection.SparseVectorIndexes` and `ChromaCollection.SchemaJson` read it back. `EmbeddingFunctionConfig` of an index holds the settings of its function, and `Bm25Function` the `ChromaBm25` with those settings. `FindBm25Index(sourceKey)` returns the BM25 index on the text of a key, like `#document`, or null. + - `WithBm25Index(sourceKey)` declares a BM25 index with `chroma_bm25` on the text of a metadata key or of the documents, on the key named after the source, like `title_bm25` or `document_bm25`. With `ifSupported: true` the client creates the collection without it on a server other than Chroma Cloud, which rejects it, and on Chroma Cloud when the key is longer than 36 bytes. A write that gives that key a value other than a sparse vector throws an `ArgumentException`. `FindBm25IndexAsync(sourceKey)` of a collection client finds the index, and for the document copy key also the one on the documents. + - `ToString()` returns the JSON the client sends. +- **The indexes of the values**, like `create_index` and `delete_index` of the Python client. `WithIndex` and `WithoutIndex` turn on or off: + - the index of the string, integer, floating-point or Boolean values (`ChromaSchemaIndex.StringInverted`, `IntInverted`, `FloatInverted`, `BoolInverted`) of a metadata key, or of every key without a setting of its own; + - the full-text search index of the documents (`FullTextSearch`), on `#document` only. + + They are all on by default; [docs/COMPATIBILITY.md](COMPATIBILITY.md) lists the versions that apply them. + + ```csharp + var schema = new ChromaCollectionSchema() + .WithoutIndex(ChromaSchemaIndex.StringInverted, "body") + .WithoutIndex(ChromaSchemaIndex.FullTextSearch); + ``` + +- **On Chroma Cloud only:** + - the algorithm of a sparse vector index, `WithSparseVectorIndex(key, ChromaSparseIndexAlgorithm.MaxScore, ...)`, which `ChromaSparseVectorIndex.Algorithm` reads back, for the tenants that have it; + - a customer-managed key of Google Cloud KMS, `WithGcpCmek("projects/.../locations/.../keyRings/.../cryptoKeys/...")`, like `set_cmek` of the Python client. +- **The space with a schema:** `Configuration = new() { Space = ... }` goes in the schema, on `#embedding`, as `create_index(VectorIndexConfig(space=...))` of the Python client writes it, and not in the `hnsw:space` metadata. Chroma rejects the two together: "Cannot set both collection config and schema simultaneously". +- **Where the schema works:** + - the versions that apply it: [COMPATIBILITY.md](COMPATIBILITY.md); + - a single server rejects a sparse vector index, and does not get the ones added with `ifSupported`; + - Chroma 1.0.0 to 1.2.2 and 0.6.3 create the collection without the schema. Then `CreateCollectionAsync` deletes it and throws a `ChromaException`, and `GetOrCreateCollectionAsync` throws and keeps it, since it may have existed before; + - Chroma 1.3.0 ignores the space in a schema that turns no index on or off, and applies it when the schema also turns an index on or off; 1.3.2 and later always apply it. When the collection comes back with another space, the client does the same. diff --git a/docs/upsert-strategies.md b/docs/upsert-strategies.md new file mode 100644 index 0000000..dbbb7fb --- /dev/null +++ b/docs/upsert-strategies.md @@ -0,0 +1,20 @@ +# Upsert strategies + +[Records lost after an update of their embeddings](COMPATIBILITY.md#records-lost-after-an-update-of-their-embeddings), in COMPATIBILITY.md, describes the defect of the servers. + +## In the client: upsert strategies + +When the server cannot be changed, `WithUpsertStrategy` on the options says how `UpdateAsync` and `UpsertAsync` write the embeddings of records that exist: + +```csharp +var options = new ChromaConfigurationOptions("http://localhost:8000").WithUpsertStrategy(ChromaUpsertStrategy.SkipUnchangedEmbeddings); +``` + +| Strategy | What it protects | What it costs | +|---|---|---| +| `Server`, the default | nothing: the update and the upsert of the server | nothing more | +| `SkipUnchangedEmbeddings` | the records written again with the same embedding, as with new metadata or a new document: they are updated without it, which does not touch the vector index | a get of the records first | + +With `SkipUnchangedEmbeddings` no record is deleted, so there is no risk of data loss. A write with both unchanged and changed embeddings goes in two requests, the update of the unchanged records first. If it fails, the second request does not go, so the write can stay applied in part. The exception says how many records went in the requests that answered before the error. What the failed request did is not known: it can still be applied. A record whose embedding changes still goes by the upsert of the server, and then the vector index can still lose a record, often another one. So does an id given more than once in the same write, in the order given, as Chroma applies the writes in order, so it stays exposed too. In a `cosine` collection, where Chroma 1.x gives an embedding back 1 or 2 ulp off (KD-8), values within 4 ulp count as the same; an embedding computed again by a model can differ by more, and then it goes as changed. On Chroma 0.x, which has not KD-49, the writes go as they are. Chroma 0.5.15, 0.5.16, 0.5.20 and 0.6.3, the versions tested, have another defect, KD-51, which the strategies do not change: after some upserts a query of all the records can miss one, until the next write. A `Microsoft.Extensions.VectorData` store built on a `ChromaClient` with these options, which writes by upserts with the embeddings, gets it too. + +Turn it on for Chroma 1.0.21 to 1.5.9 installed on your own servers without `RAYON_NUM_THREADS=1`, where records are written again with the embeddings they had. Measured on Chroma 1.5.9, in a series of runs separate from the one of [Records lost after an update of their embeddings](COMPATIBILITY.md#records-lost-after-an-update-of-their-embeddings), so the numbers of `Server` differ a little from those of its table: in a `cosine` collection of 60 records with random vectors, of which 20 are written again with the same vectors and new metadata, 300 runs: a query of all the records missed one in 28 runs with `Server`, and in none with `SkipUnchangedEmbeddings`. With 120 records of which 20 get new vectors, 200 runs, they missed one in 22 runs with `Server` and in 25 with `SkipUnchangedEmbeddings`, which cannot help there. diff --git a/tools/generate_bm25_unicode.py b/tools/generate_bm25_unicode.py new file mode 100644 index 0000000..53bb5ca --- /dev/null +++ b/tools/generate_bm25_unicode.py @@ -0,0 +1,88 @@ +"""Generates ChromaDB.Client/Common/Bm25Unicode.cs: the Unicode rules of Python that the BM25 tokenizer of Chroma follows. + +Run it with the Python that the BM25 function of Chroma runs on; it writes the tables of its Unicode version, so that ChromaBm25 gives +the same tokens on every .NET runtime, whatever Unicode version the runtime knows: + python tools/generate_bm25_unicode.py > ChromaDB.Client/Common/Bm25Unicode.cs +""" +import sys +import unicodedata + +MAX = 0x110000 + + +def ranges(predicate): + result, start = [], None + for c in range(MAX + 1): + inside = c < MAX and not (0xD800 <= c <= 0xDFFF) and predicate(c) + if inside and start is None: + start = c + elif not inside and start is not None: + result.append((start, c - 1)) + start = None + return result + + +def word(c): # \w of re in Unicode: str.isalnum() or "_" + return chr(c).isalnum() or c == 0x5F + + +def space(c): # \s of re in Unicode, and str.isspace() + return chr(c).isspace() + + +# The Final_Sigma context of str.lower(), read from the behavior of Python itself, for every character: the text has only word +# characters and spaces, but the stopwords are lowercased as they are. "X" + sigma ends with a final sigma when X is cased and not +# case-ignorable; "A" + X + sigma ends with one when X is case-ignorable or cased. +def final(text): + return text.lower().endswith("ς") + + +def cased(c): + return final(chr(c) + "Σ") + + +def case_ignorable(c): + return not final(chr(c) + "Σ") and final("A" + chr(c) + "Σ") + + +def array(name, items): + lines = [] + for i in range(0, len(items), 16): + lines.append("\t\t" + ", ".join(f"0x{x:X}" for x in items[i:i + 16]) + ",") + return f"\tinternal static readonly int[] {name} =\n\t[\n" + "\n".join(lines) + "\n\t];\n" + + +def flat(rs): + return [x for r in rs for x in r] + + +def escape(text): + return "".join(f"\\u{ord(x):04X}" if ord(x) < 0x10000 else f"\\U{ord(x):08X}" for x in text) + + +lower = [(c, chr(c).lower()) for c in range(MAX) if not (0xD800 <= c <= 0xDFFF) and chr(c).lower() != chr(c)] +single = [(c, ord(l)) for c, l in lower if len(l) == 1] +multi = [(c, l) for c, l in lower if len(l) != 1] + +out = sys.stdout +out.write("// \n") +out.write(f"// Generated by tools/generate_bm25_unicode.py with Python {sys.version.split()[0]}, Unicode {unicodedata.unidata_version}.\n") +out.write("// \n\n") +out.write("namespace ChromaDB.Client.Common;\n\n") +out.write("// The Unicode rules of Python that the BM25 tokenizer follows. The ranges are pairs of first and last code point, in order.\n") +out.write("internal static class Bm25Unicode\n{\n") +out.write("\t// \\w: str.isalnum() or \"_\".\n") +out.write(array("WordRanges", flat(ranges(word)))) +out.write("\n\t// \\s: str.isspace().\n") +out.write(array("SpaceRanges", flat(ranges(space)))) +out.write("\n\t// The characters that are cased and not case-ignorable, for the final sigma.\n") +out.write(array("CasedRanges", flat(ranges(cased)))) +out.write("\n\t// The characters that are case-ignorable, for the final sigma.\n") +out.write(array("CaseIgnorableRanges", flat(ranges(case_ignorable)))) +out.write("\n\t// str.lower() of the characters that change, without the context of the final sigma: pairs of code point and lowercase.\n") +out.write(array("Lowercase", flat(single))) +out.write("\n\t// The ones whose lowercase has more than one code point.\n") +out.write("\tinternal static readonly Dictionary LowercaseLonger = new()\n\t{\n") +for c, l in multi: + out.write(f"\t\t[0x{c:X}] = \"{escape(l)}\",\n") +out.write("\t};\n}\n") diff --git a/v2-migration/MIGRATION_GUIDE_V2.md b/v2-migration/MIGRATION_GUIDE_V2.md new file mode 100644 index 0000000..f3aa9c1 --- /dev/null +++ b/v2-migration/MIGRATION_GUIDE_V2.md @@ -0,0 +1,173 @@ +# Migration guide: 1.x to 2.x + +ChromaDotNet.Client 2.x uses the v2 API of Chroma, and its methods follow the .NET conventions. Both are **breaking changes**: the URI, the package id and the method names change. + +This fork publishes the package as `ChromaDotNet.Client`; the namespaces stay `ChromaDB.Client`. + +## What changed + +Chroma moved from the v1 API to the v2 API. The main change is the structure of the URLs: + +**v1 API:** +``` +http://localhost:8000/api/v1/ +``` + +**v2 API:** +``` +http://localhost:8000/api/v2/ +``` + +In the v2 API the tenant and the database are part of the path, instead of query parameters: + +- **v1**: `/api/v1/collections?tenant={t}&database={d}` +- **v2**: `/api/v2/tenants/{t}/databases/{d}/collections` + +## Migration steps + +### Step 1: check the Chroma server + +The v2 API is available from Chroma 0.5.16. Chroma 0.5.16 to 0.5.23 serve both v1 and v2, the v1 API of 0.6.3 fails on most requests, and 1.0 and later serve only v2. + +### Step 2: update the configuration + +Change the URI of `ChromaConfigurationOptions` from `/api/v1/` to `/api/v2/`: + +**Before (1.x):** +```csharp +var configOptions = new ChromaConfigurationOptions(uri: "http://localhost:8000/api/v1/"); +``` + +**After (2.x):** +```csharp +var configOptions = new ChromaConfigurationOptions(uri: "http://localhost:8000/api/v2/"); +``` + +Just the address of the server works too: `new ChromaConfigurationOptions(uri: "http://localhost:8000")` gets `/api/v2/`. A URI with another path is used as it is. + +### Step 3: update the package reference + +Reference the latest `ChromaDotNet.Client`: + +```bash +dotnet add package ChromaDotNet.Client +``` + +## What else changes + +- **Namespaces:** they stay the same. Keep `using ChromaDB.Client;`, and `using ChromaDB.Client.Models;` for the models. +- **Features:** everything in 1.x still works, and there is much more: see the README. +- **Methods:** they follow the .NET conventions. The names end in `Async` (`GetOrCreateCollectionAsync`, `AddAsync`, `QueryAsync`...), they take an optional `CancellationToken`, and they take and return read-only lists and dictionaries. See the [breaking changes summary](#breaking-changes-summary). +- **Request and response models:** the same data. `ChromaCollectionQueryEntry.Distance` is a `float?`, and a record has `Embedding` and `Uri` for its embedding and its URI. + +## Example: a complete migration + +**Before (1.x):** +```csharp +using ChromaDB.Client; + +var configOptions = new ChromaConfigurationOptions(uri: "http://localhost:8000/api/v1/"); +using var httpClient = new HttpClient(); +var client = new ChromaClient(configOptions, httpClient); + +var collection = await client.GetOrCreateCollection("my_collection"); +var collectionClient = new ChromaCollectionClient(collection, configOptions, httpClient); + +await collectionClient.Add( + ["doc1"], + embeddings: [new([1f, 0.5f, 0f, -0.5f, -1f])] +); +``` + +**After (2.x):** +```csharp +using ChromaDB.Client; + +// /api/v1/ → /api/v2/ +var configOptions = new ChromaConfigurationOptions(uri: "http://localhost:8000/api/v2/"); +using var httpClient = new HttpClient(); +var client = new ChromaClient(configOptions, httpClient); + +// The methods end in Async +var collection = await client.GetOrCreateCollectionAsync("my_collection"); +// The client of the collection shares the options and the HttpClient of the client +var collectionClient = client.GetCollectionClient(collection); + +await collectionClient.AddAsync( + ["doc1"], + embeddings: [new([1f, 0.5f, 0f, -0.5f, -1f])] +); +``` + +`new ChromaCollectionClient(collection, configOptions, httpClient)` still works; `client.GetCollectionClient(collection)` gives the same client without repeating the options and the `HttpClient`. + +## Workarounds of 1.x to remove + +Some applications kept 1.x working on Chroma 1.x with a `DelegatingHandler` that rewrote the routes from `/api/v1/` to `/api/v2/`, added the tenant and the database to the path, or read them from `auth/identity`. 2.x sends the v2 routes itself, with the tenant and the database of the options (`default_tenant` and `default_database` when they are not set), so remove such a handler. To take the tenant and the database from the credentials, as on Chroma Cloud, call `await client.WithTenantAndDatabaseFromIdentityAsync()`: it returns a client with the ones that `auth/identity` gives. + +## Dependency injection + +`ChromaDotNet.Client.DependencyInjection` registers the client in `Microsoft.Extensions.DependencyInjection`: `services.AddChromaClient(_ => options)`, `AddKeyedChromaClient` for more than one server, tenant or database, and `serviceProvider.CreateChromaClient(options, httpClientName)` for a client kept elsewhere. `AddChromaClientFromServices(serviceProvider => options)` makes the options from the services of the application, like `IConfiguration`, when the client is resolved. The client sends its requests through `IHttpClientFactory`, so a DNS change of the server is seen. See [docs/dependency-injection.md](../docs/dependency-injection.md). + +## Behaviors to know + +These are not breaking changes, but they differ from 1.x: + +- **Reads go again.** After an error that can pass, like `500` or a request that fails on the network, a read goes again, up to 4 attempts by default, with a random delay of 40 ms or more; a write never goes again. `WithReadRetries(ChromaReadRetries.None)` turns it off. See [docs/errors.md](../docs/errors.md#reads-that-go-again). +- **Distances.** `ChromaCollectionQueryEntry.Distance` is `NaN` where Chroma sends no number, like the distance to an embedding beyond the range of a float on Chroma 1.x. +- **Empty metadata.** An empty metadata dictionary goes as no metadata, and every tested Chroma treats the two the same. +- **Embeddings of a query.** `QueryAsync` returns the embeddings only with `ChromaQueryInclude.Embeddings`, as in 1.x: by default it returns the metadata, the documents and the distances. + +## Troubleshooting + +### "404 Not Found" errors + +A 404 after the upgrade usually means that the URI still points to the v1 API: + +- check that the URI contains `/api/v2/`, not `/api/v1/`; +- check that the Chroma server supports the v2 API. + +Chroma 1.0 and later answer the v1 routes with `410 Gone` and the message "The v1 API is deprecated. Please use /v2 apis". + +### Version of the server + +Chroma supports the v2 API from 0.5.16. To check the version of the server: + +```csharp +var version = await client.GetVersionAsync(); +Console.WriteLine($"ChromaDB Server Version: {version}"); +``` + +## Breaking changes summary + +| Change | Impact | What to do | +|--------|--------|------------| +| API endpoint | High | Change the URI from `/api/v1/` to `/api/v2/` | +| Package id | High | Reference `ChromaDotNet.Client` instead of `ChromaDB.Client` | +| URL structure | None | The client handles it | +| Request and response | Low | `ChromaCollectionQueryEntry.Distance` is `float?`, null when the query does not include `ChromaQueryInclude.Distances` | +| Method names | Medium | Add `Async` to the name of each asynchronous method: `GetOrCreateCollection` → `GetOrCreateCollectionAsync`, `Add` → `AddAsync`, `Query` → `QueryAsync` | +| Cancellation | Low | Every asynchronous method takes an optional `CancellationToken`. Rebuild, and turn a method group like `client.Heartbeat` passed as a `Func` into `() => client.HeartbeatAsync()` | +| Lists and dictionaries | Low | Parameters and results are `IReadOnlyList` and `IReadOnlyDictionary`. A `List` or a collection expression still goes in, and metadata are written `new Dictionary { ["key"] = value }` | +| Records | Low | The embedding of a record is `Embedding` and its URI is `Uri`. `Data` is gone, as no Chroma server fills it | +| Metadata values | Low | Strings come back as strings and lists as `List`. `WithMetadataValues(ChromaMetadataValues.Inferred)` reads them as 1.x did, with dates as `DateTime` | +| Filters | Low | `ChromaWhere` and `ChromaWhereDocument` are gone. Use `ChromaWhereOperator` and `ChromaWhereDocumentOperator`, the types the methods take | +| Options constructor | Low | `defaultTenant:` and `defaultDatabase:` are now `tenant:` and `database:`: `new ChromaConfigurationOptions(uri, tenant: ..., database: ...)` | +| Batches | Low | Writes go in batches of the server's `max_batch_size`, and `GetAsync` reads in pages. `WithBatchSplitting(false)` sends each of them in one request | +| Errors | Low | Network errors, answers that are not the expected JSON, and timeouts throw a `ChromaException`, with the original exception as `InnerException` | + +## Need help? + +If something goes wrong during the migration: + +1. check that the Chroma server supports the v2 API; +2. check that the URI uses `/api/v2/`; +3. read the error message: it tells when the server is not compatible; +4. open an issue on GitHub. + +## Going back to 1.x + +To go back to the v1 API: + +1. go back to the original package, ChromaDB.Client 1.x: remove `ChromaDotNet.Client` and run `dotnet add package ChromaDB.Client --version 1.0.0`; +2. change the URI back to `/api/v1/`.